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
18 changes: 18 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 @@ -63,6 +72,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
28 changes: 18 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,9 @@ 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.props.ariaAttributes}
>
{this.renderItems()}
{items.length === 0 && Boolean(emptyPlaceholder) && (
Expand Down Expand Up @@ -201,11 +211,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 +229,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 +251,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 +276,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
30 changes: 26 additions & 4 deletions src/components/List/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,25 @@ 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,
ariaAttributes,
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,9 +51,13 @@ 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}
{...ariaAttributes}
>
{this.renderSortIcon()}
{this.renderContent()}
Expand Down Expand Up @@ -74,6 +90,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
9 changes: 8 additions & 1 deletion src/components/List/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 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,8 @@ export type ListProps<T = unknown> = QAProps & {
onFilterEnd?: ({items}: {items: ListItemData<T>[]}) => void;
onSortEnd?: (params: ListSortParams) => void;
autoFocus?: boolean;
ariaAttributes?: React.AriaAttributes;
role?: React.AriaRole;
loading?: boolean;
onLoadMore?: () => void;
};
Expand All @@ -53,4 +57,7 @@ export type ListItemProps<T> = {
onActivate: (index?: number) => void;
renderItem?: ListProps<T>['renderItem'];
onClick?: ListProps<T>['onItemClick'];
ariaAttributes?: React.AriaAttributes;
role?: React.AriaRole;
selectedValue?: string[];
};
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