import React from 'react'; import { cn } from '../../shared/utils'; import { useMapTokenColors } from '../map/use-map-token-color'; import type { ColorToken, MapPrecision } from '../map/map.types'; export interface MapFeatureListItem { id: string; label: string; /** Secondary line — a value, a count, a status. */ description?: string; colorToken?: ColorToken; /** How the position was obtained, surfaced so the reader can judge it. */ precision?: MapPrecision; precisionLabel?: string; data?: unknown; } export interface MapFeatureListProps extends Omit< React.HTMLAttributes, 'onSelect' > { features: MapFeatureListItem[]; selectedId?: string | null; onSelect: (id: string | null) => void; /** Replaces the default row rendering. */ renderItem?: (feature: MapFeatureListItem, selected: boolean) => React.ReactNode; title?: string; /** @default "Map features" */ ariaLabel?: string; /** @default "No features to list" */ emptyLabel?: string; /** Caps the height and scrolls. */ maxHeight?: string; } /** * The map's content as an operable list. * * @description * A map is a spatial control, and a spatial control alone is not reachable by * everyone. Pairing it with a list that shares `selectedId` in both directions * gives the same information and the same actions through plain keyboard and * screen-reader navigation — selecting in the list highlights the point, and * selecting the point highlights the row. * * Implemented as a `listbox`: arrow keys move, Enter and Space select, Escape * clears, and roving `tabIndex` keeps the whole list to a single tab stop * instead of one per feature. * * @ai-rules * 1. Share one `selectedId` state between `` and this list — that two-way link is the point. * 2. Do not hide the list at small breakpoints. It is the accessible path to the same data, not a desktop extra. * 3. Surface `precisionLabel` whenever positions are centroids, so an approximate location is never read as exact. */ export const MapFeatureList = React.forwardRef( ( { features, selectedId, onSelect, renderItem, title, ariaLabel = 'Map features', emptyLabel = 'No features to list', maxHeight, className, ...props }, ref ) => { const tokens = React.useMemo( () => features.map(feature => feature.colorToken).filter(Boolean) as ColorToken[], [features] ); const colors = useMapTokenColors(tokens); const selectedIndex = features.findIndex(feature => feature.id === selectedId); // Roving tabindex: the list is one tab stop, arrows move within it. const [activeIndex, setActiveIndex] = React.useState(0); React.useEffect(() => { if (selectedIndex >= 0) setActiveIndex(selectedIndex); }, [selectedIndex]); const itemRefs = React.useRef>([]); const focusItem = (index: number) => { const clamped = Math.max(0, Math.min(features.length - 1, index)); setActiveIndex(clamped); itemRefs.current[clamped]?.focus(); }; const handleKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case 'ArrowDown': event.preventDefault(); focusItem(activeIndex + 1); break; case 'ArrowUp': event.preventDefault(); focusItem(activeIndex - 1); break; case 'Home': event.preventDefault(); focusItem(0); break; case 'End': event.preventDefault(); focusItem(features.length - 1); break; case 'Enter': case ' ': event.preventDefault(); onSelect(features[activeIndex]?.id ?? null); break; case 'Escape': event.preventDefault(); onSelect(null); break; default: break; } }; return (
{title ? (

{title}

) : null} {features.length === 0 ? (

{emptyLabel}

) : (
    {features.map((feature, index) => { const selected = feature.id === selectedId; return (
  • { itemRefs.current[index] = element; }} role="option" aria-selected={selected} tabIndex={index === activeIndex ? 0 : -1} onClick={() => onSelect(feature.id)} onFocus={() => setActiveIndex(index)} className={cn( 'flex cursor-pointer items-start gap-2 px-3 py-2 text-xs outline-none', 'focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50' )} > {renderItem ? ( renderItem(feature, selected) ) : ( <> {feature.colorToken ? (
  • ); })}
)}
); } ); MapFeatureList.displayName = 'MapFeatureList';