diff --git a/packages/react-aria-widgets-demo/src/App.jsx b/packages/react-aria-widgets-demo/src/App.jsx
index 4fa0afd2..3b9453f7 100644
--- a/packages/react-aria-widgets-demo/src/App.jsx
+++ b/packages/react-aria-widgets-demo/src/App.jsx
@@ -3,6 +3,10 @@ import '@charlie-yao/react-aria-widgets/styles.css';
//Components and Styles
import DemoAccordion from 'src/DemoAccordion';
+import MenuBarOne from 'src/MenuBarOne';
+import MenuBarTwo from 'src/MenuBarTwo';
+import MenuButtonOne from 'src/MenuButtonOne';
+import MenuButtonTwo from 'src/MenuButtonTwo';
import 'src/styles.css';
function App() {
@@ -10,6 +14,11 @@ function App() {
Accordion
+ Menu, Menubar, Menu Button
+
+
+
+
);
}
diff --git a/packages/react-aria-widgets-demo/src/MenuBarOne.jsx b/packages/react-aria-widgets-demo/src/MenuBarOne.jsx
new file mode 100644
index 00000000..12871cb4
--- /dev/null
+++ b/packages/react-aria-widgets-demo/src/MenuBarOne.jsx
@@ -0,0 +1,437 @@
+import React from 'react';
+import { MenuBar } from '@charlie-yao/react-aria-widgets/menu';
+
+class MenuBarOne extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ radioGroupOne: undefined,
+ checkboxOneState: false,
+ checkboxTwoState: false,
+ radioGroupTwo: undefined,
+ radioGroupThree: undefined,
+ radioGroupFour: undefined,
+ checkboxThreeState: false,
+ checkboxParent: false,
+ checkboxChild1: false,
+ checkboxChild2: false,
+ checkboxChild3: false,
+ disableAll: false,
+ };
+ }
+
+ //---- Events ----
+ onChangeRadioGroupOne = (event) => {
+ this.setState({
+ radioGroupOne: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxOne = () => {
+ this.setState(state => {
+ return {
+ checkboxOneState: !state.checkboxOneState,
+ };
+ });
+ };
+
+ onToggleCheckboxTwo = () => {
+ this.setState(state => {
+ return {
+ checkboxTwoState: !state.checkboxTwoState,
+ };
+ });
+ };
+
+ onChangeRadioGroupTwo = (event) => {
+ this.setState({
+ radioGroupTwo: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupThree = (event) => {
+ this.setState({
+ radioGroupThree: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupFour = (event) => {
+ this.setState({
+ radioGroupFour: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxThree = () => {
+ this.setState(state => {
+ return {
+ checkboxThreeState: !state.checkboxThreeState,
+ };
+ });
+ };
+
+ onActivateItem = () => {
+ alert('Hello from menubar item!');
+ };
+
+ onActivateSubmenuItem = (event) => {
+ alert(`Hello from ${event.target.textContent}`);
+ };
+
+ onToggleCheckboxParent = () => {
+ this.setState(state => {
+ const { checkboxParent } = state;
+ let newValue;
+
+ if(checkboxParent || checkboxParent === 'mixed')
+ newValue = false;
+ else if(!checkboxParent)
+ newValue = true;
+
+ return {
+ checkboxParent: newValue,
+ checkboxChild1: newValue,
+ checkboxChild2: newValue,
+ checkboxChild3: newValue,
+ };
+ });
+ };
+
+ onToggleCheckboxChild1 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild1 = !checkboxChild1;
+ const allTrue = newChild1 && checkboxChild2 && checkboxChild3;
+ const allFalse = !newChild1 && !checkboxChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild1: newChild1,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild2 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild2 = !checkboxChild2;
+ const allTrue = checkboxChild1 && newChild2 && checkboxChild3;
+ const allFalse = !checkboxChild1 && !newChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild2: newChild2,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild3 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild3 = !checkboxChild3;
+ const allTrue = checkboxChild1 && checkboxChild2 && newChild3;
+ const allFalse = !checkboxChild1 && !checkboxChild2 && !newChild3;
+
+ return {
+ checkboxChild3: newChild3,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleDisableAll = () => {
+ this.setState(state => {
+ return {
+ disableAll: !state.disableAll,
+ };
+ });
+ };
+
+ //---- Rendering ----
+ render() {
+ return (
+
+ );
+ }
+
+ //---- Misc. ----
+ getItems = () => {
+ const {
+ radioGroupOne, checkboxOneState, checkboxTwoState, radioGroupTwo,
+ radioGroupThree, radioGroupFour, checkboxThreeState,
+ checkboxParent, checkboxChild1, checkboxChild2, checkboxChild3,
+ disableAll,
+ } = this.state;
+
+ return [
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 1',
+ children: [
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupOne,
+ children: [
+ {
+ node: 'Radio Option 1',
+ value: 'option1',
+ isChecked: radioGroupOne === 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ value: 'option2',
+ isChecked: radioGroupOne === 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ value: 'option3',
+ isChecked: radioGroupOne === 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 1',
+ isChecked: checkboxOneState,
+ onActivate: this.onToggleCheckboxOne,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 2',
+ isChecked: checkboxTwoState,
+ onActivate: this.onToggleCheckboxTwo,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupTwo,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupTwo === 'option1',
+ value: 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupTwo === 'option2',
+ value: 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupTwo === 'option3',
+ value: 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 2',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupThree,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupThree === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupThree === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupThree === 'option3',
+ value: 'option3',
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupFour,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupFour === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupFour === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupFour === 'option3',
+ value: 'option3',
+ isDisabled: true,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 3',
+ onActivate: this.onToggleCheckboxThree,
+ isChecked: checkboxThreeState,
+ isDisabled: true,
+ },
+ {
+ type: 'checkbox',
+ node: 'Disable All',
+ onActivate: this.onToggleDisableAll,
+ isChecked: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 3',
+ children: [
+ {
+ type: 'checkbox',
+ node: 'Checkbox Parent',
+ isChecked: checkboxParent,
+ onActivate: this.onToggleCheckboxParent,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 1',
+ isChecked: checkboxChild1,
+ onActivate: this.onToggleCheckboxChild1,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 2',
+ isChecked: checkboxChild2,
+ onActivate: this.onToggleCheckboxChild2,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 3',
+ isChecked: checkboxChild3,
+ onActivate: this.onToggleCheckboxChild3,
+ },
+ ],
+ },
+ ];
+ };
+}
+
+export default MenuBarOne;
diff --git a/packages/react-aria-widgets-demo/src/MenuBarTwo.jsx b/packages/react-aria-widgets-demo/src/MenuBarTwo.jsx
new file mode 100644
index 00000000..2c79c357
--- /dev/null
+++ b/packages/react-aria-widgets-demo/src/MenuBarTwo.jsx
@@ -0,0 +1,444 @@
+import React from 'react';
+import { MenuBar } from '@charlie-yao/react-aria-widgets/menu';
+
+class MenuBarTwo extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ radioGroupOne: undefined,
+ checkboxOneState: false,
+ checkboxTwoState: false,
+ radioGroupTwo: undefined,
+ radioGroupThree: undefined,
+ radioGroupFour: undefined,
+ checkboxThreeState: false,
+ checkboxParent: false,
+ checkboxChild1: false,
+ checkboxChild2: false,
+ checkboxChild3: false,
+ disableAll: false,
+ };
+ }
+
+ //---- Events ----
+ onChangeRadioGroupOne = (event) => {
+ this.setState({
+ radioGroupOne: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxOne = () => {
+ this.setState(state => {
+ return {
+ checkboxOneState: !state.checkboxOneState,
+ };
+ });
+ };
+
+ onToggleCheckboxTwo = () => {
+ this.setState(state => {
+ return {
+ checkboxTwoState: !state.checkboxTwoState,
+ };
+ });
+ };
+
+ onChangeRadioGroupTwo = (event) => {
+ this.setState({
+ radioGroupTwo: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupThree = (event) => {
+ this.setState({
+ radioGroupThree: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupFour = (event) => {
+ this.setState({
+ radioGroupFour: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxThree = () => {
+ this.setState(state => {
+ return {
+ checkboxThreeState: !state.checkboxThreeState,
+ };
+ });
+ };
+
+ onActivateItem = () => {
+ alert('Hello from menubar item!');
+ };
+
+ onActivateSubmenuItem = (event) => {
+ alert(`Hello from ${event.target.textContent}`);
+ };
+
+ onToggleCheckboxParent = () => {
+ this.setState(state => {
+ const { checkboxParent } = state;
+ let newValue;
+
+ if(checkboxParent || checkboxParent === 'mixed')
+ newValue = false;
+ else if(!checkboxParent)
+ newValue = true;
+
+ return {
+ checkboxParent: newValue,
+ checkboxChild1: newValue,
+ checkboxChild2: newValue,
+ checkboxChild3: newValue,
+ };
+ });
+ };
+
+ onToggleCheckboxChild1 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild1 = !checkboxChild1;
+ const allTrue = newChild1 && checkboxChild2 && checkboxChild3;
+ const allFalse = !newChild1 && !checkboxChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild1: newChild1,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild2 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild2 = !checkboxChild2;
+ const allTrue = checkboxChild1 && newChild2 && checkboxChild3;
+ const allFalse = !checkboxChild1 && !newChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild2: newChild2,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild3 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild3 = !checkboxChild3;
+ const allTrue = checkboxChild1 && checkboxChild2 && newChild3;
+ const allFalse = !checkboxChild1 && !checkboxChild2 && !newChild3;
+
+ return {
+ checkboxChild3: newChild3,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleDisableAll = () => {
+ this.setState(state => {
+ return {
+ disableAll: !state.disableAll,
+ };
+ });
+ };
+
+ //---- Rendering ----
+ render() {
+ return (
+
+ );
+ }
+
+ //---- Misc. ----
+ getItems = () => {
+ const {
+ radioGroupOne, checkboxOneState, checkboxTwoState, radioGroupTwo,
+ radioGroupThree, radioGroupFour, checkboxThreeState,
+ checkboxParent, checkboxChild1, checkboxChild2, checkboxChild3,
+ disableAll,
+ } = this.state;
+
+ return [
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 1',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupOne,
+ children: [
+ {
+ node: 'Radio Option 1',
+ value: 'option1',
+ isChecked: radioGroupOne === 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ value: 'option2',
+ isChecked: radioGroupOne === 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ value: 'option3',
+ isChecked: radioGroupOne === 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 1',
+ isChecked: checkboxOneState,
+ onActivate: this.onToggleCheckboxOne,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 2',
+ isChecked: checkboxTwoState,
+ onActivate: this.onToggleCheckboxTwo,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupTwo,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupTwo === 'option1',
+ value: 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupTwo === 'option2',
+ value: 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupTwo === 'option3',
+ value: 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 2',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupThree,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupThree === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupThree === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupThree === 'option3',
+ value: 'option3',
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupFour,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupFour === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupFour === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupFour === 'option3',
+ value: 'option3',
+ isDisabled: true,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 3',
+ onActivate: this.onToggleCheckboxThree,
+ isChecked: checkboxThreeState,
+ isDisabled: true,
+ },
+ {
+ type: 'checkbox',
+ node: 'Disable All',
+ onActivate: this.onToggleDisableAll,
+ isChecked: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 3',
+ children: [
+ {
+ type: 'checkbox',
+ node: 'Checkbox Parent',
+ isChecked: checkboxParent,
+ onActivate: this.onToggleCheckboxParent,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 1',
+ isChecked: checkboxChild1,
+ onActivate: this.onToggleCheckboxChild1,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 2',
+ isChecked: checkboxChild2,
+ onActivate: this.onToggleCheckboxChild2,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 3',
+ isChecked: checkboxChild3,
+ onActivate: this.onToggleCheckboxChild3,
+ },
+ ],
+ },
+ ];
+ };
+}
+
+export default MenuBarTwo;
diff --git a/packages/react-aria-widgets-demo/src/MenuButtonOne.jsx b/packages/react-aria-widgets-demo/src/MenuButtonOne.jsx
new file mode 100644
index 00000000..5c101efc
--- /dev/null
+++ b/packages/react-aria-widgets-demo/src/MenuButtonOne.jsx
@@ -0,0 +1,439 @@
+import React from 'react';
+import { MenuButton } from '@charlie-yao/react-aria-widgets/menu';
+
+class MenuButtonOne extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ radioGroupOne: undefined,
+ checkboxOneState: false,
+ checkboxTwoState: false,
+ radioGroupTwo: undefined,
+ radioGroupThree: undefined,
+ radioGroupFour: undefined,
+ checkboxThreeState: false,
+ checkboxParent: false,
+ checkboxChild1: false,
+ checkboxChild2: false,
+ checkboxChild3: false,
+ disableAll: false,
+ };
+ }
+
+ //---- Events ----
+ onChangeRadioGroupOne = (event) => {
+ this.setState({
+ radioGroupOne: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxOne = () => {
+ this.setState(state => {
+ return {
+ checkboxOneState: !state.checkboxOneState,
+ };
+ });
+ };
+
+ onToggleCheckboxTwo = () => {
+ this.setState(state => {
+ return {
+ checkboxTwoState: !state.checkboxTwoState,
+ };
+ });
+ };
+
+ onChangeRadioGroupTwo = (event) => {
+ this.setState({
+ radioGroupTwo: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupThree = (event) => {
+ this.setState({
+ radioGroupThree: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupFour = (event) => {
+ this.setState({
+ radioGroupFour: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxThree = () => {
+ this.setState(state => {
+ return {
+ checkboxThreeState: !state.checkboxThreeState,
+ };
+ });
+ };
+
+ onActivateItem = () => {
+ alert('Hello from menubar item!');
+ };
+
+ onActivateSubmenuItem = (event) => {
+ alert(`Hello from ${event.target.textContent}`);
+ };
+
+ onToggleCheckboxParent = () => {
+ this.setState(state => {
+ const { checkboxParent } = state;
+ let newValue;
+
+ if(checkboxParent || checkboxParent === 'mixed')
+ newValue = false;
+ else if(!checkboxParent)
+ newValue = true;
+
+ return {
+ checkboxParent: newValue,
+ checkboxChild1: newValue,
+ checkboxChild2: newValue,
+ checkboxChild3: newValue,
+ };
+ });
+ };
+
+ onToggleCheckboxChild1 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild1 = !checkboxChild1;
+ const allTrue = newChild1 && checkboxChild2 && checkboxChild3;
+ const allFalse = !newChild1 && !checkboxChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild1: newChild1,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild2 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild2 = !checkboxChild2;
+ const allTrue = checkboxChild1 && newChild2 && checkboxChild3;
+ const allFalse = !checkboxChild1 && !newChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild2: newChild2,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild3 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild3 = !checkboxChild3;
+ const allTrue = checkboxChild1 && checkboxChild2 && newChild3;
+ const allFalse = !checkboxChild1 && !checkboxChild2 && !newChild3;
+
+ return {
+ checkboxChild3: newChild3,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleDisableAll = () => {
+ this.setState(state => {
+ return {
+ disableAll: !state.disableAll,
+ };
+ });
+ };
+
+ //---- Rendering ----
+ render() {
+ return (
+
+ Test Button!
+
+ );
+ }
+
+ //---- Misc. ----
+ getItems = () => {
+ const {
+ radioGroupOne, checkboxOneState, checkboxTwoState, radioGroupTwo,
+ radioGroupThree, radioGroupFour, checkboxThreeState,
+ checkboxParent, checkboxChild1, checkboxChild2, checkboxChild3,
+ disableAll,
+ } = this.state;
+
+ return [
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 1',
+ children: [
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupOne,
+ children: [
+ {
+ node: 'Radio Option 1',
+ value: 'option1',
+ isChecked: radioGroupOne === 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ value: 'option2',
+ isChecked: radioGroupOne === 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ value: 'option3',
+ isChecked: radioGroupOne === 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 1',
+ isChecked: checkboxOneState,
+ onActivate: this.onToggleCheckboxOne,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 2',
+ isChecked: checkboxTwoState,
+ onActivate: this.onToggleCheckboxTwo,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupTwo,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupTwo === 'option1',
+ value: 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupTwo === 'option2',
+ value: 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupTwo === 'option3',
+ value: 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 2',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupThree,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupThree === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupThree === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupThree === 'option3',
+ value: 'option3',
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupFour,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupFour === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupFour === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupFour === 'option3',
+ value: 'option3',
+ isDisabled: true,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 3',
+ onActivate: this.onToggleCheckboxThree,
+ isChecked: checkboxThreeState,
+ isDisabled: true,
+ },
+ {
+ type: 'checkbox',
+ node: 'Disable All',
+ onActivate: this.onToggleDisableAll,
+ isChecked: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 3',
+ children: [
+ {
+ type: 'checkbox',
+ node: 'Checkbox Parent',
+ isChecked: checkboxParent,
+ onActivate: this.onToggleCheckboxParent,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 1',
+ isChecked: checkboxChild1,
+ onActivate: this.onToggleCheckboxChild1,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 2',
+ isChecked: checkboxChild2,
+ onActivate: this.onToggleCheckboxChild2,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 3',
+ isChecked: checkboxChild3,
+ onActivate: this.onToggleCheckboxChild3,
+ },
+ ],
+ },
+ ];
+ };
+}
+
+export default MenuButtonOne;
diff --git a/packages/react-aria-widgets-demo/src/MenuButtonTwo.jsx b/packages/react-aria-widgets-demo/src/MenuButtonTwo.jsx
new file mode 100644
index 00000000..f608cdb5
--- /dev/null
+++ b/packages/react-aria-widgets-demo/src/MenuButtonTwo.jsx
@@ -0,0 +1,442 @@
+import React from 'react';
+import { MenuButton } from '@charlie-yao/react-aria-widgets/menu';
+
+class MenuButtonTwo extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ radioGroupOne: undefined,
+ checkboxOneState: false,
+ checkboxTwoState: false,
+ radioGroupTwo: undefined,
+ radioGroupThree: undefined,
+ radioGroupFour: undefined,
+ checkboxThreeState: false,
+ checkboxParent: false,
+ checkboxChild1: false,
+ checkboxChild2: false,
+ checkboxChild3: false,
+ disableAll: false,
+ };
+ }
+
+ //---- Events ----
+ onChangeRadioGroupOne = (event) => {
+ this.setState({
+ radioGroupOne: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxOne = () => {
+ this.setState(state => {
+ return {
+ checkboxOneState: !state.checkboxOneState,
+ };
+ });
+ };
+
+ onToggleCheckboxTwo = () => {
+ this.setState(state => {
+ return {
+ checkboxTwoState: !state.checkboxTwoState,
+ };
+ });
+ };
+
+ onChangeRadioGroupTwo = (event) => {
+ this.setState({
+ radioGroupTwo: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupThree = (event) => {
+ this.setState({
+ radioGroupThree: event.target.dataset.value,
+ });
+ };
+
+ onChangeRadioGroupFour = (event) => {
+ this.setState({
+ radioGroupFour: event.target.dataset.value,
+ });
+ };
+
+ onToggleCheckboxThree = () => {
+ this.setState(state => {
+ return {
+ checkboxThreeState: !state.checkboxThreeState,
+ };
+ });
+ };
+
+ onActivateItem = () => {
+ alert('Hello from menubar item!');
+ };
+
+ onActivateSubmenuItem = (event) => {
+ alert(`Hello from ${event.target.textContent}`);
+ };
+
+ onToggleCheckboxParent = () => {
+ this.setState(state => {
+ const { checkboxParent } = state;
+ let newValue;
+
+ if(checkboxParent || checkboxParent === 'mixed')
+ newValue = false;
+ else if(!checkboxParent)
+ newValue = true;
+
+ return {
+ checkboxParent: newValue,
+ checkboxChild1: newValue,
+ checkboxChild2: newValue,
+ checkboxChild3: newValue,
+ };
+ });
+ };
+
+ onToggleCheckboxChild1 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild1 = !checkboxChild1;
+ const allTrue = newChild1 && checkboxChild2 && checkboxChild3;
+ const allFalse = !newChild1 && !checkboxChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild1: newChild1,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild2 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild2 = !checkboxChild2;
+ const allTrue = checkboxChild1 && newChild2 && checkboxChild3;
+ const allFalse = !checkboxChild1 && !newChild2 && !checkboxChild3;
+
+ return {
+ checkboxChild2: newChild2,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleCheckboxChild3 = () => {
+ this.setState(state => {
+ const { checkboxChild1, checkboxChild2, checkboxChild3 } = state;
+ const newChild3 = !checkboxChild3;
+ const allTrue = checkboxChild1 && checkboxChild2 && newChild3;
+ const allFalse = !checkboxChild1 && !checkboxChild2 && !newChild3;
+
+ return {
+ checkboxChild3: newChild3,
+ checkboxParent: allTrue ? true : (allFalse ? false : 'mixed'),
+ };
+ });
+ };
+
+ onToggleDisableAll = () => {
+ this.setState(state => {
+ return {
+ disableAll: !state.disableAll,
+ };
+ });
+ };
+
+ //---- Rendering ----
+ render() {
+ return (
+
+ Test Button!
+
+ );
+ }
+
+ //---- Misc. ----
+ getItems = () => {
+ const {
+ radioGroupOne, checkboxOneState, checkboxTwoState, radioGroupTwo,
+ radioGroupThree, radioGroupFour, checkboxThreeState,
+ checkboxParent, checkboxChild1, checkboxChild2, checkboxChild3,
+ disableAll,
+ } = this.state;
+
+ return [
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 1',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupOne,
+ children: [
+ {
+ node: 'Radio Option 1',
+ value: 'option1',
+ isChecked: radioGroupOne === 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ value: 'option2',
+ isChecked: radioGroupOne === 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ value: 'option3',
+ isChecked: radioGroupOne === 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 1',
+ isChecked: checkboxOneState,
+ onActivate: this.onToggleCheckboxOne,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 2',
+ isChecked: checkboxTwoState,
+ onActivate: this.onToggleCheckboxTwo,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupTwo,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupTwo === 'option1',
+ value: 'option1',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupTwo === 'option2',
+ value: 'option2',
+ isDisabled: disableAll,
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupTwo === 'option3',
+ value: 'option3',
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 2',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ orientation: 'horizontal',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'menu',
+ node: 'Nested Parent Menuitem',
+ children: [
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ {
+ type: 'item',
+ node: 'Hello world!',
+ onActivate: this.onActivateSubmenuItem,
+ isDisabled: disableAll,
+ },
+ ],
+ },
+
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupThree,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupThree === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupThree === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupThree === 'option3',
+ value: 'option3',
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'radiogroup',
+ onActivate: this.onChangeRadioGroupFour,
+ children: [
+ {
+ node: 'Radio Option 1',
+ isChecked: radioGroupFour === 'option1',
+ value: 'option1',
+ },
+ {
+ node: 'Radio Option 2',
+ isChecked: radioGroupFour === 'option2',
+ value: 'option2',
+ },
+ {
+ node: 'Radio Option 3',
+ isChecked: radioGroupFour === 'option3',
+ value: 'option3',
+ isDisabled: true,
+ },
+ ],
+ },
+ {
+ type: 'separator',
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox 3',
+ onActivate: this.onToggleCheckboxThree,
+ isChecked: checkboxThreeState,
+ isDisabled: true,
+ },
+ {
+ type: 'checkbox',
+ node: 'Disable All',
+ onActivate: this.onToggleDisableAll,
+ isChecked: disableAll,
+ },
+ {
+ type: 'menu',
+ node: 'Parent Menuitem 3',
+ children: [
+ {
+ type: 'checkbox',
+ node: 'Checkbox Parent',
+ isChecked: checkboxParent,
+ onActivate: this.onToggleCheckboxParent,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 1',
+ isChecked: checkboxChild1,
+ onActivate: this.onToggleCheckboxChild1,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 2',
+ isChecked: checkboxChild2,
+ onActivate: this.onToggleCheckboxChild2,
+ },
+ {
+ type: 'checkbox',
+ node: 'Checkbox Child 3',
+ isChecked: checkboxChild3,
+ onActivate: this.onToggleCheckboxChild3,
+ },
+ ],
+ },
+ ];
+ };
+}
+
+export default MenuButtonTwo;
diff --git a/packages/react-aria-widgets/package.json b/packages/react-aria-widgets/package.json
index c8dcd5a7..0db9e2f2 100644
--- a/packages/react-aria-widgets/package.json
+++ b/packages/react-aria-widgets/package.json
@@ -19,6 +19,7 @@
"exports": {
".": "dist/index.bundle.js",
"./accordion": "dist/accordion.bundle.js",
+ "./menu": "dist/menu.bundle.js",
"./styles.css": "dist/index.css"
},
"publishConfig": {
diff --git a/packages/react-aria-widgets/src/Menu/Menu.jsx b/packages/react-aria-widgets/src/Menu/Menu.jsx
new file mode 100644
index 00000000..83322032
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/Menu.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/*
+ * Note:
+ *
+ * - The menu should either have a labelId prop that points to the menuitem or
+ * button that controls its display XOR a label prop.
+ */
+function Menu(props) {
+ const { children, orientation, label, labelId, id } = props;
+
+ return (
+
+ );
+}
+
+Menu.propTypes = {
+ children: PropTypes.node.isRequired,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ id: PropTypes.string,
+};
+
+Menu.defaultProps = {
+ orientation: 'vertical',
+ label: undefined,
+ labelId: undefined,
+ id: undefined,
+};
+
+export default Menu;
diff --git a/packages/react-aria-widgets/src/Menu/MenuBar.jsx b/packages/react-aria-widgets/src/Menu/MenuBar.jsx
new file mode 100644
index 00000000..66831258
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuBar.jsx
@@ -0,0 +1,260 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+//HOCs
+import createMenuManager from 'src/Menu/createMenuManager';
+
+//Misc.
+import { MENUITEMS_PROPTYPE } from 'src/utils/propTypes';
+import { renderItems } from 'src/Menu/utils';
+
+/*
+ * Note:
+ *
+ * - If the menubar has a visible label, a labelId prop that points towards
+ * the labeling element should be provided. Otherwise, one should pass in
+ * a label via the label prop. In other words, one XOR the other must be provided.
+ */
+class MenuBar extends React.Component {
+ static propTypes = {
+ items: MENUITEMS_PROPTYPE.isRequired,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ //From MenuManager
+ setItemRef: PropTypes.func.isRequired,
+ tabbableIndex: PropTypes.number.isRequired,
+ expandedIndex: PropTypes.number.isRequired,
+ collapseItem: PropTypes.func.isRequired,
+ expandItem: PropTypes.func.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focusPrevItem: PropTypes.func.isRequired,
+ focusNextItem: PropTypes.func.isRequired,
+ focusFirstItem: PropTypes.func.isRequired,
+ focusLastItem: PropTypes.func.isRequired,
+ focusItemFirstChild: PropTypes.func.isRequired,
+ focusItemLastChild: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ orientation: 'horizontal',
+ label: undefined,
+ labelId: undefined,
+ };
+
+ //---- Events ----
+ onChildClick = (event) => {
+ const { items, expandedIndex, collapseItem, expandItem } = this.props
+ const { target } = event;
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radigroups
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(type === 'menu') {
+ if(expandedIndex === flattenedIndex)
+ collapseItem();
+ else
+ expandItem(flattenedIndex);
+ }
+ else if(type === 'checkbox' || type === 'radiogroup' || type === 'item') {
+ if(isDisabled)
+ return;
+
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ };
+
+ onChildKeyDown = (event) => {
+ const {
+ items, orientation,
+ collapseItem, expandItem, focusPrevItem, focusNextItem, focusFirstItem, focusLastItem,
+ focusItemFirstChild, focusItemLastChild,
+ } = this.props;
+ const { key, target } = event;
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radigroups
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(key === 'ArrowUp' || key === 'Up') {
+ event.preventDefault();
+
+ if(orientation === 'horizontal') {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemLastChild(flattenedIndex);
+ });
+ }
+ }
+ else
+ focusPrevItem(flattenedIndex);
+ }
+ else if(key === 'ArrowDown' || key === 'Down') {
+ event.preventDefault();
+
+ if(orientation === 'horizontal') {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ }
+ else
+ focusNextItem(flattenedIndex);
+ }
+ else if(key === 'ArrowLeft' || key === 'Left') {
+ event.preventDefault();
+
+ if(orientation === 'horizontal')
+ focusPrevItem(flattenedIndex);
+ else {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemLastChild(flattenedIndex);
+ });
+ }
+ }
+ }
+ else if(key === 'ArrowRight' || key === 'Right') {
+ event.preventDefault();
+
+ if(orientation === 'horizontal')
+ focusNextItem(flattenedIndex);
+ else {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ }
+ }
+ else if(key === 'Enter') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ }
+ else if(key === ' ' || key === 'Spacebar') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ }
+ else if(key === 'Home') {
+ event.preventDefault();
+ focusFirstItem();
+ }
+ else if(key === 'End') {
+ event.preventDefault();
+ focusLastItem();
+ }
+ else if(key === 'Tab')
+ collapseItem();
+ };
+
+ onChildOver = (event) => {
+ const { items, expandedIndex, expandItem, focusItem } = this.props;
+ const { target } = event;
+
+ console.log(target);
+
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type } = item;
+
+ if(expandedIndex === -1)
+ return;
+
+ focusItem(flattenedIndex);
+
+ if(type === 'menu')
+ expandItem(flattenedIndex);
+ };
+
+ onChildLeave = () => {
+ };
+
+ //---- Rendering ----
+ render() {
+ const {
+ items, orientation, label, labelId,
+ setItemRef, tabbableIndex, expandedIndex, collapseItem, focusItem, focusPrevItem, focusNextItem,
+ } = this.props;
+ const itemNodes = renderItems({
+ items,
+ setItemRef,
+ tabbableIndex,
+ expandedIndex,
+ collapseItem,
+ focusRootItem: focusItem,
+ focusPrevRootItem: focusPrevItem,
+ focusNextRootItem: focusNextItem,
+ position: [],
+ flattenedPosition: [],
+ onChildKeyDown: this.onChildKeyDown,
+ onChildClick: this.onChildClick,
+ onChildOver: this.onChildOver,
+ onChildLeave: this.onChildLeave,
+ });
+
+ return (
+
+ );
+ }
+}
+
+export default createMenuManager(MenuBar);
diff --git a/packages/react-aria-widgets/src/Menu/MenuButton.jsx b/packages/react-aria-widgets/src/Menu/MenuButton.jsx
new file mode 100644
index 00000000..e53c9e45
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuButton.jsx
@@ -0,0 +1,337 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+//Components and Styles
+import Menu from 'src/Menu/Menu';
+
+//HOCs
+import createMenuButtonManager from 'src/Menu/createMenuButtonManager';
+import createMenuManager from 'src/Menu/createMenuManager';
+
+//Misc.
+import { MENUITEMS_PROPTYPE } from 'src/utils/propTypes';
+import { renderItems } from 'src/Menu/utils';
+
+/*
+ * Note about labels and IDs:
+ *
+ * 1) Passing in an id prop means giving the menu an aria-labelledby attribute
+ * that points to the . Passing a menuLabel prop means giving the menu
+ * an aria-label attribute. However, since the menu is controlled by the
+ * and the is likely going to contain accessible label text, chances are,
+ * you should be using the id prop to label the menu instead of the menuLabel prop.
+ *
+ * See:
+ * https://www.w3.org/TR/wai-aria-1.1/#aria-label
+ *
+ * This also means that, as it's current programmed, the menu's aria-labelledby
+ * attribute cannot point to any other HTML element other than the
+ * controlling it.
+ *
+ * 2) One can optionally pass in a menuId prop, giving the an
+ * aria-controls attribute that points to the menu.
+ */
+class MenuButton extends React.Component {
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ items: MENUITEMS_PROPTYPE.isRequired,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+ menuLabel: PropTypes.string,
+ menuId: PropTypes.string,
+ id: PropTypes.string,
+ //From MenuButtonManager
+ isExpanded: PropTypes.bool.isRequired,
+ collapse: PropTypes.func.isRequired,
+ expand: PropTypes.func.isRequired,
+ //From MenuManager
+ setManagerRef: PropTypes.func.isRequired,
+ setItemRef: PropTypes.func.isRequired,
+ expandedIndex: PropTypes.number.isRequired,
+ collapseItem: PropTypes.func.isRequired,
+ expandItem: PropTypes.func.isRequired,
+ focus: PropTypes.func.isRequired,
+ focusPrevItem: PropTypes.func.isRequired,
+ focusNextItem: PropTypes.func.isRequired,
+ focusFirstItem: PropTypes.func.isRequired,
+ focusLastItem: PropTypes.func.isRequired,
+ focusItemFirstChild: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ orientation: 'vertical',
+ menuLabel: undefined,
+ menuId: undefined,
+ id: undefined,
+ };
+
+ //---- Events ----
+ onClick = () => {
+ const { isExpanded, collapse, expand } = this.props;
+
+ if(isExpanded)
+ collapse();
+ else
+ expand();
+ };
+
+ onKeyDown = (event) => {
+ const { expand, focusFirstItem, focusLastItem } = this.props;
+ const { key } = event;
+
+ if(key === 'Enter' || key === ' ' || key === 'Spacebar') {
+ event.preventDefault();
+
+ expand(() => {
+ focusFirstItem();
+ });
+ }
+ else if(key === 'ArrowUp' || key === 'Up') {
+ event.preventDefault();
+
+ expand(() => {
+ focusLastItem();
+ });
+ }
+ else if(key === 'ArrowDown' || key === 'Down') {
+ event.preventDefault();
+
+ expand(() => {
+ focusFirstItem();
+ });
+ }
+ };
+
+ onChildClick = (event) => {
+ const { items, collapse, expandedIndex, collapseItem, expandItem, focus } = this.props
+ const { target } = event;
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radigroups
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(type === 'menu') {
+ if(expandedIndex === flattenedIndex)
+ collapseItem();
+ else
+ expandItem(flattenedIndex);
+ }
+ else if(type === 'checkbox' || type === 'radiogroup' || type === 'item') {
+ if(isDisabled)
+ return;
+
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ };
+
+ onChildKeyDown = (event) => {
+ const {
+ items, orientation, collapse,
+ expandItem, focus, focusPrevItem, focusNextItem, focusFirstItem, focusLastItem,
+ focusItemFirstChild,
+ } = this.props;
+ const { key, target } = event;
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radiogroups
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(key === 'ArrowUp' || key === 'Up') {
+ event.preventDefault();
+
+ if(orientation === 'vertical')
+ focusPrevItem(flattenedIndex);
+ }
+ else if(key === 'ArrowDown' || key === 'Down') {
+ event.preventDefault();
+
+ if(orientation === 'vertical')
+ focusNextItem(flattenedIndex);
+ else {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ }
+ }
+ else if(key === 'ArrowLeft' || key === 'Left') {
+ event.preventDefault();
+
+ if(orientation === 'horizontal')
+ focusPrevItem(flattenedIndex);
+ }
+ else if(key === 'ArrowRight' || key === 'Right') {
+ event.preventDefault();
+
+ if(orientation === 'vertical') {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ }
+ else
+ focusNextItem(flattenedIndex);
+ }
+ else if(key === 'Enter') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ }
+ else if(key === ' ' || key === 'Spacebar') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ }
+ else if(key === 'Home') {
+ event.preventDefault();
+ focusFirstItem();
+ }
+ else if(key === 'End') {
+ event.preventDefault();
+ focusLastItem();
+ }
+ else if(key === 'Escape' || key === 'Esc') {
+ collapse(false, () => {
+ focus();
+ });
+ }
+ else if(key === 'Tab')
+ collapse();
+ };
+
+ onChildOver = () => {
+ const { items, expandedIndex, expandItem, focusItem } = this.props;
+ const { target } = event;
+
+ console.log(target);
+
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type } = item;
+
+ focusItem(flattenedIndex);
+
+ if(type === 'menu')
+ expandItem(flattenedIndex);
+ };
+
+ onChildLeave = () => {
+ };
+
+ //---- Rendering ----
+ render() {
+ const {
+ children, items, orientation, menuLabel, menuId, id, isExpanded,
+ setManagerRef, setItemRef, expandedIndex, collapseItem, focus,
+ } = this.props;
+ const itemNodes = renderItems({
+ items,
+ setItemRef,
+ expandedIndex,
+ collapseItem,
+ focusRootItem: focus,
+ position: [ 0 ],
+ flattenedPosition: [ 0 ],
+ onChildKeyDown: this.onChildKeyDown,
+ onChildClick: this.onChildClick,
+ onChildOver: this.onChildOver,
+ onChildLeave: this.onChildLeave,
+ });
+
+ return (
+
+
+ { children }
+
+
+ { itemNodes }
+
+
+ );
+ }
+}
+
+export default createMenuButtonManager(createMenuManager(MenuButton));
diff --git a/packages/react-aria-widgets/src/Menu/MenuItem.jsx b/packages/react-aria-widgets/src/Menu/MenuItem.jsx
new file mode 100644
index 00000000..03c3e66e
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuItem.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const MenuItem = React.forwardRef(function MenuItem(props, ref) {
+ const {
+ children, position, flattenedPosition,
+ onKeyDown, onClick, onMouseOver, onMouseLeave,
+ isDisabled, isTabbable
+ } = props;
+
+ return (
+
+ { children }
+
+ );
+});
+
+MenuItem.propTypes = {
+ children: PropTypes.node.isRequired,
+ position: PropTypes.arrayOf(PropTypes.number).isRequired,
+ flattenedPosition: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onKeyDown: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onMouseOver: PropTypes.func.isRequired,
+ onMouseLeave: PropTypes.func.isRequired,
+ isDisabled: PropTypes.bool,
+ isTabbable: PropTypes.bool,
+};
+
+MenuItem.defaultProps = {
+ isDisabled: false,
+ isTabbable: false,
+};
+
+export default MenuItem;
diff --git a/packages/react-aria-widgets/src/Menu/MenuItemCheckbox.jsx b/packages/react-aria-widgets/src/Menu/MenuItemCheckbox.jsx
new file mode 100644
index 00000000..4e9f07b8
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuItemCheckbox.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const MenuItemCheckbox = React.forwardRef(function MenuItemCheckbox(props, ref) {
+ const {
+ children, position, flattenedPosition,
+ onKeyDown, onClick, onMouseOver, onMouseLeave,
+ isDisabled, isTabbable, isChecked
+ } = props;
+
+ return (
+
+ { children }
+
+ );
+});
+
+MenuItemCheckbox.propTypes = {
+ children: PropTypes.node.isRequired,
+ position: PropTypes.arrayOf(PropTypes.number).isRequired,
+ flattenedPosition: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onKeyDown: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onMouseOver: PropTypes.func.isRequired,
+ onMouseLeave: PropTypes.func.isRequired,
+ isDisabled: PropTypes.bool,
+ isTabbable: PropTypes.bool,
+ isChecked: PropTypes.oneOfType([
+ PropTypes.bool,
+ PropTypes.oneOf([ 'true', 'false', 'mixed' ]),
+ ]),
+};
+
+MenuItemCheckbox.defaultProps = {
+ isDisabled: false,
+ isTabbable: false,
+ isChecked: false,
+};
+
+export default MenuItemCheckbox;
diff --git a/packages/react-aria-widgets/src/Menu/MenuItemRadio.jsx b/packages/react-aria-widgets/src/Menu/MenuItemRadio.jsx
new file mode 100644
index 00000000..13e85ce6
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuItemRadio.jsx
@@ -0,0 +1,52 @@
+/* eslint-disable react/jsx-props-no-spreading */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const MenuItemRadio = React.forwardRef(function MenuItemRadio(props, ref) {
+ const {
+ children, position, flattenedPosition,
+ onKeyDown, onClick, onMouseOver, onMouseLeave,
+ isDisabled, isTabbable, isChecked, ...rest
+ } = props;
+
+ return (
+
+ { children }
+
+ );
+});
+
+MenuItemRadio.propTypes = {
+ children: PropTypes.node.isRequired,
+ position: PropTypes.arrayOf(PropTypes.number).isRequired,
+ flattenedPosition: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onKeyDown: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onMouseOver: PropTypes.func.isRequired,
+ onMouseLeave: PropTypes.func.isRequired,
+ isDisabled: PropTypes.bool,
+ isTabbable: PropTypes.bool,
+ isChecked: PropTypes.bool,
+};
+
+MenuItemRadio.defaultProps = {
+ isDisabled: false,
+ isTabbable: false,
+ isChecked: false,
+};
+
+export default MenuItemRadio;
diff --git a/packages/react-aria-widgets/src/Menu/MenuItemRadioGroup.jsx b/packages/react-aria-widgets/src/Menu/MenuItemRadioGroup.jsx
new file mode 100644
index 00000000..c903f3ea
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuItemRadioGroup.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/*
+ * Note that if a menu or menubar contains more than one group of
+ * menuitemradio elements or if the menu has a group of menuitemradio
+ * elements alongside other unrelated menu items:
+ *
+ * - the menuitemradio elements should be nested in an element with the "group" role
+ * - menuitemradio groups should be delimited with an element with the "separator" role
+ *
+ * See:
+ * https://www.w3.org/TR/wai-aria-1.1/#menuitemradio
+ */
+function MenuItemRadioGroup(props) {
+ const { children, label, labelId } = props;
+
+ return (
+
+
+
+ );
+}
+
+MenuItemRadioGroup.propTypes = {
+ children: PropTypes.node.isRequired,
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+};
+
+MenuItemRadioGroup.defaultProps = {
+ label: undefined,
+ labelId: undefined,
+};
+
+export default MenuItemRadioGroup;
diff --git a/packages/react-aria-widgets/src/Menu/MenuItemSeparator.jsx b/packages/react-aria-widgets/src/Menu/MenuItemSeparator.jsx
new file mode 100644
index 00000000..ababade4
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/MenuItemSeparator.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/*
+ * In the authoring practices guide, they say,
+ * "All separators should have aria-orientation consistent
+ * with the separator's orientation."
+ *
+ * That statement doesn't make much sense to me because it's
+ * tautological. At first glance, it seems like a typo and
+ * that the correct wording should be "consistent with the
+ * containing menu or menubar's orientation", but that also
+ * doesn't make sense. If a menu or menubar is oriented
+ * vertically, then the separator should be horizontal,
+ * and vice-versa, right?
+ *
+ * See:
+ * https://www.w3.org/TR/wai-aria-practices-1.1/#menu
+ */
+function MenuItemSeparator(props) {
+ const { children, orientation } = props;
+
+ return (
+
+ { children }
+
+ );
+}
+
+MenuItemSeparator.propTypes = {
+ children: PropTypes.node,
+ orientation: PropTypes.oneOf([ 'horizontal', 'vertical' ]),
+};
+
+MenuItemSeparator.defaultProps = {
+ children: undefined,
+ orientation: 'horizontal',
+};
+
+export default MenuItemSeparator;
diff --git a/packages/react-aria-widgets/src/Menu/ParentMenuItem.jsx b/packages/react-aria-widgets/src/Menu/ParentMenuItem.jsx
new file mode 100644
index 00000000..091eca85
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/ParentMenuItem.jsx
@@ -0,0 +1,333 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+//Components and Styles
+import Menu from 'src/Menu/Menu';
+
+//HOCs
+import createMenuManager from 'src/Menu/createMenuManager';
+
+//Misc.
+import { MENUITEMS_PROPTYPE } from 'src/utils/propTypes';
+import { renderItems } from 'src/Menu/utils';
+
+class ParentMenuItem extends React.Component {
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ items: MENUITEMS_PROPTYPE.isRequired,
+ position: PropTypes.arrayOf(PropTypes.number).isRequired,
+ flattenedPosition: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onKeyDown: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onMouseOver: PropTypes.func.isRequired,
+ onMouseLeave: PropTypes.func.isRequired,
+ collapse: PropTypes.func.isRequired,
+ focusPrevRootItem: PropTypes.func,
+ focusNextRootItem: PropTypes.func,
+ focusRootItem: PropTypes.func,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ isExpanded: PropTypes.bool,
+ isDisabled: PropTypes.bool,
+ isTabbable: PropTypes.bool,
+ //From MenuManager
+ setManagerRef: PropTypes.func.isRequired,
+ setItemRef: PropTypes.func.isRequired,
+ expandedIndex: PropTypes.number.isRequired,
+ collapseItem: PropTypes.func.isRequired,
+ expandItem: PropTypes.func.isRequired,
+ focus: PropTypes.func.isRequired,
+ focusPrevItem: PropTypes.func.isRequired,
+ focusNextItem: PropTypes.func.isRequired,
+ focusFirstItem: PropTypes.func.isRequired,
+ focusLastItem: PropTypes.func.isRequired,
+ focusItemFirstChild: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ focusPrevRootItem: undefined,
+ focusNextRootItem: undefined,
+ focusRootItem: undefined,
+ orientation: 'vertical',
+ label: undefined,
+ labelId: undefined,
+ isExpanded: false,
+ isDisabled: false,
+ isTabbable: false,
+ };
+
+ //---- Events ----
+ onChildClick = (event) => {
+ const { items, collapse, focusRootItem, expandedIndex, collapseItem, expandItem } = this.props
+ const { target } = event;
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radigroups
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const flattenedRootIndex = Number.parseInt(flattenedPosition[0], 10);
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(type === 'menu') {
+ if(expandedIndex === flattenedIndex)
+ collapseItem();
+ else
+ expandItem(flattenedIndex);
+ }
+ else if(type === 'checkbox' || type === 'radiogroup' || type === 'item') {
+ if(isDisabled)
+ return;
+
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(true, () => {
+ focusRootItem(flattenedRootIndex);
+ });
+ }
+ };
+
+ onChildKeyDown = (event) => {
+ const {
+ items, collapse, focusPrevRootItem, focusNextRootItem, focusRootItem, orientation,
+ expandItem, focus, focusPrevItem, focusNextItem, focusFirstItem, focusLastItem, focusItemFirstChild,
+ } = this.props;
+ const { key, target } = event;
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const isDisabled = target.getAttribute('aria-disabled') === 'true'; //can't use isDisabled on the item for radiogroups
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const flattenedRootIndex = Number.parseInt(flattenedPosition[0], 10);
+ const level = position.length - 1;
+ const item = items[index];
+ const { type, onActivate } = item;
+
+ if(key === 'ArrowUp' || key === 'Up') {
+ event.preventDefault();
+
+ if(orientation === 'vertical')
+ focusPrevItem(flattenedIndex);
+ else {
+ collapse(false, () => {
+ if(level === 1 && focusPrevRootItem)
+ focusPrevRootItem(flattenedRootIndex, true);
+ else
+ focus();
+ });
+ }
+ }
+ else if(key === 'ArrowDown' || key === 'Down') {
+ event.preventDefault();
+
+ if(orientation === 'vertical')
+ focusNextItem(flattenedIndex);
+ else {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(focusNextRootItem) {
+ collapse(true, () => {
+ focusNextRootItem(flattenedRootIndex, true);
+ });
+ }
+ }
+ }
+ else if(key === 'ArrowLeft' || key === 'Left') {
+ event.preventDefault();
+
+ if(orientation === 'vertical') {
+ collapse(false, () => {
+ if(level === 1 && focusPrevRootItem)
+ focusPrevRootItem(flattenedRootIndex, true);
+ else
+ focus();
+ });
+ }
+ else
+ focusPrevItem(flattenedIndex);
+ }
+ else if(key === 'ArrowRight' || key === 'Right') {
+ event.preventDefault();
+
+ if(orientation === 'vertical') {
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(focusNextRootItem) {
+ collapse(true, () => {
+ focusNextRootItem(flattenedRootIndex, true);
+ });
+ }
+ }
+ else
+ focusNextItem(flattenedIndex);
+ }
+ else if(key === 'Enter') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(true, () => {
+ focusRootItem(flattenedRootIndex);
+ });
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(true, () => {
+ focusRootItem(flattenedRootIndex);
+ });
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(true, () => {
+ focusRootItem(flattenedRootIndex);
+ });
+ }
+ }
+ else if(key === ' ' || key === 'Spacebar') {
+ event.preventDefault();
+
+ if(isDisabled)
+ return;
+
+ if(type === 'menu') {
+ expandItem(flattenedIndex, () => {
+ focusItemFirstChild(flattenedIndex);
+ });
+ }
+ else if(type === 'checkbox') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'radiogroup') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+ }
+ else if(type === 'item') {
+ if(typeof onActivate === 'function')
+ onActivate(event);
+
+ collapse(true, () => {
+ focusRootItem(flattenedRootIndex);
+ });
+ }
+ }
+ else if(key === 'Home') {
+ event.preventDefault();
+ focusFirstItem();
+ }
+ else if(key === 'End') {
+ event.preventDefault();
+ focusLastItem();
+ }
+ else if(key === 'Escape' || key === 'Esc') {
+ event.preventDefault();
+
+ collapse(false, () => {
+ focus();
+ });
+ }
+ else if(key === 'Tab')
+ collapse(true);
+ };
+
+ onChildOver = () => {
+ const { items, expandedIndex, expandItem, focusItem } = this.props;
+ const { target } = event;
+
+ console.log(target);
+
+ const position = target.dataset.position.split(',');
+ const flattenedPosition = target.dataset.flattenedposition.split(',');
+ const index = Number.parseInt(position[position.length - 1], 10);
+ const flattenedIndex = Number.parseInt(flattenedPosition[flattenedPosition.length - 1], 10);
+ const item = items[index];
+ const { type } = item;
+
+ focusItem(flattenedIndex);
+
+ if(type === 'menu')
+ expandItem(flattenedIndex);
+ };
+
+ onChildLeave = () => {
+ };
+
+ //---- Rendering ----
+ render() {
+ const {
+ children, items, position, flattenedPosition,
+ onKeyDown, onClick, onMouseOver, onMouseLeave,
+ focusPrevRootItem, focusNextRootItem, focusRootItem,
+ orientation, label, labelId, isExpanded, isDisabled, isTabbable,
+ setManagerRef, setItemRef, expandedIndex, collapseItem,
+ } = this.props;
+ const itemNodes = renderItems({
+ items,
+ setItemRef,
+ expandedIndex,
+ collapseItem,
+ focusRootItem,
+ focusPrevRootItem,
+ focusNextRootItem,
+ position,
+ flattenedPosition,
+ onChildKeyDown: this.onChildKeyDown,
+ onChildClick: this.onChildClick,
+ onChildOver: this.onChildOver,
+ onChildLeave: this.onChildLeave,
+ });
+
+ return (
+
+
+ { children }
+
+
+ { itemNodes }
+
+
+ );
+ }
+}
+
+export default createMenuManager(ParentMenuItem);
diff --git a/packages/react-aria-widgets/src/Menu/createMenuButtonManager.jsx b/packages/react-aria-widgets/src/Menu/createMenuButtonManager.jsx
new file mode 100644
index 00000000..11777ce0
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/createMenuButtonManager.jsx
@@ -0,0 +1,48 @@
+/* eslint-disable react/jsx-props-no-spreading */
+
+import React from 'react';
+
+export default function createMenuButtonManager(Component) {
+ return class MenuButtonManager extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isExpanded: false,
+ };
+ }
+
+ //---- Rendering ----
+ render() {
+ const { isExpanded } = this.state;
+
+ return (
+
+ );
+ }
+
+ //---- Misc. ----
+ collapse = (collapseAllParents, callback) => {
+ this.setState({
+ isExpanded: false,
+ }, () => {
+ if(typeof callback === 'function')
+ callback();
+ });
+ };
+
+ expand = (callback) => {
+ this.setState({
+ isExpanded: true,
+ }, () => {
+ if(typeof callback === 'function')
+ callback();
+ });
+ };
+ };
+}
diff --git a/packages/react-aria-widgets/src/Menu/createMenuManager.jsx b/packages/react-aria-widgets/src/Menu/createMenuManager.jsx
new file mode 100644
index 00000000..e4f65d3a
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/createMenuManager.jsx
@@ -0,0 +1,152 @@
+/* eslint-disable react/jsx-props-no-spreading */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default function createMenuManager(Component) {
+ class MenuManager extends React.Component {
+ static propTypes = {
+ forwardedRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.object,
+ ]),
+ collapse: PropTypes.func,
+ };
+
+ static defaultProps = {
+ forwardedRef: null, //seems to be React's default
+ collapse: undefined,
+ };
+
+ constructor(props) {
+ super(props);
+
+ //Note: tabbableIndex is only really relevant to the
+ //root menuitems in a menubar. Submenus should ignore
+ //it.
+ this.state = {
+ tabbableIndex: 0,
+ expandedIndex: -1,
+ };
+
+ this.managerRef = undefined;
+ this.itemRefs = [];
+ }
+
+ //---- Rendering ----
+ render() {
+ const { forwardedRef, ...rest } = this.props;
+ const { tabbableIndex, expandedIndex } = this.state;
+
+ return (
+
+ );
+ }
+
+ //---- Misc. ----
+ setManagerRef = (ref) => {
+ this.managerRef = ref;
+ };
+
+ setItemRef = (ref) => {
+ this.itemRefs.push(ref);
+ };
+
+ collapseItem = (collapseAllParents, callback) => {
+ const { collapse } = this.props;
+
+ this.setState({
+ expandedIndex: -1,
+ }, () => {
+ //This feels rather fragile. Do we need to care about children
+ //or bidirectionality?
+ if(collapseAllParents) {
+ if(typeof collapse === 'function')
+ collapse(true, callback);
+ else if(typeof callback === 'function')
+ callback();
+ }
+ else if(typeof callback === 'function')
+ callback();
+ });
+ };
+
+ expandItem = (index, callback) => {
+ this.setState({
+ expandedIndex: index,
+ }, () => {
+ if(typeof callback === 'function')
+ callback();
+ });
+ };
+
+ focus = () => {
+ this.managerRef.focus();
+ };
+
+ focusItem = (index, autoExpand = false) => {
+ const itemRef = this.itemRefs[index];
+ const isMenu = !(itemRef instanceof HTMLElement); //this feels rather fragile
+
+ this.setState(state => {
+ const { expandedIndex } = state;
+ const wasExpanded = expandedIndex !== -1;
+
+ return {
+ tabbableIndex: index,
+ expandedIndex: autoExpand || wasExpanded ? index : -1,
+ };
+ }, () => {
+ if(isMenu)
+ itemRef.props.focus();
+ else
+ itemRef.focus();
+ });
+ };
+
+ focusPrevItem = (index, autoExpand) => {
+ this.focusItem(index === 0 ? this.itemRefs.length - 1 : index - 1, autoExpand);
+ };
+
+ focusNextItem = (index, autoExpand) => {
+ this.focusItem(index === this.itemRefs.length - 1 ? 0 : index + 1, autoExpand);
+ };
+
+ focusFirstItem = () => {
+ this.focusItem(0);
+ };
+
+ focusLastItem = () => {
+ this.focusItem(this.itemRefs.length - 1);
+ };
+
+ focusItemFirstChild = (index) => {
+ this.itemRefs[index].props.focusFirstItem();
+ };
+
+ focusItemLastChild = (index) => {
+ this.itemRefs[index].props.focusLastItem();
+ };
+ }
+
+ return React.forwardRef((props, ref) => {
+ return ;
+ });
+}
diff --git a/packages/react-aria-widgets/src/Menu/index.js b/packages/react-aria-widgets/src/Menu/index.js
new file mode 100644
index 00000000..b2eb294f
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/index.js
@@ -0,0 +1,32 @@
+//Components and Styles
+import MenuBar from 'src/Menu/MenuBar';
+import MenuButton from 'src/Menu/MenuButton';
+import ParentMenuItem from 'src/Menu/ParentMenuItem';
+import Menu from 'src/Menu/Menu';
+import MenuItem from 'src/Menu/MenuItem';
+import MenuItemCheckbox from 'src/Menu/MenuItem';
+import MenuItemRadio from 'src/Menu/MenuItemRadio';
+import MenuItemRadioGroup from 'src/Menu/MenuItemRadioGroup';
+import MenuItemSeparator from 'src/Menu/MenuItemSeparator';
+
+//HOCs
+import createMenuManager from 'src/Menu/createMenuManager';
+import createMenuButtonManager from 'src/Menu/createMenuButtonManager';
+
+//Misc.
+import * as menuUtils from 'src/Menu/utils';
+
+export {
+ MenuBar,
+ MenuButton,
+ ParentMenuItem,
+ Menu,
+ MenuItem,
+ MenuItemCheckbox,
+ MenuItemRadio,
+ MenuItemRadioGroup,
+ MenuItemSeparator,
+ createMenuManager,
+ createMenuButtonManager,
+ menuUtils,
+};
diff --git a/packages/react-aria-widgets/src/Menu/utils.jsx b/packages/react-aria-widgets/src/Menu/utils.jsx
new file mode 100644
index 00000000..ac523278
--- /dev/null
+++ b/packages/react-aria-widgets/src/Menu/utils.jsx
@@ -0,0 +1,188 @@
+import React from 'react';
+
+//Components and Styles
+import MenuItem from 'src/Menu/MenuItem';
+import ParentMenuItem from 'src/Menu/ParentMenuItem';
+import MenuItemCheckbox from 'src/Menu/MenuItemCheckbox';
+import MenuItemSeparator from 'src/Menu/MenuItemSeparator';
+import MenuItemRadioGroup from 'src/Menu/MenuItemRadioGroup';
+import MenuItemRadio from 'src/Menu/MenuItemRadio';
+
+/**
+ * Renders the items in a particular menu/sub-menu.
+ *
+ * @param {object} options
+ * @param {object[]} options.items
+ * @param {function} options.setItemRef
+ * @param {number} [options.tabbableIndex]
+ * @param {number} options.expandableIndex
+ * @param {function} options.collapseItem
+ * @param {function} [options.focusRootItem]
+ * @param {function} [options.focusPrevRootItem]
+ * @param {function} [options.focusNextRootItem]
+ * @param {number[]} options.position
+ * @param {number[]} options.flattenedPosition
+ * @param {function} options.onChildKeyDown
+ * @param {function} options.onChildClick
+ * @param {function} options.onChildOver
+ * @param {function} options.onChildLeave
+ * @returns {React.Component[]}
+ */
+export function renderItems(options) {
+ /* eslint-disable react/no-array-index-key */
+
+ const {
+ items, setItemRef, tabbableIndex, expandedIndex, collapseItem,
+ focusRootItem, focusPrevRootItem, focusNextRootItem, position, flattenedPosition,
+ onChildKeyDown, onChildClick, onChildOver, onChildLeave
+ } = options;
+ const itemNodes = [];
+ const level = position.length;
+ let _position = [];
+ let _flattenedPosition = [];
+ let flattenedIndex = 0;
+
+ items.forEach((item, i) => {
+ const { type, node, children, orientation, label, labelId, isDisabled, isChecked } = item;
+
+ if(type === 'item') {
+ _position = position.slice(0);
+ _position[level] = i;
+ _flattenedPosition = flattenedPosition.slice(0);
+ _flattenedPosition[level] = flattenedIndex;
+
+ itemNodes.push(
+
+ { node }
+
+ );
+
+ flattenedIndex++;
+ }
+ else if(type === 'menu') {
+ _position = position.slice(0);
+ _position[level] = i;
+ _flattenedPosition = flattenedPosition.slice(0);
+ _flattenedPosition[level] = flattenedIndex;
+
+ itemNodes.push(
+
+ { node }
+
+ );
+
+ flattenedIndex++;
+ }
+ else if(type === 'checkbox') {
+ _position = position.slice(0);
+ _position[level] = i;
+ _flattenedPosition = flattenedPosition.slice(0);
+ _flattenedPosition[level] = flattenedIndex;
+
+ itemNodes.push(
+
+ { node }
+
+ );
+
+ flattenedIndex++;
+ }
+ else if(type === 'separator') {
+ itemNodes.push(
+
+ { node }
+
+ );
+ }
+ else if(type === 'radiogroup') {
+ const radioNodes = [];
+
+ children.forEach((radioItem, j) => {
+ const { node, isDisabled, isChecked, value } = radioItem;
+
+ _position = position.slice(0);
+ _position[level] = i;
+ _flattenedPosition = flattenedPosition.slice(0);
+ _flattenedPosition[level] = flattenedIndex;
+
+ radioNodes.push(
+
+ { node }
+
+ );
+
+ flattenedIndex++;
+ });
+
+ itemNodes.push(
+
+ { radioNodes }
+
+ );
+ }
+ });
+
+ return itemNodes;
+
+ /* eslint-enable react/no-array-index-key */
+}
diff --git a/packages/react-aria-widgets/src/index.jsx b/packages/react-aria-widgets/src/index.jsx
index bbb342b6..ee088134 100644
--- a/packages/react-aria-widgets/src/index.jsx
+++ b/packages/react-aria-widgets/src/index.jsx
@@ -1,11 +1,14 @@
//Components and Styles
import * as Accordion from 'src/Accordion';
+import * as Menu from 'src/Menu';
import 'src/styles.scss';
export default {
Accordion,
+ Menu,
};
export {
Accordion,
+ Menu,
};
diff --git a/packages/react-aria-widgets/src/styles.scss b/packages/react-aria-widgets/src/styles.scss
index 20da4f79..0c723662 100644
--- a/packages/react-aria-widgets/src/styles.scss
+++ b/packages/react-aria-widgets/src/styles.scss
@@ -1,3 +1,53 @@
+:focus {
+ background-color: red;
+}
+
.react-aria-widgets-hidden {
display: none !important;
}
+
+[aria-disabled="true"] {
+ opacity: 50%;
+ cursor: not-allowed;
+}
+
+[role="menuitem"], button[aria-haspopup="menu"] {
+ &[aria-expanded="false"] + [role="menu"] {
+ display: none;
+ }
+}
+
+[role="menubar"], [role="menu"] {
+ &[aria-orientation="horizontal"] {
+ display: flex;
+
+ > li {
+ flex: 1 1 auto;
+ }
+ }
+}
+
+[role="menuitemcheckbox"], [role="menuitemradio"] {
+ &[aria-checked="true"] {
+ background-color: blue;
+ }
+
+ &[aria-checked="mixed"] {
+ background-color: green;
+ }
+}
+
+//A technique for visually-hiding elements
+//but still keeping them readable to machines.
+//See:
+//https://webaim.org/techniques/css/invisiblecontent/
+.vhide {
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ width: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute
+}
diff --git a/packages/react-aria-widgets/src/utils/propTypes.js b/packages/react-aria-widgets/src/utils/propTypes.js
index 404f87fe..4be2d2b2 100644
--- a/packages/react-aria-widgets/src/utils/propTypes.js
+++ b/packages/react-aria-widgets/src/utils/propTypes.js
@@ -1,3 +1,5 @@
+import PropTypes from 'prop-types';
+
/**
* Creates a custom React prop type.
*
@@ -6,8 +8,8 @@
*
* This also circumvents an issue with eslint-plugin-react
* as the rule react/require-default-props will complain
- * if it encounters a custom prop validator.
- * normally cannot mark them as required.
+ * if it encounters a custom prop validator as they
+ * normally cannot be marked as required.
*
* See:
* https://github.com/yannickcr/eslint-plugin-react/issues/1020
@@ -47,3 +49,60 @@ function _validateHeaderLevelProp(props, propName) {
const validateHeaderLevelProp = createCustomPropType(_validateHeaderLevelProp);
validateHeaderLevelProp.isRequired = createCustomPropType(_validateHeaderLevelProp, true);
export { validateHeaderLevelProp };
+
+export const MENUITEM_PROPTYPE = PropTypes.shape({
+ type: PropTypes.oneOf([ 'item' ]).isRequired,
+ node: PropTypes.node.isRequired,
+ onActivate: PropTypes.func,
+ isDisabled: PropTypes.bool,
+});
+
+export const PARENT_MENUITEM_PROPTYPE = PropTypes.shape({
+ type: PropTypes.oneOf([ 'menu' ]).isRequired,
+ node: PropTypes.node.isRequired,
+ children: MENUITEMS_PROPTYPE,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ isDisabled: PropTypes.bool,
+});
+
+export const MENUITEM_CHECKBOX_PROPTYPE = PropTypes.shape({
+ type: PropTypes.oneOf([ 'checkbox' ]).isRequired,
+ node: PropTypes.node.isRequired,
+ onActivate: PropTypes.func,
+ isDisabled: PropTypes.bool,
+ isChecked: PropTypes.oneOfType([
+ PropTypes.bool,
+ PropTypes.oneOf([ 'true', 'false', 'mixed' ]),
+ ]),
+});
+
+export const MENUITEM_RADIO_PROPTYPE = PropTypes.shape({
+ node: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool,
+ isChecked: PropTypes.bool,
+ value: PropTypes.any,
+});
+
+export const MENUITEM_RADIOGROUP_PROPTYPE = PropTypes.shape({
+ type: PropTypes.oneOf([ 'radiogroup' ]).isRequired,
+ children: PropTypes.arrayOf(MENUITEM_RADIO_PROPTYPE).isRequired,
+ onActivate: PropTypes.func,
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+});
+
+export const MENUITEM_SEPARATOR_PROPTYPE = PropTypes.shape({
+ type: PropTypes.oneOf([ 'separator' ]).isRequired,
+ node: PropTypes.node,
+ orientation: PropTypes.oneOf([ 'vertical', 'horizontal' ]),
+});
+
+export const MENUITEMS_PROPTYPE = PropTypes.arrayOf(PropTypes.oneOfType([
+ MENUITEM_PROPTYPE,
+ PARENT_MENUITEM_PROPTYPE,
+ MENUITEM_CHECKBOX_PROPTYPE,
+ MENUITEM_RADIOGROUP_PROPTYPE,
+ MENUITEM_SEPARATOR_PROPTYPE,
+]));
diff --git a/packages/react-aria-widgets/webpack.common.js b/packages/react-aria-widgets/webpack.common.js
index 46fd265e..ab79d867 100644
--- a/packages/react-aria-widgets/webpack.common.js
+++ b/packages/react-aria-widgets/webpack.common.js
@@ -28,6 +28,9 @@ module.exports = {
accordion: {
import: './src/Accordion/index.js',
},
+ menu: {
+ import: './src/Menu/index.js',
+ },
},
output: {
filename: '[name].bundle.js',