diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc5dc1dba..c2852cfa97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Features +- Add ARIA attributes and focus handling for `RadioGroup` in `Toolbar` @sophieH29 ([#1526](https://github.com/stardust-ui/react/pull/1526)) + ## [v0.34.0](https://github.com/stardust-ui/react/tree/v0.34.0) (2019-06-26) [Compare changes](https://github.com/stardust-ui/react/compare/v0.33.0...v0.34.0) diff --git a/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.steps.tsx b/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.steps.tsx new file mode 100644 index 0000000000..81b7a6894d --- /dev/null +++ b/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.steps.tsx @@ -0,0 +1,3 @@ +const config: ScreenerTestsConfig = { themes: ['teams', 'teamsDark', 'teamsHighContrast'] } + +export default config diff --git a/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.tsx b/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.tsx new file mode 100644 index 0000000000..b7e2ed345c --- /dev/null +++ b/docs/src/examples/components/Toolbar/Content/ToolbarExampleRadioGroup.shorthand.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' +import { Toolbar } from '@stardust-ui/react' + +const ToolbarExamplePopupShorthand = () => { + const [bulletListActive, setBulletListActive] = React.useState(false) + const [numberListActive, setNumberListActive] = React.useState(false) + const [toDoListActive, setToDoListActive] = React.useState(false) + return ( + { + setBulletListActive(!bulletListActive) + + // deselect other radio items + setNumberListActive(false) + setToDoListActive(false) + }, + 'aria-label': 'bullet list', + }, + { + key: 'number-list', + icon: { name: 'number-list', outline: true }, + active: numberListActive, + onClick: () => { + setNumberListActive(!numberListActive) + + // deselect other radio items + setBulletListActive(false) + setToDoListActive(false) + }, + 'aria-label': 'number list', + }, + { + key: 'to-do-list', + icon: { name: 'to-do-list', outline: true }, + active: toDoListActive, + onClick: () => { + setToDoListActive(!toDoListActive) + + // deselect other radio items + setBulletListActive(false) + setNumberListActive(false) + }, + 'aria-label': 'to do list', + }, + ], + }, + ]} + /> + ) +} + +export default ToolbarExamplePopupShorthand diff --git a/docs/src/examples/components/Toolbar/Content/index.tsx b/docs/src/examples/components/Toolbar/Content/index.tsx index 0f5d37bc79..021286c708 100644 --- a/docs/src/examples/components/Toolbar/Content/index.tsx +++ b/docs/src/examples/components/Toolbar/Content/index.tsx @@ -20,6 +20,11 @@ const Content = () => ( description="Toolbar item can open a menu." examplePath="components/Toolbar/Content/ToolbarExampleMenu" /> + ) diff --git a/docs/src/examples/components/Toolbar/Types/ToolbarExampleEditor.shorthand.tsx b/docs/src/examples/components/Toolbar/Types/ToolbarExampleEditor.shorthand.tsx index 65f82eb849..be8a5a0a89 100644 --- a/docs/src/examples/components/Toolbar/Types/ToolbarExampleEditor.shorthand.tsx +++ b/docs/src/examples/components/Toolbar/Types/ToolbarExampleEditor.shorthand.tsx @@ -66,6 +66,10 @@ const ToolbarExampleShorthand = () => { setLog(prevLog => [`${new Date().toLocaleTimeString()}: ${message}`, ...prevLog]) } + const [bulletListActive, setBulletListActive] = React.useState(false) + const [numberListActive, setNumberListActive] = React.useState(false) + const [toDoListActive, setToDoListActive] = React.useState(false) + return ( <> { { key: 'font-size', icon: { name: 'font-size', outline: true } }, { key: 'remove-format', icon: { name: 'remove-format', outline: true } }, { key: 'divider2', kind: 'divider' }, + { + key: 'radiogroup', + kind: 'group', + items: [ + { + key: 'bullets', + icon: { name: 'bullets', outline: true }, + active: bulletListActive, + onClick: () => { + setBulletListActive(!bulletListActive) + + // deselect other radio items + setNumberListActive(false) + setToDoListActive(false) + }, + 'aria-label': 'bullet list', + }, + { + key: 'number-list', + icon: { name: 'number-list', outline: true }, + active: numberListActive, + onClick: () => { + setNumberListActive(!numberListActive) + + // deselect other radio items + setBulletListActive(false) + setToDoListActive(false) + }, + 'aria-label': 'number list', + }, + { + key: 'to-do-list', + icon: { name: 'to-do-list', outline: true }, + active: toDoListActive, + onClick: () => { + setToDoListActive(!toDoListActive) + + // deselect other radio items + setBulletListActive(false) + setNumberListActive(false) + }, + 'aria-label': 'to do list', + }, + ], + }, + { key: 'divider3', kind: 'divider' }, { key: 'outdent', icon: { name: 'outdent', outline: true } }, { key: 'indent', icon: { name: 'indent', outline: true } }, - { key: 'bullets', icon: { name: 'bullets', outline: true } }, - { key: 'number-list', icon: { name: 'number-list', outline: true } }, - { key: 'divider3', kind: 'divider' }, + { key: 'divider4', kind: 'divider' }, { key: 'more', icon: { name: 'more', outline: true }, diff --git a/packages/react/src/components/Toolbar/ToolbarRadioGroup.tsx b/packages/react/src/components/Toolbar/ToolbarRadioGroup.tsx index e63c483b96..6f63ce2436 100644 --- a/packages/react/src/components/Toolbar/ToolbarRadioGroup.tsx +++ b/packages/react/src/components/Toolbar/ToolbarRadioGroup.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as _ from 'lodash' import * as customPropTypes from '@stardust-ui/react-proptypes' +import { Ref } from '@stardust-ui/react-component-ref' import { ChildrenComponentProps, @@ -10,15 +11,16 @@ import { UIComponent, childrenExist, commonPropTypes, + applyAccessibilityKeyHandlers, } from '../../lib' import { mergeComponentVariables } from '../../lib/mergeThemes' import { ShorthandCollection, WithAsProp, withSafeTypeForAs } from '../../types' import { Accessibility } from '../../lib/accessibility/types' -import { defaultBehavior } from '../../lib/accessibility' +import { toolbarRadioGroupBehavior, toolbarRadioGroupItemBehavior } from '../../lib/accessibility' import ToolbarDivider from './ToolbarDivider' -import ToolbarItem from './ToolbarItem' +import ToolbarItem, { ToolbarItemProps } from './ToolbarItem' export type ToolbarRadioGroupItemShorthandKinds = 'divider' | 'item' @@ -48,7 +50,54 @@ class ToolbarRadioGroup extends UIComponent> } static defaultProps = { - accessibility: defaultBehavior, + accessibility: toolbarRadioGroupBehavior as Accessibility, + } + + itemRefs: React.RefObject[] = [] + + actionHandlers = { + nextItem: event => this.setFocusedItem(event, 1), + prevItem: event => this.setFocusedItem(event, -1), + } + + setFocusedItem = (event, direction) => { + const { items } = this.props + + // filter items which are not disabled + const filteredRadioItems: React.RefObject[] = _.filter( + this.itemRefs, + (item, index) => { + const currentItem = items[index] as ToolbarItemProps + return currentItem && !currentItem.disabled + }, + ) + + // get the index of currently focused element (w/ tabindex = 0) or the first one as default + const currentFocusedIndex = + _.findIndex(filteredRadioItems, (item: React.RefObject) => { + return item.current.tabIndex === 0 + }) || 0 + + const itemsLength = filteredRadioItems.length + let nextIndex = currentFocusedIndex + direction + + if (nextIndex >= itemsLength) { + nextIndex = 0 + } + + if (nextIndex < 0) { + nextIndex = itemsLength - 1 + } + + const nextItemToFocus = filteredRadioItems[nextIndex].current + if (nextItemToFocus) { + nextItemToFocus.focus() + } + + if (document.activeElement === nextItemToFocus) { + event.stopPropagation() + } + event.preventDefault() } handleItemOverrides = variables => predefinedProps => ({ @@ -57,20 +106,42 @@ class ToolbarRadioGroup extends UIComponent> renderItems(items, variables) { const itemOverridesFn = this.handleItemOverrides(variables) + this.itemRefs = [] + return _.map(items, (item, index) => { const kind = _.get(item, 'kind', 'item') + const ref = React.createRef() + this.itemRefs[index] = ref + if (kind === 'divider') { return ToolbarDivider.create(item, { overrideProps: itemOverridesFn }) } - return ToolbarItem.create(item, { overrideProps: itemOverridesFn }) + + const toolbarItem = ToolbarItem.create(item, { + defaultProps: { + accessibility: toolbarRadioGroupItemBehavior, + }, + overrideProps: itemOverridesFn, + }) + + return ( + + {toolbarItem} + + ) }) } renderComponent({ ElementType, classes, variables, accessibility, unhandledProps }) { const { children, items } = this.props return ( - + {childrenExist(children) ? children : this.renderItems(items, variables)} ) diff --git a/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupBehavior.ts new file mode 100644 index 0000000000..61b1bec7c9 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupBehavior.ts @@ -0,0 +1,31 @@ +import { Accessibility } from '../../types' +import * as keyboardKey from 'keyboard-key' + +/** + * @description + * Implements ARIA Radio Group design pattern. + * @specification + * Adds role='radiogroup'. This allows screen readers to handle the component as a radio group. + * Triggers 'nextItem' action with 'ArrowDown' on 'root'. + * Triggers 'prevItem' action with 'ArrowUp' on 'root'. + */ +const toolbarRadioGroupBehavior: Accessibility = () => ({ + attributes: { + root: { + role: 'radiogroup', + }, + }, + + keyActions: { + root: { + nextItem: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + prevItem: { + keyCombinations: [{ keyCode: keyboardKey.ArrowUp }], + }, + }, + }, +}) + +export default toolbarRadioGroupBehavior diff --git a/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupItemBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupItemBehavior.ts new file mode 100644 index 0000000000..b0f3620127 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Toolbar/toolbarRadioGroupItemBehavior.ts @@ -0,0 +1,29 @@ +import { Accessibility } from '../../types' +import buttonBehavior, { ButtonBehaviorProps } from '../Button/buttonBehavior' + +/** + * @specification + * Adds role='radio'. This allows screen readers to handle the component as a radio button. + * Adds attribute 'aria-checked=true' based on the property 'active'. + * Adds attribute 'aria-disabled=true' based on the property 'disabled'. This can be overriden by providing 'aria-disabled' property directly to the component. + * Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'. + */ +const toolbarRadioGroupItemBehavior: Accessibility = props => ({ + attributes: { + root: { + role: 'radio', + 'aria-checked': props.active, + 'aria-disabled': props.disabled, + }, + }, + keyActions: buttonBehavior(props).keyActions, +}) + +export default toolbarRadioGroupItemBehavior + +type ToolbarRadioGroupItemBehaviorProps = { + /** Indicates if radio item is selected. */ + active?: boolean + /** Indicates if radio item is disabled. */ + disabled?: boolean +} & ButtonBehaviorProps diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index 27cb2e2616..d3fbb850a5 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -25,6 +25,10 @@ export { default as menuItemAsToolbarButtonBehavior, } from './Behaviors/Toolbar/menuItemAsToolbarButtonBehavior' export { default as toolbarBehavior } from './Behaviors/Toolbar/toolbarBehavior' +export { default as toolbarRadioGroupBehavior } from './Behaviors/Toolbar/toolbarRadioGroupBehavior' +export { + default as toolbarRadioGroupItemBehavior, +} from './Behaviors/Toolbar/toolbarRadioGroupItemBehavior' export { default as radioGroupBehavior } from './Behaviors/Radio/radioGroupBehavior' export { default as radioGroupItemBehavior } from './Behaviors/Radio/radioGroupItemBehavior' export { default as popupBehavior } from './Behaviors/Popup/popupBehavior' diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index 0a03d414ea..05cb6149f3 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -45,6 +45,8 @@ import { chatBehavior, chatMessageBehavior, toolbarBehavior, + toolbarRadioGroupBehavior, + toolbarRadioGroupItemBehavior, } from 'src/lib/accessibility' import { TestHelper } from './testHelper' import definitions from './testDefinitions' @@ -94,5 +96,7 @@ testHelper.addBehavior('accordionContentBehavior', accordionContentBehavior) testHelper.addBehavior('chatBehavior', chatBehavior) testHelper.addBehavior('chatMessageBehavior', chatMessageBehavior) testHelper.addBehavior('toolbarBehavior', toolbarBehavior) +testHelper.addBehavior('toolbarRadioGroupBehavior', toolbarRadioGroupBehavior) +testHelper.addBehavior('toolbarRadioGroupItemBehavior', toolbarRadioGroupItemBehavior) testHelper.run(behaviorMenuItems) diff --git a/packages/react/test/specs/components/Toolbar/ToolbarRadioGroup-test.tsx b/packages/react/test/specs/components/Toolbar/ToolbarRadioGroup-test.tsx index 23b4ea037d..4fcb3695d3 100644 --- a/packages/react/test/specs/components/Toolbar/ToolbarRadioGroup-test.tsx +++ b/packages/react/test/specs/components/Toolbar/ToolbarRadioGroup-test.tsx @@ -1,4 +1,4 @@ -import { isConformant } from 'test/specs/commonTests' +import { isConformant, handlesAccessibility } from 'test/specs/commonTests' import ToolbarRadioGroup from 'src/components/Toolbar/ToolbarRadioGroup' import { ReactWrapper } from 'enzyme' @@ -8,6 +8,12 @@ import * as React from 'react' describe('ToolbarRadioGroup', () => { isConformant(ToolbarRadioGroup) + describe('accessibility', () => { + handlesAccessibility(ToolbarRadioGroup, { + defaultRootRole: 'radiogroup', + }) + }) + describe('variables', () => { function checkMergedVariables(toolbarRadioGroup: ReactWrapper): void { expect( @@ -67,4 +73,72 @@ describe('ToolbarRadioGroup', () => { checkMergedVariables(toolbarRadioGroup) }) }) + + describe('allows cycling between items using UP/DOWN arrow keys', () => { + const arrowUp = 38 + const arrowDown = 40 + + const getShorthandItems = (props?: { disabledItem?: number; focusedItem?: number }) => [ + { + key: 'test-key1', + tabIndex: props && props.focusedItem === 0 ? 0 : -1, + disabled: props && props.disabledItem === 0, + }, + { + key: 'test-key2', + tabIndex: props && props.focusedItem === 1 ? 0 : -1, + disabled: props && props.disabledItem === 1, + }, + { + key: 'test-key3', + tabIndex: props && props.focusedItem === 2 ? 0 : -1, + disabled: props && props.disabledItem === 2, + }, + ] + + const testKeyDown = (testName, items, keyCode, expectedFocusedIndex) => { + it(`keyDown test - ${testName}`, () => { + const radioButtons = mountWithProvider().find('button') + + const expectedActiveElement = radioButtons.at(expectedFocusedIndex).getDOMNode() + + expect(document.activeElement).not.toBe(expectedActiveElement) + + radioButtons.first().simulate('keyDown', { preventDefault() {}, keyCode, which: keyCode }) + + expect(document.activeElement).toBe(expectedActiveElement) + }) + } + + testKeyDown( + 'should move focus to next, second item', + getShorthandItems({ focusedItem: 0 }), + arrowDown, + 1, + ) + testKeyDown( + 'should move focus to next, third item', + getShorthandItems({ focusedItem: 1 }), + arrowDown, + 2, + ) + testKeyDown( + 'should move focus to previous, first item', + getShorthandItems({ focusedItem: 1 }), + arrowUp, + 0, + ) + testKeyDown( + 'should move focus to first item when the focused item is the last one', + getShorthandItems({ focusedItem: 2 }), + arrowDown, + 0, + ) + testKeyDown( + 'should move focus to last item when the focused item is the first one', + getShorthandItems({ focusedItem: 0 }), + arrowUp, + 2, + ) + }) })