@gtivr4/a1-design-system-react 0.14.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/color-scheme.css +2 -0
  3. package/src/components/accordion/Accordion.d.ts +8 -0
  4. package/src/components/accordion/Accordion.jsx +9 -1
  5. package/src/components/accordion/accordion.css +46 -6
  6. package/src/components/autocomplete/Autocomplete.d.ts +53 -0
  7. package/src/components/autocomplete/Autocomplete.jsx +380 -0
  8. package/src/components/autocomplete/autocomplete.css +346 -0
  9. package/src/components/banner/Banner.d.ts +9 -2
  10. package/src/components/banner/Banner.jsx +32 -6
  11. package/src/components/banner/banner.css +81 -0
  12. package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
  13. package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
  14. package/src/components/bottom-sheet/bottom-sheet.css +113 -0
  15. package/src/components/button/button.css +7 -3
  16. package/src/components/code/Code.jsx +6 -1
  17. package/src/components/data-table/DataTable.jsx +11 -1
  18. package/src/components/data-table/data-table.css +19 -0
  19. package/src/components/figure/Figure.d.ts +37 -4
  20. package/src/components/figure/Figure.jsx +78 -9
  21. package/src/components/figure/figure.css +105 -8
  22. package/src/components/grid/Grid.d.ts +1 -1
  23. package/src/components/grid/Grid.jsx +2 -0
  24. package/src/components/grid/grid.css +5 -0
  25. package/src/components/icon-button/IconButton.d.ts +2 -2
  26. package/src/components/icon-button/IconButton.jsx +3 -2
  27. package/src/components/icon-button/icon-button.css +11 -1
  28. package/src/components/menu/Menu.jsx +12 -0
  29. package/src/components/menu/menu.css +17 -6
  30. package/src/components/page-layout/page-layout.css +10 -4
  31. package/src/components/page-nav/PageNav.jsx +29 -8
  32. package/src/components/page-nav/page-nav.css +13 -0
  33. package/src/components/paragraph/Paragraph.d.ts +2 -0
  34. package/src/components/paragraph/Paragraph.jsx +4 -0
  35. package/src/components/paragraph/paragraph.css +6 -6
  36. package/src/components/section/Section.d.ts +6 -0
  37. package/src/components/section/Section.jsx +19 -0
  38. package/src/components/section/section.css +33 -10
  39. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  40. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  41. package/src/components/segmented-control/segmented.css +31 -1
  42. package/src/components/slider/Slider.d.ts +71 -0
  43. package/src/components/slider/Slider.jsx +243 -0
  44. package/src/components/slider/slider.css +238 -0
  45. package/src/components/split-button/SplitButton.d.ts +39 -0
  46. package/src/components/split-button/SplitButton.jsx +94 -0
  47. package/src/components/split-button/split-button.css +40 -0
  48. package/src/components/tabs/tabs.css +3 -0
  49. package/src/components/toolbar/Toolbar.d.ts +131 -0
  50. package/src/components/toolbar/Toolbar.jsx +335 -0
  51. package/src/components/toolbar/toolbar.css +229 -0
  52. package/src/components/top-header/top-header.css +2 -0
  53. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  54. package/src/index.d.ts +71 -0
  55. package/src/index.js +15 -1
  56. package/src/themes.css +293 -0
  57. package/src/tokens.css +26 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtivr4/a1-design-system-react",
3
- "version": "0.14.0",
3
+ "version": "0.18.0",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -40,5 +40,6 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "react": ">=18"
43
- }
43
+ },
44
+ "types": "./src/index.d.ts"
44
45
  }
