webui/src/components/common/SelectableList.tsx

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