Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Components: Add a WAI-ARIA compliant custom select. #17926

Merged
merged 11 commits into from Nov 28, 2019
28 changes: 26 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/components/package.json
Expand Up @@ -37,6 +37,7 @@
"classnames": "^2.2.5",
"clipboard": "^2.0.1",
"dom-scroll-into-view": "^1.2.1",
"downshift": "^3.3.4",
"lodash": "^4.17.15",
"memize": "^1.0.5",
"moment": "^2.22.1",
Expand Down
149 changes: 149 additions & 0 deletions packages/components/src/custom-select/index.js
@@ -0,0 +1,149 @@
/**
* External dependencies
*/
import { useSelect } from 'downshift';
import classnames from 'classnames';

/**
* Internal dependencies
*/
import { Button, Dashicon } from '../';

const itemToString = ( item ) => item && item.name;
// This is needed so that in Windows, where
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checked this on Windows and the behaviour is identical to Mac: on arrow down/up the menu opens. I don't see that as a major issue though. If we did want the arrow keys to work with the menu closed we'd probably have to make sure the options always render, and hide them with CSS, but then it's not clear that we could limit that behaviour to Windows 🤷‍♀

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the issue here is the returned changes. @epiqueras just return the selectedItem because if you also return the rest of the changes then one of them is isOpen that comes as true :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 😄

// the menu does not necessarily open on
// key up/down, you can still switch between
// options with the menu closed.
const stateReducer = (
epiqueras marked this conversation as resolved.
Show resolved Hide resolved
{ selectedItem },
{ type, changes, props: { items } }
) => {
// TODO: Remove this.
// eslint-disable-next-line no-console
console.debug(
'Selected Item: ',
selectedItem,
'Type: ',
type,
'Changes: ',
changes,
'Items: ',
items
);
switch ( type ) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownArrowDown:
// If we already have a selected item, try to select the next one,
// without circular navigation. Otherwise, select the first item.
return {
selectedItem:
items[
selectedItem ?
Math.min( items.indexOf( selectedItem ) + 1, items.length - 1 ) :
0
],
};
case useSelect.stateChangeTypes.ToggleButtonKeyDownArrowUp:
// If we already have a selected item, try to select the previous one,
// without circular navigation. Otherwise, select the last item.
return {
selectedItem:
items[
selectedItem ?
Math.max( items.indexOf( selectedItem ) - 1, 0 ) :
items.length - 1
],
};
default:
return changes;
}
};
export default function CustomSelect( {
className,
hideLabelFromVision,
label,
items,
onSelectedItemChange,
selectedItem: _selectedItem,
} ) {
const {
getLabelProps,
getToggleButtonProps,
getMenuProps,
getItemProps,
isOpen,
highlightedIndex,
selectedItem,
} = useSelect( {
initialSelectedItem: items[ 0 ],
items,
itemToString,
onSelectedItemChange,
selectedItem: _selectedItem,
stateReducer,
} );
const menuProps = getMenuProps( {
className: 'components-custom-select__menu',
} );
// We need this here, because the null active descendant is not
// fully ARIA compliant.
if (
menuProps[ 'aria-activedescendant' ] &&
menuProps[ 'aria-activedescendant' ].slice( 0, 'downshift-null'.length ) ===
'downshift-null'
) {
delete menuProps[ 'aria-activedescendant' ];
}
return (
<div className={ classnames( 'components-custom-select', className ) }>
{ /* eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ }
<label
{ ...getLabelProps( {
className: classnames( 'components-custom-select__label', {
'screen-reader-text': hideLabelFromVision,
} ),
} ) }
>
{ label }
</label>
<Button
{ ...getToggleButtonProps( {
// This is needed because some speech recognition software don't support `aria-labelledby`.
'aria-label': label,
epiqueras marked this conversation as resolved.
Show resolved Hide resolved
'aria-labelledby': undefined,
className: 'components-custom-select__button',
} ) }
>
{ itemToString( selectedItem ) }
<Dashicon
icon="arrow-down-alt2"
className="components-custom-select__button-icon"
/>
</Button>
<ul { ...menuProps }>
{ isOpen &&
items.map( ( item, index ) => (
// eslint-disable-next-line react/jsx-key
<li
{ ...getItemProps( {
item,
index,
key: item.key,
className: classnames( 'components-custom-select__item', {
'is-highlighted': index === highlightedIndex,
} ),
style: item.style,
} ) }
>
{ item === selectedItem && (
<Dashicon
icon="saved"
className="components-custom-select__item-icon"
/>
) }
{ item.name }
</li>
) ) }
</ul>
</div>
);
}
30 changes: 30 additions & 0 deletions packages/components/src/custom-select/stories/index.js
@@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import CustomSelect from '../';

export default { title: 'CustomSelect', component: CustomSelect };

const items = [
{
key: 'small',
name: 'Small',
style: { fontSize: '50%' },
},
{
key: 'normal',
name: 'Normal',
style: { fontSize: '100%' },
},
{
key: 'large',
name: 'Large',
style: { fontSize: '200%' },
},
{
key: 'huge',
name: 'Huge',
style: { fontSize: '300%' },
},
];
export const _default = () => <CustomSelect label="Font Size" items={ items } />;
56 changes: 56 additions & 0 deletions packages/components/src/custom-select/style.scss
@@ -0,0 +1,56 @@
.components-custom-select {
color: $dark-gray-500;
position: relative;
}

.components-custom-select__label {
display: block;
margin-bottom: 5px;
}

.components-custom-select__button {
border: 1px solid $dark-gray-200;
border-radius: 4px;
color: $dark-gray-500;
display: inline;
min-height: 30px;
min-width: 130px;
position: relative;
text-align: left;

&:focus {
border-color: $blue-medium-500;
}

&-icon {
height: 100%;
padding: 0 4px;
position: absolute;
right: 0;
top: 0;
}
}

.components-custom-select__menu {
background: $white;
padding: 0;
position: absolute;
width: 100%;
z-index: z-index(".components-popover");
}

.components-custom-select__item {
align-items: center;
display: flex;
list-style-type: none;
padding: 10px 5px 10px 25px;

&.is-highlighted {
background: $light-gray-500;
}

&-icon {
margin-left: -20px;
margin-right: 0;
}
}