@@ -443,8 +443,10 @@ html.a1-theme-dark .a1-theme-light .a1-notification--default {
443
443
  html.a1-theme-light {
444
444
  color-scheme: light;
445
445
  --semantic-color-surface-page: var(--base-color-neutral-0);
446
+ --semantic-color-surface-card: var(--base-color-neutral-0);
446
447
  --semantic-color-surface-panel: var(--base-color-neutral-50);
447
448
  --semantic-color-surface-raised: var(--base-color-neutral-100);
449
+ --semantic-color-surface-inverse: var(--base-color-neutral-900);
448
450
  --semantic-color-text-default: var(--base-color-neutral-900);
449
451
  --semantic-color-text-muted: var(--base-color-neutral-600);
450
452
  --semantic-color-text-inverse: var(--base-color-neutral-0);
@@ -3,6 +3,12 @@ import * as React from "react";
3
3
  export interface AccordionProps {
4
4
  /** Trigger label text */
5
5
  label: string;
6
+ /**
7
+ * Secondary information shown in the trigger, **below** the label. It only
8
+ * shows while the accordion is collapsed (a glanceable summary, e.g. the
9
+ * applied settings) and is hidden when open. Truncates with an ellipsis.
10
+ */
11
+ subtext?: React.ReactNode;
6
12
  /** Controlled open state */
7
13
  open?: boolean;
8
14
  /** Initial open state (uncontrolled). Default: false */
@@ -11,6 +17,8 @@ export interface AccordionProps {
11
17
  onChange?: (open: boolean) => void;
12
18
  /** Size — affects trigger text size and padding. Default: "md" */
13
19
  size?: "sm" | "md" | "lg";
20
+ /** Show a divider under the trigger/header (it stays attached to the header, not the bottom of the open panel). Default: false */
21
+ divider?: boolean;
14
22
  /** Prevent the accordion from being toggled. Default: false */
15
23
  disabled?: boolean;
16
24
  className?: string;
@@ -6,11 +6,13 @@ const SIZES = ["sm", "md", "lg"];
6
6
 
7
7
  export function Accordion({
8
8
  label,
9
+ subtext,
9
10
  children,
10
11
  open: controlledOpen,
11
12
  defaultOpen = false,
12
13
  onChange,
13
14
  size = "md",
15
+ divider = false,
14
16
  disabled = false,
15
17
  className = "",
16
18
  ...rest
@@ -46,6 +48,7 @@ export function Accordion({
46
48
  "a1-accordion",
47
49
  `a1-accordion--${resolvedSize}`,
48
50
  open && "a1-accordion--open",
51
+ divider && "a1-accordion--divider",
49
52
  disabled && "a1-accordion--disabled",
50
53
  className,
51
54
  ].filter(Boolean).join(" ")}
@@ -63,7 +66,12 @@ export function Accordion({
63
66
  <span className="a1-accordion__chevron" aria-hidden="true">
64
67
  <Icon name="expand_more" />
65
68
  </span>
66
- <span className="a1-accordion__label">{label}</span>
69
+ <span className="a1-accordion__text">
70
+ <span className="a1-accordion__label">{label}</span>
71
+ {subtext != null && subtext !== "" && (
72
+ <span className="a1-accordion__subtext">{subtext}</span>
73
+ )}
74
+ </span>
67
75
  </button>
68
76
 
69
77
  <div
@@ -3,31 +3,34 @@
3
3
  .a1-accordion {
4
4
  /* Size tokens — default (md) */
5
5
  --a1-ac-height: var(--component-accordion-trigger-height-md);
6
- --a1-ac-px: var(--component-accordion-padding-inline-md);
6
+ --a1-ac-px: var(--base-spacing-8);
7
7
  --a1-ac-py: var(--base-spacing-8);
8
8
  --a1-ac-icon-size: var(--component-accordion-icon-size-md);
9
9
  --a1-ac-font-size: var(--semantic-font-size-body-md);
10
10
  --a1-ac-font-weight: var(--base-font-weight-medium);
11
+ --a1-ac-subtext-size: var(--semantic-font-size-body-sm);
11
12
  }
12
13
 
13
14
  /* ─── Sizes ─────────────────────────────────────────────────────────────────── */
14
15
 
15
16
  .a1-accordion--sm {
16
17
  --a1-ac-height: var(--component-accordion-trigger-height-sm);
17
- --a1-ac-px: var(--component-accordion-padding-inline-sm);
18
+ --a1-ac-px: var(--base-spacing-4);
18
19
  --a1-ac-py: var(--base-spacing-6);
19
20
  --a1-ac-icon-size: var(--component-accordion-icon-size-sm);
20
21
  --a1-ac-font-size: var(--semantic-font-size-body-sm);
21
22
  --a1-ac-font-weight: var(--base-font-weight-medium);
23
+ --a1-ac-subtext-size: var(--semantic-font-size-body-xs);
22
24
  }
23
25
 
24
26
  .a1-accordion--lg {
25
27
  --a1-ac-height: var(--component-accordion-trigger-height-lg);
26
- --a1-ac-px: var(--component-accordion-padding-inline-lg);
28
+ --a1-ac-px: var(--base-spacing-12);
27
29
  --a1-ac-py: var(--base-spacing-12);
28
30
  --a1-ac-icon-size: var(--component-accordion-icon-size-lg);
29
31
  --a1-ac-font-size: var(--semantic-font-size-body-lg);
30
32
  --a1-ac-font-weight: var(--base-font-weight-bold);
33
+ --a1-ac-subtext-size: var(--semantic-font-size-body-md);
31
34
  }
32
35
 
33
36
  /* ─── Trigger ───────────────────────────────────────────────────────────────── */
@@ -41,7 +44,6 @@
41
44
  padding-inline: var(--a1-ac-px);
42
45
  padding-block: var(--a1-ac-py);
43
46
  border: none;
44
- border-radius: var(--component-accordion-border-radius);
45
47
  background: transparent;
46
48
  cursor: pointer;
47
49
  color: var(--semantic-color-text-default);
@@ -85,13 +87,36 @@
85
87
  transform: rotate(0deg);
86
88
  }
87
89
 
88
- /* ─── Label ─────────────────────────────────────────────────────────────────── */
90
+ /* ─── Label + subtext ───────────────────────────────────────────────────────── */
91
+
92
+ /* Label and subtext stack vertically (subtext below the title). */
93
+ .a1-accordion__text {
94
+ display: flex;
95
+ flex: 1 1 auto;
96
+ flex-direction: column;
97
+ min-width: 0;
98
+ }
89
99
 
90
100
  .a1-accordion__label {
91
- flex: 1;
92
101
  min-width: 0;
93
102
  }
94
103
 
104
+ /* Secondary info below the title, shown only while collapsed (e.g. a summary of
105
+ applied settings). Truncates with an ellipsis. */
106
+ .a1-accordion__subtext {
107
+ min-width: 0;
108
+ color: var(--semantic-color-text-muted);
109
+ font-size: var(--a1-ac-subtext-size);
110
+ font-weight: var(--semantic-font-weight-body);
111
+ white-space: nowrap;
112
+ overflow: hidden;
113
+ text-overflow: ellipsis;
114
+ }
115
+
116
+ .a1-accordion--open .a1-accordion__subtext {
117
+ display: none;
118
+ }
119
+
95
120
  /* ─── Body — CSS grid height animation ──────────────────────────────────────── */
96
121
 
97
122
  .a1-accordion__body {
@@ -109,6 +134,21 @@
109
134
  min-height: 0;
110
135
  }
111
136
 
137
+ /* ─── Divider (optional) ────────────────────────────────────────────────────── */
138
+ /* Attached to the trigger (the expanding header), so it stays put under the
139
+ header whether collapsed or expanded — never drops to the bottom of the open
140
+ panel. */
141
+
142
+ .a1-accordion--divider .a1-accordion__trigger {
143
+ border-block-end: var(--component-divider-size-xs) solid var(--semantic-color-border-subtle);
144
+ }
145
+
146
+ /* With a divider, give the open panel breathing room so its first item does not
147
+ sit directly against the divider line. Scales with the accordion size. */
148
+ .a1-accordion--divider .a1-accordion__body-inner {
149
+ padding-block-start: var(--a1-ac-py);
150
+ }
151
+
112
152
  /* ─── Disabled ──────────────────────────────────────────────────────────────── */
113
153
 
114
154
  .a1-accordion--disabled .a1-accordion__trigger {
@@ -0,0 +1,53 @@
1
+ import * as React from "react";
2
+
3
+ export interface AutocompleteOption {
4
+ value: string;
5
+ label?: string;
6
+ /** A CSS colour rendered as a swatch beside the option (and on the selected value / chip). In `variant="color"` the swatch defaults to `value` when omitted. */
7
+ swatch?: string;
8
+ /** A Material Symbols glyph name rendered beside the option / chip / selected value. */
9
+ icon?: string;
10
+ /** Group name. When any option has a `group`, the listbox renders a sticky heading before each group's options. Options are ordered by each group's first appearance. */
11
+ group?: string;
12
+ }
13
+
14
+ export interface AutocompleteProps {
15
+ /** Suggestion list. Pass strings or `{ value, label }` objects. */
16
+ options?: (string | AutocompleteOption)[];
17
+ /** Selected value — a string in single mode, a string[] in multi mode. */
18
+ value?: string | string[];
19
+ /** Called with the new value (string, or string[] when `multiple`). */
20
+ onChange?: (value: string | string[]) => void;
21
+ /** Allow selecting more than one option (renders removable chips). Default: false */
22
+ multiple?: boolean;
23
+ /** Allow creating a value not in `options` ("Add …"). Default: false */
24
+ allowCreate?: boolean;
25
+ /** Called with the created value when an `allowCreate` option is chosen. */
26
+ onCreate?: (value: string) => void;
27
+ /** Visual variant. "color" renders a colour swatch beside each option / chip / the selected value (swatch defaults to the option `value`). Default: "default" */
28
+ variant?: "default" | "color";
29
+ /** Field label. */
30
+ label?: React.ReactNode;
31
+ /** Helper text shown below the control. */
32
+ hint?: React.ReactNode;
33
+ /** Error message; styles the control and replaces the hint. */
34
+ error?: React.ReactNode;
35
+ /** Size scale, matching the field family. Default: "default" */
36
+ size?: "compact" | "default" | "comfortable";
37
+ required?: boolean;
38
+ disabled?: boolean;
39
+ id?: string;
40
+ name?: string;
41
+ className?: string;
42
+ /** Text shown when no options match. Default: "No matches" */
43
+ emptyText?: React.ReactNode;
44
+ /** Label for the create option, given the current query. */
45
+ createLabel?: (query: string) => React.ReactNode;
46
+ /** Cap the number of options rendered in the listbox (for very large lists like an icon picker). Excess options are hidden behind a "keep typing" footer. */
47
+ maxVisible?: number;
48
+ /** Footer text shown when the list is capped by `maxVisible`, given the shown count. */
49
+ moreText?: (shown: number) => React.ReactNode;
50
+ "aria-label"?: string;
51
+ }
52
+
53
+ export declare function Autocomplete(props: AutocompleteProps): React.ReactElement;
@@ -0,0 +1,380 @@
1
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Icon } from "../icon/Icon.jsx";
4
+ import { MessageBadge } from "../message/Message.jsx";
5
+ import { useLabel } from "../labels/Labels.jsx";
6
+ import "./autocomplete.css";
7
+
8
+ const SIZES = ["compact", "default", "comfortable"];
9
+
10
+ function normalizeOption(o) {
11
+ return typeof o === "string" ? { value: o, label: o } : o;
12
+ }
13
+
14
+ /**
15
+ * Autocomplete — a combobox that filters a list of options as you type, in
16
+ * single- or multi-select mode. Optionally lets the user create a value that
17
+ * isn't in the list (`allowCreate`). Built on the ARIA combobox/listbox pattern
18
+ * and styled to match the A1 field family.
19
+ */
20
+ const VARIANTS = ["default", "color"];
21
+
22
+ export function Autocomplete({
23
+ options = [],
24
+ value,
25
+ onChange,
26
+ multiple = false,
27
+ allowCreate = false,
28
+ onCreate,
29
+ variant = "default",
30
+ label,
31
+ hint,
32
+ error,
33
+ size,
34
+ required = false,
35
+ disabled = false,
36
+ id: providedId,
37
+ name,
38
+ className = "",
39
+ emptyText = "No matches",
40
+ createLabel = (q) => `Add “${q}”`,
41
+ maxVisible,
42
+ moreText = (shown) => `Showing the first ${shown} — keep typing to narrow.`,
43
+ "aria-label": ariaLabel,
44
+ ...props
45
+ }) {
46
+ const autoId = useId();
47
+ const id = providedId ?? autoId;
48
+ const listId = `${id}-listbox`;
49
+ const hintId = `${id}-hint`;
50
+ const errorId = `${id}-error`;
51
+ const resolvedSize = SIZES.includes(size) ? size : "default";
52
+ const resolvedVariant = VARIANTS.includes(variant) ? variant : "default";
53
+ const requiredText = useLabel("field.required", "Required");
54
+
55
+ const items = useMemo(() => options.map(normalizeOption), [options]);
56
+ // Grouping: when any option carries a `group`, the listbox renders a heading
57
+ // before each group. `groupRank` records each group's first-appearance order
58
+ // so results can be ordered to keep groups contiguous.
59
+ const hasGroups = useMemo(() => items.some((i) => i.group != null), [items]);
60
+ const groupRank = useMemo(() => {
61
+ const order = new Map();
62
+ for (const i of items) if (i.group != null && !order.has(i.group)) order.set(i.group, order.size);
63
+ return order;
64
+ }, [items]);
65
+ const selectedValues = multiple
66
+ ? (Array.isArray(value) ? value : [])
67
+ : (value != null && value !== "" ? [value] : []);
68
+
69
+ const [query, setQuery] = useState("");
70
+ const [open, setOpen] = useState(false);
71
+ const [activeIndex, setActiveIndex] = useState(-1);
72
+ const inputRef = useRef(null);
73
+ const rootRef = useRef(null);
74
+ const controlRef = useRef(null);
75
+ const listRef = useRef(null);
76
+ // The listbox is portaled to <body> so it escapes any clipping ancestor
77
+ // (e.g. an Accordion's overflow:hidden panel); position it under the control.
78
+ const [menuPos, setMenuPos] = useState(null);
79
+
80
+ const labelFor = (v) => items.find((i) => i.value === v)?.label ?? v;
81
+ // A swatch colour for a value: an explicit option `swatch`, or the value
82
+ // itself when it's a CSS colour (the common case for a colour picker).
83
+ const swatchFor = (v) => {
84
+ const opt = items.find((i) => i.value === v);
85
+ if (opt && opt.swatch) return opt.swatch;
86
+ return resolvedVariant === "color" ? v : undefined;
87
+ };
88
+ // A Material Symbols glyph for a value (option `icon`) — used by icon pickers.
89
+ const iconFor = (v) => items.find((i) => i.value === v)?.icon;
90
+ const selectedLabel = !multiple && selectedValues.length ? labelFor(selectedValues[0]) : "";
91
+ const selectedSwatch = !multiple && selectedValues.length ? swatchFor(selectedValues[0]) : undefined;
92
+ const selectedIcon = !multiple && selectedValues.length ? iconFor(selectedValues[0]) : undefined;
93
+
94
+ const q = query.trim().toLowerCase();
95
+ // In multi mode, keep selected options in the list (shown checked) so they can
96
+ // be toggled off; in single mode the list is just the filter results.
97
+ const filtered = items.filter((i) => !q || i.label.toLowerCase().includes(q));
98
+ // Order grouped results by each group's first appearance so the headings stay
99
+ // contiguous and keyboard navigation order matches the visual order.
100
+ const ordered = hasGroups
101
+ ? [...filtered].sort((a, b) => (groupRank.get(a.group) ?? Infinity) - (groupRank.get(b.group) ?? Infinity))
102
+ : filtered;
103
+ // Cap how many options render (large lists, e.g. the icon picker). The create
104
+ // row and the "refine" footer sit outside the cap.
105
+ const truncated = maxVisible != null && ordered.length > maxVisible;
106
+ const limited = truncated ? ordered.slice(0, maxVisible) : ordered;
107
+ const exactExists = items.some((i) => i.label.toLowerCase() === q || String(i.value).toLowerCase() === q);
108
+ const showCreate = allowCreate && q.length > 0 && !exactExists
109
+ && !(multiple && selectedValues.includes(query.trim()));
110
+ const visible = showCreate
111
+ ? [...limited, { __create: true, value: query.trim(), label: createLabel(query.trim()) }]
112
+ : limited;
113
+
114
+ // Precompute render rows: a group heading is inserted before the first option
115
+ // of each group. Headings are not part of `visible`, so keyboard navigation
116
+ // (which indexes `visible`) naturally skips them.
117
+ const rows = [];
118
+ let prevGroup;
119
+ visible.forEach((opt, idx) => {
120
+ if (hasGroups && !opt.__create && opt.group != null && opt.group !== prevGroup) {
121
+ rows.push({ kind: "group", label: opt.group, key: `__group-${opt.group}` });
122
+ }
123
+ if (!opt.__create) prevGroup = opt.group;
124
+ rows.push({ kind: "option", opt, idx });
125
+ });
126
+
127
+ // Close on outside interaction. The portaled listbox lives outside rootRef, so
128
+ // ignore clicks within it too.
129
+ useEffect(() => {
130
+ if (!open) return;
131
+ const onDoc = (e) => {
132
+ if (rootRef.current?.contains(e.target)) return;
133
+ if (listRef.current?.contains(e.target)) return;
134
+ setOpen(false);
135
+ };
136
+ document.addEventListener("mousedown", onDoc);
137
+ return () => document.removeEventListener("mousedown", onDoc);
138
+ }, [open]);
139
+
140
+ // Track the control's viewport rect while open so the fixed-position listbox
141
+ // follows it through scrolling/resizing of any ancestor.
142
+ useLayoutEffect(() => {
143
+ if (!open || disabled) return undefined;
144
+ const update = () => {
145
+ const el = controlRef.current;
146
+ if (!el) return;
147
+ const r = el.getBoundingClientRect();
148
+ setMenuPos({ top: r.bottom, left: r.left, width: r.width });
149
+ };
150
+ update();
151
+ window.addEventListener("scroll", update, true);
152
+ window.addEventListener("resize", update);
153
+ return () => {
154
+ window.removeEventListener("scroll", update, true);
155
+ window.removeEventListener("resize", update);
156
+ };
157
+ }, [open, disabled]);
158
+
159
+ // On open, highlight the currently-selected option so the menu opens focused
160
+ // on it (single-select; in multi mode the first selected value).
161
+ useEffect(() => {
162
+ if (!open) return;
163
+ if (selectedValues.length === 0) { setActiveIndex(-1); return; }
164
+ const idx = visible.findIndex((o) => !o.__create && o.value === selectedValues[0]);
165
+ if (idx >= 0) setActiveIndex(idx);
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [open]);
168
+
169
+ // Keep the active option scrolled into view (on open + arrow navigation).
170
+ useEffect(() => {
171
+ if (!open || activeIndex < 0) return;
172
+ const el = listRef.current?.querySelector(".a1-autocomplete__option--active");
173
+ el?.scrollIntoView({ block: "nearest" });
174
+ }, [open, activeIndex, menuPos]);
175
+
176
+ function commit(option) {
177
+ if (!option) return;
178
+ if (option.__create) onCreate?.(option.value);
179
+ const v = option.value;
180
+ if (multiple) {
181
+ // Toggle: re-selecting a checked option removes it. The menu stays open
182
+ // until the user dismisses it (outside click / Escape).
183
+ if (selectedValues.includes(v)) onChange?.(selectedValues.filter((x) => x !== v));
184
+ else onChange?.([...selectedValues, v]);
185
+ setQuery("");
186
+ setActiveIndex(-1);
187
+ setOpen(true);
188
+ inputRef.current?.focus();
189
+ } else {
190
+ onChange?.(v);
191
+ setQuery("");
192
+ setOpen(false);
193
+ }
194
+ }
195
+
196
+ function removeValue(v) {
197
+ if (multiple) onChange?.(selectedValues.filter((x) => x !== v));
198
+ else onChange?.("");
199
+ }
200
+
201
+ function handleKeyDown(e) {
202
+ if (e.key === "ArrowDown") {
203
+ e.preventDefault();
204
+ setOpen(true);
205
+ setActiveIndex((i) => Math.min(i + 1, visible.length - 1));
206
+ } else if (e.key === "ArrowUp") {
207
+ e.preventDefault();
208
+ setActiveIndex((i) => Math.max(i - 1, 0));
209
+ } else if (e.key === "Enter") {
210
+ if (open && activeIndex >= 0 && visible[activeIndex]) { e.preventDefault(); commit(visible[activeIndex]); }
211
+ else if (open && visible.length === 1) { e.preventDefault(); commit(visible[0]); }
212
+ } else if (e.key === "Escape") {
213
+ setOpen(false);
214
+ } else if (e.key === "Backspace" && multiple && query === "" && selectedValues.length) {
215
+ removeValue(selectedValues[selectedValues.length - 1]);
216
+ }
217
+ }
218
+
219
+ const describedBy = [error ? errorId : hint ? hintId : null].filter(Boolean).join(" ") || undefined;
220
+ const inputValue = multiple ? query : (open ? query : selectedLabel);
221
+
222
+ const classes = [
223
+ "a1-autocomplete",
224
+ `a1-autocomplete--${resolvedSize}`,
225
+ resolvedVariant === "color" && "a1-autocomplete--color",
226
+ multiple && "a1-autocomplete--multiple",
227
+ error && "a1-autocomplete--error",
228
+ required && "a1-autocomplete--required",
229
+ disabled && "a1-autocomplete--disabled",
230
+ open && "a1-autocomplete--open",
231
+ className,
232
+ ].filter(Boolean).join(" ");
233
+
234
+ return (
235
+ <div className={classes} ref={rootRef} {...props}>
236
+ {label && (
237
+ <label className="a1-autocomplete__label" htmlFor={id}>
238
+ {label}
239
+ {required && resolvedSize === "comfortable" ? (
240
+ <MessageBadge status="info" subtle size="sm" icon={null}>{requiredText}</MessageBadge>
241
+ ) : required ? (
242
+ <span className="a1-autocomplete__asterisk" aria-hidden="true"> *</span>
243
+ ) : null}
244
+ </label>
245
+ )}
246
+
247
+ <div
248
+ ref={controlRef}
249
+ className="a1-autocomplete__control"
250
+ onClick={() => { if (!disabled) { setOpen(true); inputRef.current?.focus(); } }}
251
+ >
252
+ {multiple && selectedValues.map((v) => (
253
+ <span key={v} className="a1-autocomplete__chip">
254
+ {swatchFor(v) && <span className="a1-autocomplete__swatch" style={{ background: swatchFor(v) }} aria-hidden="true" />}
255
+ {iconFor(v) && <Icon name={iconFor(v)} className="a1-autocomplete__chip-icon" aria-hidden="true" />}
256
+ {labelFor(v)}
257
+ <button
258
+ type="button"
259
+ className="a1-autocomplete__chip-remove"
260
+ aria-label={`Remove ${labelFor(v)}`}
261
+ disabled={disabled}
262
+ onClick={(e) => { e.stopPropagation(); removeValue(v); }}
263
+ >
264
+ <Icon name="close" />
265
+ </button>
266
+ </span>
267
+ ))}
268
+
269
+ {!multiple && selectedSwatch && (
270
+ <span className="a1-autocomplete__swatch a1-autocomplete__swatch--leading" style={{ background: selectedSwatch }} aria-hidden="true" />
271
+ )}
272
+
273
+ {!multiple && !selectedSwatch && selectedIcon && !open && (
274
+ <Icon name={selectedIcon} className="a1-autocomplete__leading-icon" aria-hidden="true" />
275
+ )}
276
+
277
+ <input
278
+ ref={inputRef}
279
+ id={id}
280
+ name={name}
281
+ className="a1-autocomplete__input"
282
+ type="text"
283
+ role="combobox"
284
+ aria-expanded={open}
285
+ aria-controls={listId}
286
+ aria-autocomplete="list"
287
+ aria-label={ariaLabel}
288
+ aria-activedescendant={open && activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
289
+ aria-describedby={describedBy}
290
+ autoComplete="off"
291
+ disabled={disabled}
292
+ required={required && selectedValues.length === 0}
293
+ value={inputValue}
294
+ onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIndex(-1); }}
295
+ onFocus={() => setOpen(true)}
296
+ onKeyDown={handleKeyDown}
297
+ />
298
+
299
+ {!multiple && selectedValues.length > 0 && !disabled && (
300
+ <button
301
+ type="button"
302
+ className="a1-autocomplete__clear"
303
+ aria-label="Clear selection"
304
+ onClick={(e) => { e.stopPropagation(); removeValue(selectedValues[0]); setQuery(""); inputRef.current?.focus(); }}
305
+ >
306
+ <Icon name="close" />
307
+ </button>
308
+ )}
309
+ <Icon name="expand_more" className="a1-autocomplete__chevron" aria-hidden="true" />
310
+ </div>
311
+
312
+ {open && !disabled && menuPos && createPortal(
313
+ <ul
314
+ ref={listRef}
315
+ className="a1-autocomplete__listbox a1-autocomplete__listbox--floating"
316
+ role="listbox"
317
+ id={listId}
318
+ aria-multiselectable={multiple || undefined}
319
+ style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: menuPos.width }}
320
+ >
321
+ {visible.length === 0 ? (
322
+ <li className="a1-autocomplete__empty">{emptyText}</li>
323
+ ) : (
324
+ <>
325
+ {rows.map((row) => {
326
+ if (row.kind === "group") {
327
+ return <li key={row.key} className="a1-autocomplete__group" role="presentation">{row.label}</li>;
328
+ }
329
+ const { opt, idx } = row;
330
+ const isSelected = !opt.__create && selectedValues.includes(opt.value);
331
+ return (
332
+ <li
333
+ key={opt.__create ? `__create-${opt.value}` : opt.value}
334
+ id={`${id}-opt-${idx}`}
335
+ role="option"
336
+ aria-selected={isSelected}
337
+ className={[
338
+ "a1-autocomplete__option",
339
+ idx === activeIndex && "a1-autocomplete__option--active",
340
+ opt.__create && "a1-autocomplete__option--create",
341
+ ].filter(Boolean).join(" ")}
342
+ onMouseDown={(e) => { e.preventDefault(); commit(opt); }}
343
+ onMouseEnter={() => setActiveIndex(idx)}
344
+ >
345
+ {opt.__create && <Icon name="add" className="a1-autocomplete__option-icon" aria-hidden="true" />}
346
+ {multiple && !opt.__create && (
347
+ <span className={`a1-autocomplete__checkbox${isSelected ? " a1-autocomplete__checkbox--on" : ""}`} aria-hidden="true" />
348
+ )}
349
+ {!opt.__create && swatchFor(opt.value) && (
350
+ <span className="a1-autocomplete__swatch" style={{ background: swatchFor(opt.value) }} aria-hidden="true" />
351
+ )}
352
+ {!opt.__create && opt.icon && (
353
+ <Icon name={opt.icon} className="a1-autocomplete__option-icon" aria-hidden="true" />
354
+ )}
355
+ <span className="a1-autocomplete__option-label">{opt.label}</span>
356
+ {!multiple && isSelected && <Icon name="check" className="a1-autocomplete__option-check" aria-hidden="true" />}
357
+ </li>
358
+ );
359
+ })}
360
+ {truncated && (
361
+ <li className="a1-autocomplete__more" role="presentation">{moreText(maxVisible)}</li>
362
+ )}
363
+ </>
364
+ )}
365
+ </ul>,
366
+ // When inside a modal <dialog> (top layer), portal into it so the
367
+ // listbox joins the dialog's top-layer stack and isn't painted behind
368
+ // it. Otherwise portal to <body>. See "Z-index and Layering" in
369
+ // project-foundations.md.
370
+ controlRef.current?.closest("dialog") ?? document.body,
371
+ )}
372
+
373
+ {error ? (
374
+ <p className="a1-autocomplete__message a1-autocomplete__message--error" id={errorId}>{error}</p>
375
+ ) : hint ? (
376
+ <p className="a1-autocomplete__message a1-autocomplete__message--hint" id={hintId}>{hint}</p>
377
+ ) : null}
378
+ </div>
379
+ );
380
+ }