| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { Fragment, useMemo, useRef, useState } from "react"; |
| import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; |
| import { AnchoredOverlay } from "./OverlaySurface"; |
| import { FieldTypeIcon } from "./icons"; |
| import { TYPE_LABELS } from "./iconShapes"; |
| import type { FieldType } from "./types"; |
|
|
| |
| export type FieldSelectType = FieldType | "measure"; |
|
|
| export interface FieldSelectItem { |
| key: string; |
| label: string; |
| |
| type?: FieldSelectType; |
| |
| |
| icon?: ReactNode; |
| |
| |
| |
| |
| group?: string; |
| |
| hint?: string; |
| disabled?: boolean; |
| } |
|
|
| |
| export { FieldTypeIcon }; |
|
|
| function ItemMark({ item }: { item: FieldSelectItem }) { |
| if (item.icon) return <>{item.icon}</>; |
| if (!item.type) return <span className="cg-fsel-nomark" aria-hidden />; |
| return ( |
| <FieldTypeIcon |
| type={item.type} |
| title={item.type === "measure" ? "Metric" : TYPE_LABELS[item.type]} |
| /> |
| ); |
| } |
|
|
| function CaretDown() { |
| return ( |
| <svg |
| className="cg-fsel-caret" |
| width="10" |
| height="10" |
| viewBox="0 0 16 16" |
| fill="none" |
| aria-hidden |
| > |
| <path |
| d="M4 6l4 4 4-4" |
| stroke="currentColor" |
| strokeWidth={1.5} |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| function CheckMark() { |
| return ( |
| <svg |
| className="cg-type-check" |
| width="14" |
| height="14" |
| viewBox="0 0 16 16" |
| fill="none" |
| aria-hidden |
| > |
| <path |
| d="m3.5 8.5 3 3 6-6.5" |
| stroke="currentColor" |
| strokeWidth={1.6} |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| const SEARCH_FROM = 8; |
|
|
| export function FieldSelectButton({ |
| fields, |
| value, |
| onChange, |
| placeholder = "Choose a field…", |
| allow, |
| ariaLabel, |
| className, |
| disabled, |
| /** Rendered above the list — the surface's own sentence ("Group by", "Copy from"). */ |
| title, |
| /** Keeps the picker open after a choice (the reference-inserter picks repeatedly). */ |
| keepOpen, |
| /** Marks this control as its pane's initial focus target — the attribute `AnchoredOverlay`'s |
| * `initialFocus` selector looks for. A replaced `<select>` 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<HTMLButtonElement>(null); |
| const listRef = useRef<HTMLDivElement>(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<HTMLButtonElement>("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 ( |
| <> |
| <button |
| ref={triggerRef} |
| type="button" |
| disabled={disabled} |
| className={ |
| "cg-select cg-fsel" + |
| (current ? "" : isSet && value !== "" ? " is-missing" : " is-empty") + |
| (className ? ` ${className}` : "") |
| } |
| aria-label={ariaLabel} |
| aria-haspopup="listbox" |
| aria-expanded={open} |
| data-overlay-autofocus={overlayAutofocus ? true : undefined} |
| onClick={() => setOpen((o) => !o)} |
| > |
| {current ? ( |
| <ItemMark item={current} /> |
| ) : ( |
| // No mark, but the SLOT stays: an unresolvable value and the placeholder both keep |
| // the label in the same column as every resolved row above and below them. |
| <span className="cg-fsel-nomark" aria-hidden /> |
| )} |
| <span className="cg-fsel-label"> |
| {/* Set to something this table no longer offers AND the caller did not pass a row |
| for it: the key is shown VERBATIM in the amber `is-missing` slot. Unreadable is |
| the honest rendering of a broken reference, and it is the one thing a <select> |
| could never do — it would show its first option instead. */} |
| {current?.label ?? (isSet && value !== "" ? value : placeholder)} |
| </span> |
| <CaretDown /> |
| </button> |
| {open && triggerRef.current && ( |
| <AnchoredOverlay |
| anchor={triggerRef.current} |
| className="cg-pop cg-fsel-pop" |
| onDismiss={close} |
| role="dialog" |
| ariaLabel={ariaLabel ?? title ?? "Choose a field"} |
| initialFocus={ |
| offered.length >= SEARCH_FROM ? ".cg-fsel-search input" : "button.cg-type-row" |
| } |
| dataKind="field-select" |
| > |
| <div className="cg-fsel-body" onKeyDown={onListKeyDown}> |
| {title && <div className="cg-pop-title">{title}</div>} |
| {offered.length >= SEARCH_FROM && ( |
| <div className="cg-type-search cg-fsel-search"> |
| <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden> |
| <circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" /> |
| <path |
| d="m10.4 10.4 3.1 3.1" |
| stroke="currentColor" |
| strokeWidth="1.35" |
| strokeLinecap="round" |
| /> |
| </svg> |
| <input |
| type="text" |
| value={q} |
| placeholder="Find a field…" |
| aria-label="Find a field" |
| onChange={(event) => setQ(event.target.value)} |
| /> |
| </div> |
| )} |
| <div className="cg-type-list" role="listbox" ref={listRef} |
| aria-label={ariaLabel ?? title ?? "Fields"}> |
| {shown.map((item) => { |
| const heading = item.group && item.group !== lastGroup ? item.group : null; |
| lastGroup = item.group; |
| return ( |
| // A Fragment, not a wrapper: `.cg-type-list` is the flex column that lays |
| // the rows out, and a wrapper would need `display: contents` to stay out of |
| // the way — which drops the element from the accessibility tree in exactly |
| // the container that is claiming to be a listbox. |
| <Fragment key={item.key}> |
| {heading && ( |
| <div className="cg-fsel-group" role="presentation"> |
| {heading} |
| </div> |
| )} |
| <button |
| type="button" |
| role="option" |
| disabled={item.disabled} |
| aria-selected={item.key === value} |
| className={"cg-type-row" + (item.key === value ? " is-on" : "")} |
| onClick={() => { |
| onChange(item.key); |
| if (keepOpen) setQ(""); |
| else close(); |
| }} |
| > |
| <ItemMark item={item} /> |
| <span className="cg-type-label"> |
| {item.label} |
| {item.hint && <span className="cg-fsel-hint">{item.hint}</span>} |
| </span> |
| {item.key === value && <CheckMark />} |
| </button> |
| </Fragment> |
| ); |
| })} |
| {shown.length === 0 && ( |
| <div className="cg-pop-note"> |
| {offered.length === 0 ? "No field to choose here." : "No field matches."} |
| </div> |
| )} |
| </div> |
| </div> |
| </AnchoredOverlay> |
| )} |
| </> |
| ); |
| } |
| |