62 lines
2.2 KiB
TypeScript
62 lines
2.2 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { ListBox, ListBoxItem, ListLayout, Virtualizer } from 'react-aria-components';
|
|
|
|
import { measures } from '../tokens/measures';
|
|
import styles from './List.module.css';
|
|
|
|
export interface ListRow {
|
|
id: string;
|
|
/** The row's text for type-ahead from the keyboard and for the screen reader. */
|
|
text: string;
|
|
content: ReactNode;
|
|
}
|
|
|
|
interface Props {
|
|
label: string;
|
|
items: ListRow[];
|
|
/** What to show instead of the list when it is empty. */
|
|
empty?: ReactNode;
|
|
/** Row height in pixels: the virtualizer needs it as a number, CSS is not visible to it. */
|
|
rowSize?: number;
|
|
/**
|
|
* The row leads somewhere. The list then becomes selectable as a whole — a button must not be
|
|
* nested into a row: children of the `option` role are presentational, and axe fails the build
|
|
* on `nested-interactive` (the same trap as the tab's close cross, Ф-17).
|
|
*/
|
|
onSelect?: (id: string) => void;
|
|
}
|
|
|
|
/**
|
|
* A flat list of one row height, virtualized by default: a book's bank is hundreds of terms in a
|
|
* row, and an unvirtualized list falls over here just as the chapter tree does (Ф-12).
|
|
*/
|
|
export function List({ label, items, empty, rowSize, onSelect }: Props) {
|
|
// The empty state is drawn INSTEAD of the list, not inside it: the library's `renderEmptyState`
|
|
// wraps the content into `role="option"`, and the screen reader announces the placeholder as a
|
|
// selectable option — one that has, on top of that, nothing to select.
|
|
if (items.length === 0) return <p className={styles.empty}>{empty}</p>;
|
|
|
|
return (
|
|
<Virtualizer
|
|
layout={ListLayout}
|
|
layoutOptions={{ rowSize: rowSize ?? measures['--row-height'] }}
|
|
>
|
|
<ListBox
|
|
className={onSelect ? `${styles.list} ${styles.selectable}` : styles.list}
|
|
aria-label={label}
|
|
items={items}
|
|
selectionMode={onSelect ? 'single' : 'none'}
|
|
selectedKeys={[]}
|
|
onSelectionChange={(keys) => {
|
|
if (keys !== 'all') for (const key of keys) onSelect?.(String(key));
|
|
}}
|
|
>
|
|
{(item) => (
|
|
<ListBoxItem className={styles.item} textValue={item.text}>
|
|
{item.content}
|
|
</ListBoxItem>
|
|
)}
|
|
</ListBox>
|
|
</Virtualizer>
|
|
);
|
|
}
|