136 lines
3.8 KiB
TypeScript
136 lines
3.8 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from "react";
|
|
|
|
export interface SelectableListProps<T> {
|
|
items: T[];
|
|
selectedValue?: T | null;
|
|
renderLabel: (item: T) => React.ReactNode;
|
|
onSelect: (item: T) => void;
|
|
getChildren?: (item: T) => T[] | undefined;
|
|
}
|
|
|
|
export const SelectableList = <T,>(
|
|
props: SelectableListProps<T>,
|
|
): JSX.Element => {
|
|
const { items, selectedValue, renderLabel, onSelect, getChildren } = props;
|
|
|
|
const flattenedItems = React.useMemo(() => {
|
|
const flattened: { item: T; depth: number }[] = [];
|
|
|
|
const walk = (source: T[], depth: number) => {
|
|
source.forEach((item) => {
|
|
flattened.push({ item, depth });
|
|
|
|
const children = getChildren?.(item) ?? [];
|
|
if (children.length > 0) {
|
|
walk(children, depth + 1);
|
|
}
|
|
});
|
|
};
|
|
|
|
walk(items, 0);
|
|
|
|
return flattened;
|
|
}, [items, getChildren]);
|
|
|
|
const flatItems = flattenedItems.map((entry) => entry.item);
|
|
|
|
const listRef = useRef<HTMLUListElement | null>(null);
|
|
const isFocusedRef = useRef(false);
|
|
const prevSelectedValueRef = useRef<T | null | undefined>(selectedValue);
|
|
|
|
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLUListElement>) => {
|
|
if (!isFocusedRef.current) return;
|
|
if (!flatItems.length) return;
|
|
|
|
const currentIndex = selectedValue
|
|
? flatItems.indexOf(selectedValue)
|
|
: -1;
|
|
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
const nextIndex =
|
|
currentIndex < flatItems.length - 1 ? currentIndex + 1 : 0;
|
|
onSelect(flatItems[nextIndex]);
|
|
}
|
|
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const prevIndex =
|
|
currentIndex > 0 ? currentIndex - 1 : flatItems.length - 1;
|
|
onSelect(flatItems[prevIndex]);
|
|
}
|
|
},
|
|
[flatItems, selectedValue, onSelect],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isFocusedRef.current) return;
|
|
if (!selectedValue) return;
|
|
|
|
const index = flatItems.indexOf(selectedValue);
|
|
if (index < 0) return;
|
|
|
|
const el = itemRefs.current[index];
|
|
if (el) {
|
|
el.focus({ preventScroll: false });
|
|
}
|
|
}, [flatItems, selectedValue]);
|
|
|
|
// Separate effect for scrolling - only when selection changes
|
|
useEffect(() => {
|
|
// Only scroll if the selected value actually changed
|
|
if (prevSelectedValueRef.current === selectedValue) return;
|
|
|
|
prevSelectedValueRef.current = selectedValue;
|
|
|
|
if (!selectedValue) return;
|
|
|
|
const index = flatItems.indexOf(selectedValue);
|
|
if (index < 0) return;
|
|
|
|
const el = itemRefs.current[index];
|
|
if (el) {
|
|
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
}
|
|
}, [selectedValue, flatItems]);
|
|
|
|
return (
|
|
<ul
|
|
ref={listRef}
|
|
className="selectable-list"
|
|
tabIndex={0}
|
|
role="listbox"
|
|
aria-activedescendant={
|
|
selectedValue ? `option-${flatItems.indexOf(selectedValue)}` : undefined
|
|
}
|
|
onFocus={() => (isFocusedRef.current = true)}
|
|
onBlur={() => (isFocusedRef.current = false)}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{flattenedItems.map(({ item, depth }, index) => {
|
|
const isSelected = selectedValue === item;
|
|
const className = isSelected ? "selected" : "";
|
|
|
|
return (
|
|
<li
|
|
key={index}
|
|
id={`option-${index}`}
|
|
ref={(el) => (itemRefs.current[index] = el)}
|
|
tabIndex={isSelected ? 0 : -1}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => onSelect(item)}
|
|
className={className}
|
|
style={{ paddingLeft: `${depth * 16}px` }}
|
|
>
|
|
{renderLabel(item)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
};
|