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
22 changes: 22 additions & 0 deletions src/components/List/List.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ $block: '.#{variables.$ns}list';
&_virtualized {
height: var(--yc-list-height);
}

&:focus {
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
outline: none;
box-shadow: 0 0 0 2px var(--g-color-line-misc);
}

&:focus:not(:focus-visible) {
box-shadow: none;
}
}

&__item,
Expand Down Expand Up @@ -53,6 +62,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 All @@ -63,6 +76,15 @@ $block: '.#{variables.$ns}list';
margin-right: 0;
}
}

&:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--g-color-line-misc);
}

&:focus:not(:focus-visible) {
box-shadow: none;
}
}

&__empty-placeholder {
Expand Down
27 changes: 17 additions & 10 deletions src/components/List/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
filter: '',
};

refFilter = React.createRef<HTMLInputElement>();
refContainer = React.createRef<any>();
blurTimer: ReturnType<typeof setTimeout> | null = null;
loadingItem = {value: '__LIST_ITEM_LOADING__', disabled: true} as unknown as ListItemData<
Expand Down Expand Up @@ -105,13 +104,21 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
}

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 +133,8 @@ 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}
tabIndex={role === 'listbox' ? 0 : undefined}
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
>
{this.renderItems()}
{items.length === 0 && Boolean(emptyPlaceholder) && (
Expand Down Expand Up @@ -201,11 +210,6 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
}
break;
}
default: {
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
if (this.refFilter.current) {
this.refFilter.current.focus();
}
}
}
};

Expand All @@ -224,12 +228,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 +250,10 @@ 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'}
/>
);
};
Expand All @@ -267,7 +275,6 @@ export class List<T = unknown> extends React.Component<ListProps<T>, ListState<T
return (
<div className={b('filter', filterClassName)}>
<TextInput
controlRef={this.refFilter}
size={size}
placeholder={filterPlaceholder}
value={filter}
Expand Down
3 changes: 2 additions & 1 deletion src/components/List/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ 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 |
28 changes: 24 additions & 4 deletions src/components/List/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,24 @@ 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}
tabIndex={
(this.props.onClick || role === 'option') && !item.disabled ? 0 : undefined
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
}
aria-selected={selected}
data-qa={active ? ListQa.ACTIVE_ITEM : undefined}
className={b(
'item',
Expand All @@ -39,8 +50,11 @@ export class ListItem<T = unknown> extends React.Component<ListItemProps<T>> {
style={style}
onClick={item.disabled ? undefined : this.onClick}
onClickCapture={item.disabled ? undefined : this.onClickCapture}
onKeyDown={item.disabled ? undefined : this.onKeyDown}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onFocus={this.onMouseEnter}
onBlur={this.onMouseLeave}
ref={this.ref}
>
{this.renderSortIcon()}
Expand Down Expand Up @@ -74,6 +88,12 @@ export class ListItem<T = unknown> extends React.Component<ListItemProps<T>> {
});
};

private onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (this.props.onClick && [' ', 'Spacebar'].includes(event.key)) {
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
this.onClick();
}
};

private onMouseEnter = () =>
!this.props.item.disabled && this.props.onActivate(this.props.itemIndex);

Expand Down
1 change: 1 addition & 0 deletions src/components/List/components/SimpleContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class SimpleContainer extends React.Component<SimpleContainerProps, Simpl
ref.current.scrollIntoView?.({
block: 'nearest',
});
ref.current.focus();
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/List/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type React from 'react';

import type {TextInputSize} from '../controls';
import type {QAProps} from '../types';

Expand All @@ -17,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 @@ -37,6 +39,7 @@ 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;
};
Expand All @@ -53,4 +56,5 @@ export type ListItemProps<T> = {
onActivate: (index?: number) => void;
renderItem?: ListProps<T>['renderItem'];
onClick?: ListProps<T>['onItemClick'];
role?: React.AriaRole;
};
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);
});
});
13 changes: 13 additions & 0 deletions src/components/Select/components/SelectList/SelectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export const SelectList = React.forwardRef<List<FlattenOption>, SelectListProps>
[flattenOptions, loading],
);

const selectedIndexes = React.useMemo(
() =>
flattenOptions.reduce<number[]>((acc, option, index) => {
if ('value' in option && value.includes(option.value)) {
acc.push(index);
}
return acc;
}, []),
[flattenOptions, value],
);

const optionsHeight = getOptionsHeight({
options: items,
getOptionHeight,
Expand Down Expand Up @@ -127,6 +138,8 @@ export const SelectList = React.forwardRef<List<FlattenOption>, SelectListProps>
virtualized={virtualized}
renderItem={renderItem}
onItemClick={onOptionClick}
selectedItemIndex={selectedIndexes}
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
role="listbox"
/>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const SelectPopup = React.forwardRef<HTMLDivElement, SelectPopupProps>(
open={open}
onClose={handleClose}
disablePortal={disablePortal}
focusTrap
zamkovskaya marked this conversation as resolved.
Show resolved Hide resolved
restoreFocus
restoreFocusRef={controlRef}
modifiers={getModifiers({width, disablePortal, virtualized})}
Expand Down