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>
      )}
    </>
  );
}