Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

feat(Toolbar): Add ARIA attributes and focus handling for radio group #1526

Merged
merged 20 commits into from
Jun 27, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const ToolbarExampleShorthand = () => {
const [highlightOpen, setHighlightOpen] = React.useState(false)
const [fontColorActive, setFontColorActive] = React.useState(false)

const [bulletListActive, setBulletListActive] = React.useState(false)
const [numberListActive, setNumberListActive] = React.useState(false)
const [toDoListActive, setToDoListActive] = React.useState(false)

return (
<Toolbar
items={[
Expand Down Expand Up @@ -124,11 +128,55 @@ const ToolbarExampleShorthand = () => {
{ 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 } },
]}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const config: ScreenerTestsConfig = { themes: ['teams', 'teamsDark', 'teamsHighContrast'] }

export default config
Original file line number Diff line number Diff line change
@@ -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 (
<Toolbar
items={[
{
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',
},
],
},
]}
/>
)
}

export default ToolbarExamplePopupShorthand
5 changes: 5 additions & 0 deletions docs/src/examples/components/Toolbar/Types/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const Types = () => (
}
examplePath="components/Toolbar/Types/ToolbarExamplePopup"
/>
<ComponentExample
title="Toolbar can contain a radio group"
description="Toolbar items can be grouped into radio group. Up/Down arrow keys can be used to cycle between radio items. Only one of the radio items can be selected at a time, should be implemented additionally."
examplePath="components/Toolbar/Types/ToolbarExampleRadioGroup"
/>
<ComponentExample
title="Text editor toolbar"
description="A Toolbar use case for a text editor."
Expand Down
79 changes: 74 additions & 5 deletions packages/react/src/components/Toolbar/ToolbarRadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'

Expand Down Expand Up @@ -48,7 +50,54 @@ class ToolbarRadioGroup extends UIComponent<WithAsProp<ToolbarRadioGroupProps>>
}

static defaultProps = {
accessibility: defaultBehavior as Accessibility,
accessibility: toolbarRadioGroupBehavior as Accessibility,
}

itemRefs: React.RefObject<HTMLElement>[] = []

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<HTMLElement>[] = _.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<HTMLElement>) => {
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 => ({
Expand All @@ -57,20 +106,40 @@ class ToolbarRadioGroup extends UIComponent<WithAsProp<ToolbarRadioGroupProps>>

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<HTMLElement>()
this.itemRefs[index] = ref

if (kind === 'divider') {
return ToolbarDivider.create(item, { overrideProps: itemOverridesFn })
}
return ToolbarItem.create(item, { overrideProps: itemOverridesFn })

return (
<Ref innerRef={ref}>
{ToolbarItem.create(item, {
defaultProps: {
accessibility: toolbarRadioGroupItemBehavior,
},
overrideProps: itemOverridesFn,
})}
</Ref>
)
})
}

renderComponent({ ElementType, classes, variables, accessibility, unhandledProps }) {
const { children, items } = this.props
return (
<ElementType {...accessibility.attributes.root} {...unhandledProps} className={classes.root}>
<ElementType
{...accessibility.attributes.root}
{...unhandledProps}
{...applyAccessibilityKeyHandlers(accessibility.keyHandlers.root, unhandledProps)}
className={classes.root}
>
{childrenExist(children) ? children : this.renderItems(items, variables)}
</ElementType>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<ToolbarRadioGroupItemBehaviorProps> = 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
4 changes: 4 additions & 0 deletions packages/react/src/lib/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions packages/react/test/specs/behaviors/behavior-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
chatBehavior,
chatMessageBehavior,
toolbarBehavior,
toolbarRadioGroupBehavior,
toolbarRadioGroupItemBehavior,
} from 'src/lib/accessibility'
import { TestHelper } from './testHelper'
import definitions from './testDefinitions'
Expand Down Expand Up @@ -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)
Loading