File size: 12,793 Bytes
016c754 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | // ---------------------------------------------------------------------------
// 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>
)}
</>
);
}
|