diff --git a/src/components/List/List.scss b/src/components/List/List.scss index 563c36890f..7156d11daa 100644 --- a/src/components/List/List.scss +++ b/src/components/List/List.scss @@ -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 { diff --git a/src/components/List/List.tsx b/src/components/List/List.tsx index 79cbf999aa..390768d4cc 100644 --- a/src/components/List/List.tsx +++ b/src/components/List/List.tsx @@ -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'; @@ -82,8 +83,9 @@ export class List extends React.Component, ListState; + uniqId = getUniqId(); - componentDidUpdate(prevProps: ListProps) { + componentDidUpdate(prevProps: ListProps, prevState: ListState) { if (this.props.items !== prevProps.items) { const filter = this.getFilter(); const internalFiltering = filter && !this.props.onFilterUpdate; @@ -98,6 +100,10 @@ export class List extends React.Component, ListState extends React.Component, ListState {({mobile}) => ( + // The event handler should only be used to capture bubbled events // eslint-disable-next-line jsx-a11y/no-static-element-interactions
extends React.Component, ListState {this.renderItems()} {items.length === 0 && Boolean(emptyPlaceholder) && ( @@ -224,12 +239,15 @@ export class List extends React.Component, ListState { - 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 ( extends React.Component, ListState ); }; diff --git a/src/components/List/README.md b/src/components/List/README.md index bf68204514..28b6a448c8 100644 --- a/src/components/List/README.md +++ b/src/components/List/README.md @@ -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` | | diff --git a/src/components/List/components/ListItem.tsx b/src/components/List/components/ListItem.tsx index a1bd8d903d..cf7541b20b 100644 --- a/src/components/List/components/ListItem.tsx +++ b/src/components/List/components/ListItem.tsx @@ -17,13 +17,22 @@ export class ListItem extends React.Component> { ref = React.createRef(); 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
extends React.Component> { onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave} ref={this.ref} + id={`${this.props.listId}-item-${this.props.itemIndex}`} > {this.renderSortIcon()} {this.renderContent()} diff --git a/src/components/List/types.ts b/src/components/List/types.ts index 0889cd4bb7..cd03e07b01 100644 --- a/src/components/List/types.ts +++ b/src/components/List/types.ts @@ -19,7 +19,7 @@ export type ListProps = QAProps & { filterPlaceholder?: string; filter?: string; activeItemIndex?: number; - selectedItemIndex?: number; + selectedItemIndex?: number | number[]; itemHeight?: number | ((item: ListItemData, itemIndex: number) => number); itemsHeight?: number | ((items: T[]) => number); virtualized?: boolean; @@ -39,8 +39,11 @@ export type ListProps = QAProps & { onFilterEnd?: ({items}: {items: ListItemData[]}) => void; onSortEnd?: (params: ListSortParams) => void; autoFocus?: boolean; + role?: React.AriaRole; loading?: boolean; onLoadMore?: () => void; + onChangeActive?: (index?: number) => void; + id?: string; }; export type ListItemProps = { @@ -55,4 +58,6 @@ export type ListItemProps = { onActivate: (index?: number) => void; renderItem?: ListProps['renderItem']; onClick?: ListProps['onItemClick']; + role?: React.AriaRole; + listId?: string; }; diff --git a/src/components/Select/README.md b/src/components/Select/README.md index 9b1bd9f5e1..b132aa9f5c 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -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 | --- diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index a14a78c450..c852f89c5b 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -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'; @@ -76,6 +77,7 @@ export const Select = React.forwardRef(function disablePortal, hasClear = false, onClose, + id, } = props; const [mobile] = useMobile(); const [{filter}, dispatch] = React.useReducer(reducer, initialState); @@ -86,7 +88,15 @@ export const Select = React.forwardRef(function const filterRef = React.useRef(null); const listRef = React.useRef>(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, @@ -96,6 +106,8 @@ export const Select = React.forwardRef(function onClose, onOpenChange, }); + const uniqId = useUniqId(); + const selectId = id ?? uniqId; const options = props.options || getOptionsFromChildren(props.children); const flattenOptions = getFlattenOptions(options); const filteredFlattenOptions = filterable @@ -242,6 +254,9 @@ export const Select = React.forwardRef(function onKeyDown={handleControlKeyDown} renderControl={renderControl} value={value} + popupId={`select-popup-${selectId}`} + selectId={`select-${selectId}`} + activeIndex={activeIndex} /> (function disablePortal={disablePortal} virtualized={virtualized} mobile={mobile} + id={`select-popup-${selectId}`} > {filterable && ( (function getOptionGroupHeight={getOptionGroupHeight} loading={props.loading} onLoadMore={props.onLoadMore} + selectId={`select-${selectId}`} + onChangeActive={setActiveIndex} /> ) : ( diff --git a/src/components/Select/__tests__/Select.filter.test.tsx b/src/components/Select/__tests__/Select.filter.test.tsx index df0924ed4b..2fa1be18ab 100644 --- a/src/components/Select/__tests__/Select.filter.test.tsx +++ b/src/components/Select/__tests__/Select.filter.test.tsx @@ -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); }); @@ -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); }); @@ -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 () => { @@ -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); }); }); diff --git a/src/components/Select/components/SelectControl/SelectControl.tsx b/src/components/Select/components/SelectControl/SelectControl.tsx index e117f687c4..dbbb2d73d1 100644 --- a/src/components/Select/components/SelectControl/SelectControl.tsx +++ b/src/components/Select/components/SelectControl/SelectControl.tsx @@ -55,6 +55,9 @@ export const SelectControl = React.forwardRef(( disabled, value, hasClear, + popupId, + selectId, + activeIndex, } = props; const showOptionsText = Boolean(selectedOptionsContent); const showPlaceholder = Boolean(placeholder && !showOptionsText); @@ -118,6 +121,9 @@ export const SelectControl = React.forwardRef(( ref, open: Boolean(open), renderClear: (arg) => renderClearIcon(arg), + popupId, + selectId, + activeIndex, }, {value}, ); @@ -128,9 +134,16 @@ export const SelectControl = React.forwardRef((