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

feat(List): improve a11y #960

Merged
merged 12 commits into from
Sep 12, 2023
4 changes: 4 additions & 0 deletions src/components/List/List.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ $block: '.#{variables.$ns}list';

&_selected {
background: var(--g-color-base-selection);

&:hover {
background: var(--g-color-base-selection-hover);
}
}

&_sort-handle-align_right {
Expand Down
28 changes: 24 additions & 4 deletions src/components/List/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {SelectLoadingIndicator} from '../Select/components/SelectList/SelectLoad
import {TextInput} from '../controls';
import {MobileContext} from '../mobile';
import {block} from '../utils/cn';
import {getUniqId} from '../utils/common';

import {ListItem, SimpleContainer, defaultRenderItem} from './components';
import {listNavigationIgnoredKeys} from './constants';
Expand Down Expand Up @@ -82,8 +83,9 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
loadingItem = {value: '__LIST_ITEM_LOADING__', disabled: true} as unknown as ListItemData<
T & {value: string}
>;
uniqId = getUniqId();

componentDidUpdate(prevProps: ListProps<T>) {
componentDidUpdate(prevProps: ListProps<T>, prevState: ListState<T>) {
if (this.props.items !== prevProps.items) {
const filter = this.getFilter();
const internalFiltering = filter && !this.props.onFilterUpdate;
Expand All @@ -98,20 +100,32 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
if (this.props.activeItemIndex !== prevProps.activeItemIndex) {
this.activateItem(this.props.activeItemIndex);
}

if (this.props.onChangeActive && this.state.activeItem !== prevState.activeItem) {
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
this.props.onChangeActive(this.state.activeItem);
}
}

componentWillUnmount() {
this.blurTimer = null;
}

render() {
const {emptyPlaceholder, virtualized, className, itemsClassName, qa} = this.props;
const {
emptyPlaceholder,
virtualized,
className,
itemsClassName,
qa,
role = 'list',
} = this.props;

const {items} = this.state;

return (
<MobileContext.Consumer>
{({mobile}) => (
// The event handler should only be used to capture bubbled events
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={b({mobile}, className)}
Expand All @@ -126,6 +140,7 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
className={b('items', {virtualized}, itemsClassName)}
style={this.getItemsStyle()}
onMouseLeave={this.onMouseLeave}
role={role}
>
{this.renderItems()}
{items.length === 0 && Boolean(emptyPlaceholder) && (
Expand Down Expand Up @@ -224,12 +239,15 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
};

private renderItem = ({index, style}: {index: number; style?: React.CSSProperties}) => {
const {sortHandleAlign} = this.props;
const {sortHandleAlign, role} = this.props;
const {items, activeItem} = this.state;
const item = this.getItemsWithLoading()[index];
const sortable = this.props.sortable && items.length > 1 && !this.getFilter();
const active = index === activeItem || index === this.props.activeItemIndex;
const Item = sortable ? SortableListItem : ListItem;
const selected = Array.isArray(this.props.selectedItemIndex)
? this.props.selectedItemIndex.includes(index)
: index === this.props.selectedItemIndex;

return (
<Item
Expand All @@ -243,9 +261,11 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
renderItem={this.renderItemContent}
itemClassName={this.props.itemClassName}
active={active}
selected={index === this.props.selectedItemIndex}
selected={selected}
onActivate={this.onItemActivate}
onClick={this.props.onItemClick}
role={role === 'listbox' ? 'option' : 'listitem'}
listId={this.props.id ?? this.uniqId}
/>
);
};
Expand Down
5 changes: 4 additions & 1 deletion src/components/List/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ Likewise, you can forward `onFocus` and `onBlur` if you need to repeat the behav
| onItemClick | Item click handler. `(item: any, index: number, fromKeyboard?: bool) => void` | `Function` | |
| deactivateOnLeave | If the flag is set, an item's selection is deactivated once the cursor leaves the item or the list loses its focus. If not set, the last selected item will always be selected. | `Boolean` | true |
| activeItemIndex | If a value is set, an item with this index is rendered as active ~~until the curse is lifted~~. | `Number` | |
| selectedItemIndex | If a value is set, an item with this index is rendered as selected (the background color is from `--g-color-base-selection`). | `Number` | |
| selectedItemIndex | If a value is set, an item with this index is rendered as selected (the background color is from `--g-color-base-selection`). | `Number/Array` | |
| itemClassName | Custom class name to be added to an item container | `String` | |
| itemsClassName | Custom class name to be added to an item list | `String` | |
| role | HTML `role` attribute | `String` | list |
| id | HTML `id` attribute | `string` | |
| onChangeActive | Fires when the index of an option in the listbox, visually indicated as having keyboard focus, is changed. `(index?: number) => void` | `Function` | |
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
16 changes: 13 additions & 3 deletions src/components/List/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@ export class ListItem<T = unknown> extends React.Component<ListItemProps<T>> {
ref = React.createRef<HTMLDivElement>();

render() {
const {item, style, sortable, sortHandleAlign, itemClassName, selected, active} =
this.props;
const {
item,
style,
sortable,
sortHandleAlign,
itemClassName,
selected,
active,
role = 'listitem',
} = this.props;

return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
role="listitem"
role={role}
aria-selected={selected}
data-qa={active ? ListQa.ACTIVE_ITEM : undefined}
className={b(
'item',
Expand All @@ -42,6 +51,7 @@ export class ListItem<T = unknown> extends React.Component<ListItemProps<T>> {
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
ref={this.ref}
id={`${this.props.listId}-item-${this.props.itemIndex}`}
>
{this.renderSortIcon()}
{this.renderContent()}
Expand Down
7 changes: 6 additions & 1 deletion src/components/List/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type ListProps<T = unknown> = QAProps & {
filterPlaceholder?: string;
filter?: string;
activeItemIndex?: number;
selectedItemIndex?: number;
selectedItemIndex?: number | number[];
itemHeight?: number | ((item: ListItemData<T>, itemIndex: number) => number);
itemsHeight?: number | ((items: T[]) => number);
virtualized?: boolean;
Expand All @@ -39,8 +39,11 @@ export type ListProps<T = unknown> = QAProps & {
onFilterEnd?: ({items}: {items: ListItemData<T>[]}) => void;
onSortEnd?: (params: ListSortParams) => void;
autoFocus?: boolean;
role?: React.AriaRole;
loading?: boolean;
onLoadMore?: () => void;
onChangeActive?: (index?: number) => void;
id?: string;
};

export type ListItemProps<T> = {
Expand All @@ -55,4 +58,6 @@ export type ListItemProps<T> = {
onActivate: (index?: number) => void;
renderItem?: ListProps<T>['renderItem'];
onClick?: ListProps<T>['onItemClick'];
role?: React.AriaRole;
listId?: string;
};
1 change: 1 addition & 0 deletions src/components/Select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
| onBlur | `function` | `-` | Handler that is called when the element loses focus. |
| loading | `boolean` | `-` | Add the loading item to the end of the options list. Works like persistant loading indicator while the options list is empty. |
| onLoadMore | `function` | `-` | Fires when loading indicator gets visible. |
| id | `string` | `-` | HTML `id` attribute |

---

Expand Down
20 changes: 19 additions & 1 deletion src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {CnMods} from '../utils/cn';
import {useFocusWithin} from '../utils/interactions';
import {useForkRef} from '../utils/useForkRef';
import {useSelect} from '../utils/useSelect';
import {useUniqId} from '../utils/useUniqId';

import {EmptyOptions, SelectControl, SelectFilter, SelectList, SelectPopup} from './components';
import {DEFAULT_VIRTUALIZATION_THRESHOLD, selectBlock} from './constants';
Expand Down Expand Up @@ -76,6 +77,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
disablePortal,
hasClear = false,
onClose,
id,
} = props;
const [mobile] = useMobile();
const [{filter}, dispatch] = React.useReducer(reducer, initialState);
Expand All @@ -86,7 +88,15 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
const filterRef = React.useRef<SelectFilterRef>(null);
const listRef = React.useRef<List<FlattenOption>>(null);
const handleControlRef = useForkRef(ref, controlRef);
const {value, open, toggleOpen, handleSelection, handleClearValue} = useSelect({
const {
value,
open,
activeIndex,
toggleOpen,
handleSelection,
handleClearValue,
setActiveIndex,
} = useSelect({
onUpdate,
value: propsValue,
defaultValue,
Expand All @@ -96,6 +106,8 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
onClose,
onOpenChange,
});
const uniqId = useUniqId();
const selectId = id ?? uniqId;
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
const options = props.options || getOptionsFromChildren(props.children);
const flattenOptions = getFlattenOptions(options);
const filteredFlattenOptions = filterable
Expand Down Expand Up @@ -242,6 +254,9 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
onKeyDown={handleControlKeyDown}
renderControl={renderControl}
value={value}
popupId={`select-popup-${selectId}`}
selectId={`select-${selectId}`}
activeIndex={activeIndex}
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
/>
<SelectPopup
ref={controlWrapRef}
Expand All @@ -253,6 +268,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
disablePortal={disablePortal}
virtualized={virtualized}
mobile={mobile}
id={`select-popup-${selectId}`}
>
{filterable && (
<SelectFilter
Expand Down Expand Up @@ -281,6 +297,8 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
getOptionGroupHeight={getOptionGroupHeight}
loading={props.loading}
onLoadMore={props.onLoadMore}
selectId={`select-${selectId}`}
onChangeActive={setActiveIndex}
/>
) : (
<EmptyOptions filter={filter} renderEmptyOptions={renderEmptyOptions} />
Expand Down
12 changes: 6 additions & 6 deletions src/components/Select/__tests__/Select.filter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ describe('Select filter', () => {
expect(getByPlaceholderText(FILTER_PLACEHOLDER)).toHaveFocus();
await user.keyboard('1');
// 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 31
expect(getAllByRole('listitem').length).toBe(13);
expect(getAllByRole('option').length).toBe(13);
await user.keyboard('1');
// 11
expect(getAllByRole('listitem').length).toBe(1);
expect(getAllByRole('option').length).toBe(1);
await user.keyboard('1');
// empty
expect(queryAllByRole('listitem').length).toBe(0);
expect(queryAllByRole('option').length).toBe(0);
expect(onFilterChange).toBeCalledTimes(3);
});

Expand All @@ -70,7 +70,7 @@ describe('Select filter', () => {
const selectControl = getByTestId(TEST_QA);
await user.click(selectControl);
await user.keyboard('z');
expect(queryAllByRole('listitem').length).toBe(0);
expect(queryAllByRole('option').length).toBe(0);
getByTestId(EMPTY_OPTIONS_QA);
});

Expand All @@ -85,7 +85,7 @@ describe('Select filter', () => {
await user.click(selectControl);
await user.keyboard('[a][b][c][1][2]');
// filter shouldn`t work due to initialized filterOption
expect(queryAllByRole('listitem').length).toBe(40);
expect(queryAllByRole('option').length).toBe(40);
});

test('should filter options even if filter text is empty', async () => {
Expand All @@ -100,6 +100,6 @@ describe('Select filter', () => {
await user.click(selectControl);
expect(filterOption).toHaveBeenCalled();
// 10, 20, 30, 40
expect(queryAllByRole('listitem').length).toBe(4);
expect(queryAllByRole('option').length).toBe(4);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type ControlProps = {
value: SelectProps['value'];
clearValue: () => void;
hasClear?: boolean;
popupId: string;
selectId: string;
activeIndex?: number;
} & Omit<SelectRenderControlProps, 'onClick'>;

export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((props, ref) => {
Expand All @@ -55,6 +58,9 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
disabled,
value,
hasClear,
popupId,
selectId,
activeIndex,
} = props;
const showOptionsText = Boolean(selectedOptionsContent);
const showPlaceholder = Boolean(placeholder && !showOptionsText);
Expand Down Expand Up @@ -128,9 +134,16 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
<div className={selectControlBlock(controlMods)} role="group">
<button
ref={ref}
role="combobox"
aria-controls={popupId}
className={selectControlButtonBlock(buttonMods, className)}
aria-haspopup="listbox"
aria-expanded={open}
aria-activedescendant={
activeIndex === undefined
? undefined
: `${selectId}-list-item-${activeIndex}`
}
name={name}
disabled={disabled}
onClick={toggleOpen}
Expand Down
15 changes: 15 additions & 0 deletions src/components/Select/components/SelectList/SelectList.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

$block: '.#{variables.$ns-new}select-list';
$popupBlock: '.#{variables.$ns}popup';
$listBlock: '.#{variables.$ns}list';
$xl-right-padding: '12px';

#{$block} {
Expand Down Expand Up @@ -83,6 +84,20 @@ $xl-right-padding: '12px';
width: 100%;
}

&__item#{$listBlock} {
&__item_selected {
background: none;

&:hover {
background: var(--g-color-base-simple-hover);
}
}

&__item_active {
background: var(--g-color-base-simple-hover);
}
}

&__option {
box-sizing: border-box;
display: flex;
Expand Down
Loading