// --------------------------------------------------------------------------- // customer-grid / FieldSelect.tsx // Wave-14 item 20 (contract C-FLDSEL) — the field picker that can show an icon. // // A native : // unset -> the PLACEHOLDER, never the first field // set and offered -> that field's mark + label // set but NOT offered -> the field's own label if the caller still passes it // (Toolbar's `withCurrentField` does), else the raw key // in a muted "missing" slot // The third is why `` that carried it must keep it, * or the pane opens with focus nowhere. */ overlayAutofocus, }: { fields: readonly FieldSelectItem[]; value?: string; onChange: (key: string) => void; placeholder?: string; /** Restrict the OFFER by type. The current value is still resolved against the full list, * so narrowing the offer never hides what the rule actually holds. */ allow?: readonly FieldSelectType[]; ariaLabel?: string; className?: string; disabled?: boolean; title?: string; keepOpen?: boolean; overlayAutofocus?: boolean; }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const triggerRef = useRef(null); const listRef = useRef(null); const offered = useMemo( () => allow ? fields.filter((f) => f.type != null && allow.includes(f.type)) : fields, [fields, allow] ); /** * ⚠ `""` is a VALUE here, not the absence of one — the map's encodings offer an explicit * "No colour" row whose key is the empty string, so the list can MARK the none state * instead of leaving every row unmarked. Which means "is anything set" cannot be `!!value`, * and (the bug this replaced) the label cannot be `current?.label ?? value ?? placeholder`: * `??` falls through on null/undefined only, so an empty-string value rendered an EMPTY * BUTTON where the placeholder belonged. */ const isSet = value != null; // Resolved over the FULL list, never the offer — see the three states above. const current = isSet ? fields.find((f) => f.key === value) : undefined; const needle = q.trim().toLowerCase(); const shown = needle ? offered.filter((f) => f.label.toLowerCase().includes(needle)) : offered; // No explicit re-focus of the trigger: `AnchoredOverlay`'s `restoreFocus` (default) already // returns focus to whatever was focused when it mounted, which is this button. Doing it here // too would be a second answer to the same question, and the two would disagree the day the // overlay's rule changes. const close = () => { setOpen(false); setQ(""); }; /** Arrow keys move between the option buttons; the find box is part of the loop, so * ArrowDown out of it lands on the first row rather than on nothing. */ const onListKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return; const rows = Array.from( listRef.current?.querySelectorAll("button.cg-type-row") ?? [] ).filter((el) => !el.disabled); if (!rows.length) return; event.preventDefault(); const at = rows.indexOf(document.activeElement as HTMLButtonElement); if (event.key === "Home") return rows[0].focus(); if (event.key === "End") return rows[rows.length - 1].focus(); const step = event.key === "ArrowDown" ? 1 : -1; // From outside the rows (the find box), Down enters at the top and Up at the bottom. const next = at < 0 ? (step === 1 ? 0 : rows.length - 1) : (at + step + rows.length) % rows.length; rows[next].focus(); }; let lastGroup: string | undefined; return ( <> ); })} {shown.length === 0 && (
{offered.length === 0 ? "No field to choose here." : "No field matches."}
)} )} ); }