textmachine/frontend/src/ui/List.tsx

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>
);
}