@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.
- package/package.json +3 -2
- package/src/color-scheme.css +2 -0
- package/src/components/accordion/Accordion.d.ts +8 -0
- package/src/components/accordion/Accordion.jsx +9 -1
- package/src/components/accordion/accordion.css +46 -6
- package/src/components/autocomplete/Autocomplete.d.ts +53 -0
- package/src/components/autocomplete/Autocomplete.jsx +380 -0
- package/src/components/autocomplete/autocomplete.css +346 -0
- package/src/components/banner/Banner.d.ts +9 -2
- package/src/components/banner/Banner.jsx +32 -6
- package/src/components/banner/banner.css +81 -0
- package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
- package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
- package/src/components/bottom-sheet/bottom-sheet.css +113 -0
- package/src/components/button/button.css +7 -3
- package/src/components/code/Code.jsx +6 -1
- package/src/components/data-table/DataTable.jsx +11 -1
- package/src/components/data-table/data-table.css +19 -0
- package/src/components/figure/Figure.d.ts +37 -4
- package/src/components/figure/Figure.jsx +78 -9
- package/src/components/figure/figure.css +105 -8
- package/src/components/grid/Grid.d.ts +1 -1
- package/src/components/grid/Grid.jsx +2 -0
- package/src/components/grid/grid.css +5 -0
- package/src/components/icon-button/IconButton.d.ts +2 -2
- package/src/components/icon-button/IconButton.jsx +3 -2
- package/src/components/icon-button/icon-button.css +11 -1
- package/src/components/menu/Menu.jsx +12 -0
- package/src/components/menu/menu.css +17 -6
- package/src/components/page-layout/page-layout.css +10 -4
- package/src/components/page-nav/PageNav.jsx +29 -8
- package/src/components/page-nav/page-nav.css +13 -0
- package/src/components/paragraph/Paragraph.d.ts +2 -0
- package/src/components/paragraph/Paragraph.jsx +4 -0
- package/src/components/paragraph/paragraph.css +6 -6
- package/src/components/section/Section.d.ts +6 -0
- package/src/components/section/Section.jsx +19 -0
- package/src/components/section/section.css +33 -10
- package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
- package/src/components/segmented-control/SegmentedControl.jsx +16 -3
- package/src/components/segmented-control/segmented.css +31 -1
- package/src/components/slider/Slider.d.ts +71 -0
- package/src/components/slider/Slider.jsx +243 -0
- package/src/components/slider/slider.css +238 -0
- package/src/components/split-button/SplitButton.d.ts +39 -0
- package/src/components/split-button/SplitButton.jsx +94 -0
- package/src/components/split-button/split-button.css +40 -0
- package/src/components/tabs/tabs.css +3 -0
- package/src/components/toolbar/Toolbar.d.ts +131 -0
- package/src/components/toolbar/Toolbar.jsx +335 -0
- package/src/components/toolbar/toolbar.css +229 -0
- package/src/components/top-header/top-header.css +2 -0
- package/src/components/tree-menu/TreeMenu.jsx +11 -7
- package/src/index.d.ts +71 -0
- package/src/index.js +15 -1
- package/src/themes.css +293 -0
- 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.
|
|
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
|
}
|
package/src/color-scheme.css
CHANGED
|
@@ -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-
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
+
}
|