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 + + { 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 ( +
  • +
      + { children } +
    +
  • + ); +} + +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',