loopable / web /src /customer-grid /FieldSelect.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
016c754 verified
Raw
History Blame Contribute Delete
12.8 kB
// ---------------------------------------------------------------------------
// customer-grid / FieldSelect.tsx
// Wave-14 item 20 (contract C-FLDSEL) — the field picker that can show an icon.
//
// A native <select> cannot render a mark beside an option, so every surface that
// asks "which field?" was a bare list of labels while the column header, the
// Hide-fields panel and the create picker all wore the type vocabulary. This is
// that vocabulary's fourth painter: a button in the `.cg-select` footprint that
// opens a listbox of icon + label rows.
//
// THREE STATES, not two — the whole reason this replaces a <select>:
// 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 `<select>` was dangerous here: a `value` matching no <option>
// renders the FIRST one, so a saved view holding a dropped column displayed a
// different field than it held and the first edit wrote that displayed one back
// ([[cg-condition-builder-items]]).
//
// The rows are real <button>s under role="listbox"/role="option", exactly as
// ColumnMenu's TypePicker ships them: Enter/Space activate natively, Tab moves,
// and the arrow keys are the only thing this file has to add.
// ---------------------------------------------------------------------------
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";
/** The pseudo-kind rides along: a metric is picked from these lists like a column is. */
export type FieldSelectType = FieldType | "measure";
export interface FieldSelectItem {
key: string;
label: string;
/** The type mark. Omit only when `icon` supplies a mark from another vocabulary. */
type?: FieldSelectType;
/** Overrides the type mark — the copy-configuration modal picks VIEWS, which wear their
* display-mode mark rather than a field type. */
icon?: ReactNode;
/** A quiet heading above this row. Rows are rendered in the order given; a heading is
* emitted whenever the group CHANGES, so the caller owns the grouping by ordering its
* own list. ⚠ Do not group the filter builder's field list — owner item 1 of the previous
* wave made that ONE FLAT LIST on purpose. */
group?: string;
/** A second line under the label (a metric's period, a view's mode). */
hint?: string;
disabled?: boolean;
}
/** Re-exported so a consumer needs one import for the pair (C-FLDSEL names both). */
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>
);
}
/** Above this many rows the find box earns its place; below it, it is one more thing to
* look past on a list you can already see all of. */
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>
)}
</>
);
}