@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/** Standard icon name for a "none" selection. */
|
|
4
|
+
export declare const TOOLBAR_NONE_ICON: string;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a tool's label is shown. Pass a boolean, or a breakpoint object to
|
|
8
|
+
* vary it responsively (cascades xs → xl, e.g. `{ xs: false, lg: true }` shows
|
|
9
|
+
* labels only from the `lg` breakpoint up). When labels can be hidden at any
|
|
10
|
+
* breakpoint the tool keeps an `aria-label`, so the accessible name is stable.
|
|
11
|
+
*/
|
|
12
|
+
export type ToolbarShowLabel =
|
|
13
|
+
| boolean
|
|
14
|
+
| { xs?: boolean; sm?: boolean; md?: boolean; lg?: boolean; xl?: boolean };
|
|
15
|
+
|
|
16
|
+
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
|
+
/** Accessible name for the toolbar (ignored when `label` is set). */
|
|
18
|
+
"aria-label"?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional visible caption rendered above the bar, styled to match the compact
|
|
21
|
+
* ChoiceGroup label. When set it also provides the toolbar's accessible name.
|
|
22
|
+
*/
|
|
23
|
+
label?: React.ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Lift the bar into a floating, elevated surface (shadow + border) for a
|
|
26
|
+
* toolbar that hovers over page content (e.g. a selection formatting bar).
|
|
27
|
+
* The consumer is responsible for positioning. Default: false
|
|
28
|
+
*/
|
|
29
|
+
overlay?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Stretch the bar to fill its container, with the tools growing to share the
|
|
32
|
+
* available width (dividers keep their natural size). When false the bar is
|
|
33
|
+
* `fit-content` wide. Default: false
|
|
34
|
+
*/
|
|
35
|
+
fullWidth?: boolean;
|
|
36
|
+
children?: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
/** A compact container grouping related editing controls on one subtle surface. */
|
|
39
|
+
export declare function Toolbar(props: ToolbarProps): React.ReactElement;
|
|
40
|
+
|
|
41
|
+
/** Visual separator between tools within a Toolbar. */
|
|
42
|
+
export declare function ToolbarDivider(): React.ReactElement;
|
|
43
|
+
|
|
44
|
+
export interface ToolbarToggleProps
|
|
45
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
|
46
|
+
/** Material Symbols icon name. */
|
|
47
|
+
icon?: string;
|
|
48
|
+
/** A colour swatch (any CSS color) shown inside the tool. */
|
|
49
|
+
swatch?: string;
|
|
50
|
+
/** Accessible name (and tooltip); shown as text when `showLabel`. */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** Pressed (on) state. */
|
|
53
|
+
pressed?: boolean;
|
|
54
|
+
/** Called with the next pressed state when clicked. */
|
|
55
|
+
onChange?: (pressed: boolean) => void;
|
|
56
|
+
/** Show the label as visible text; boolean or a responsive breakpoint object. Default: false */
|
|
57
|
+
showLabel?: ToolbarShowLabel;
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** A two-state toggle button (e.g. bold on/off). */
|
|
61
|
+
export declare function ToolbarToggle(props: ToolbarToggleProps): React.ReactElement;
|
|
62
|
+
|
|
63
|
+
export interface ToolbarButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
64
|
+
icon?: string;
|
|
65
|
+
/** A colour swatch (any CSS color) shown inside the tool. */
|
|
66
|
+
swatch?: string;
|
|
67
|
+
/** Accessible name (and tooltip); shown as text when `showLabel`. */
|
|
68
|
+
label?: string;
|
|
69
|
+
/** Show the label as visible text; boolean or a responsive breakpoint object. Default: false */
|
|
70
|
+
showLabel?: ToolbarShowLabel;
|
|
71
|
+
}
|
|
72
|
+
/** A plain action button inside a Toolbar (no pressed state). */
|
|
73
|
+
export declare function ToolbarButton(props: ToolbarButtonProps): React.ReactElement;
|
|
74
|
+
|
|
75
|
+
export interface ToolbarMenuItem {
|
|
76
|
+
value: string | number;
|
|
77
|
+
label?: React.ReactNode;
|
|
78
|
+
/** Material Symbols icon name shown beside the menu item. */
|
|
79
|
+
icon?: string;
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface ToolbarMenuProps {
|
|
83
|
+
/** Button icon. Defaults to the active item's icon when `value` matches one. */
|
|
84
|
+
icon?: string;
|
|
85
|
+
/** Accessible name (and tooltip); shown as text when `showLabel`. */
|
|
86
|
+
label?: string;
|
|
87
|
+
/** Currently selected value — marks the matching item active. */
|
|
88
|
+
value?: string | number;
|
|
89
|
+
/** Called with the chosen item value. */
|
|
90
|
+
onChange?: (value: string | number) => void;
|
|
91
|
+
items?: ToolbarMenuItem[];
|
|
92
|
+
/** Show the label as visible text; boolean or a responsive breakpoint object. Default: false */
|
|
93
|
+
showLabel?: ToolbarShowLabel;
|
|
94
|
+
disabled?: boolean;
|
|
95
|
+
"aria-label"?: string;
|
|
96
|
+
className?: string;
|
|
97
|
+
}
|
|
98
|
+
/** A toolbar button that opens a dropdown Menu of choices (e.g. list types). */
|
|
99
|
+
export declare function ToolbarMenu(props: ToolbarMenuProps): React.ReactElement;
|
|
100
|
+
|
|
101
|
+
export interface ToolbarGroupOption {
|
|
102
|
+
value: string | number;
|
|
103
|
+
label?: React.ReactNode;
|
|
104
|
+
/** Material Symbols icon name (rendered instead of the label in icon-only mode). */
|
|
105
|
+
icon?: string;
|
|
106
|
+
/** A colour swatch (any CSS color) shown inside the option. */
|
|
107
|
+
swatch?: string;
|
|
108
|
+
disabled?: boolean;
|
|
109
|
+
}
|
|
110
|
+
export interface ToolbarGroupProps {
|
|
111
|
+
value?: string | number;
|
|
112
|
+
/** Called with the newly selected option value. */
|
|
113
|
+
onChange?: (value: string | number) => void;
|
|
114
|
+
options?: ToolbarGroupOption[];
|
|
115
|
+
/** Lay the options out as a `columns`-wide grid (e.g. 3 for a 3×3 picker). */
|
|
116
|
+
columns?: number;
|
|
117
|
+
/** Show option labels as text; boolean or a responsive breakpoint object. Default: false (icon-only) */
|
|
118
|
+
showLabels?: ToolbarShowLabel;
|
|
119
|
+
/**
|
|
120
|
+
* `"all"` (default) honours `showLabels` for every option. `"selected"` shows
|
|
121
|
+
* the label only on the currently selected option; the rest render icon/swatch-only
|
|
122
|
+
* and a `"none"`/empty value falls back to the standard none icon. Use it for a
|
|
123
|
+
* swatch picker where only the chosen swatch is named (e.g. Section surface/gradient).
|
|
124
|
+
*/
|
|
125
|
+
labelMode?: "all" | "selected";
|
|
126
|
+
"aria-label"?: string;
|
|
127
|
+
disabled?: boolean;
|
|
128
|
+
className?: string;
|
|
129
|
+
}
|
|
130
|
+
/** A single-select button group (radio semantics); set `columns` for a grid. */
|
|
131
|
+
export declare function ToolbarGroup(props: ToolbarGroupProps): React.ReactElement;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import "./toolbar.css";
|
|
2
|
+
import { useId, useRef, useState } from "react";
|
|
3
|
+
import { Icon } from "../icon/Icon.jsx";
|
|
4
|
+
import { Menu, MenuItem } from "../menu/Menu.jsx";
|
|
5
|
+
|
|
6
|
+
/** Standard icon for a "none" selection (no alignment, no size, …). */
|
|
7
|
+
export const TOOLBAR_NONE_ICON = "block";
|
|
8
|
+
|
|
9
|
+
const BREAKPOINTS = ["xs", "sm", "md", "lg", "xl"];
|
|
10
|
+
|
|
11
|
+
function cx(...parts) {
|
|
12
|
+
return parts.filter(Boolean).join(" ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isNoneValue(v) {
|
|
16
|
+
return v === "none" || v === null || v === "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A responsive `showLabel`/`showLabels` value: `{ xs?, sm?, … }` of booleans. */
|
|
20
|
+
function isResponsive(v) {
|
|
21
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Is the label shown for any breakpoint? (decides whether the span renders). */
|
|
25
|
+
function labelEverShown(showLabel) {
|
|
26
|
+
if (isResponsive(showLabel)) return BREAKPOINTS.some((bp) => showLabel[bp]);
|
|
27
|
+
return !!showLabel;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Are labels shown at every breakpoint? (decides icon-only fallbacks/aria). */
|
|
31
|
+
function labelAlwaysShown(showLabel) {
|
|
32
|
+
return showLabel === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cascade per-breakpoint visibility classes for the label span (xs → xl). */
|
|
36
|
+
function labelVisibilityClasses(showLabel) {
|
|
37
|
+
if (!isResponsive(showLabel)) return [];
|
|
38
|
+
let last = false;
|
|
39
|
+
return BREAKPOINTS.map((bp) => {
|
|
40
|
+
if (typeof showLabel[bp] === "boolean") last = showLabel[bp];
|
|
41
|
+
return `a1-toolbar__label--${last ? "show" : "hide"}-${bp}`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Accessible name for a tool button. When labels are always visible the visible
|
|
47
|
+
* text is the name (no aria-label). When they're hidden — or responsively
|
|
48
|
+
* toggled — fall back to an explicit `aria-label` so the name is stable.
|
|
49
|
+
*/
|
|
50
|
+
function toolAriaLabel(showLabel, label) {
|
|
51
|
+
return labelAlwaysShown(showLabel) ? undefined : label;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Toolbar — a compact container that groups related editing controls (toggles,
|
|
56
|
+
* single-select button groups, action buttons, selects, menus) on one subtle
|
|
57
|
+
* surface. Think a text-editor toolbar, or the controls in the component
|
|
58
|
+
* configurator. Compose it from the `Toolbar*` sub-components, separating tools
|
|
59
|
+
* with `<ToolbarDivider />`.
|
|
60
|
+
*
|
|
61
|
+
* Pass `label` to render an optional caption above the bar — styled to match the
|
|
62
|
+
* compact ChoiceGroup label so toolbars sit naturally alongside form controls.
|
|
63
|
+
* Pass `overlay` to lift the bar into a floating, elevated surface (shadow +
|
|
64
|
+
* border) for a toolbar that hovers over page content — e.g. a selection
|
|
65
|
+
* formatting bar. The consumer positions the overlay (the bar itself does not
|
|
66
|
+
* choose a location).
|
|
67
|
+
*
|
|
68
|
+
* By default the bar is `fit-content` wide. Pass `fullWidth` to stretch it to
|
|
69
|
+
* fill its container, with the tools growing to share the available space
|
|
70
|
+
* (dividers keep their natural size).
|
|
71
|
+
*/
|
|
72
|
+
export function Toolbar({
|
|
73
|
+
label,
|
|
74
|
+
overlay = false,
|
|
75
|
+
fullWidth = false,
|
|
76
|
+
"aria-label": ariaLabel,
|
|
77
|
+
className = "",
|
|
78
|
+
children,
|
|
79
|
+
...rest
|
|
80
|
+
}) {
|
|
81
|
+
const labelId = useId();
|
|
82
|
+
const bar = (
|
|
83
|
+
<div
|
|
84
|
+
role="toolbar"
|
|
85
|
+
aria-label={label ? undefined : ariaLabel}
|
|
86
|
+
aria-labelledby={label ? labelId : undefined}
|
|
87
|
+
aria-orientation="horizontal"
|
|
88
|
+
className={cx(
|
|
89
|
+
"a1-toolbar",
|
|
90
|
+
overlay && "a1-toolbar--overlay",
|
|
91
|
+
fullWidth && "a1-toolbar--full-width",
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
{...rest}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (!label) return bar;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="a1-toolbar-field">
|
|
104
|
+
<span className="a1-toolbar-field__label" id={labelId}>{label}</span>
|
|
105
|
+
{bar}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Visual separator between tools within a Toolbar. */
|
|
111
|
+
export function ToolbarDivider({ className = "", ...rest }) {
|
|
112
|
+
return <span className={cx("a1-toolbar__divider", className)} role="separator" aria-orientation="vertical" {...rest} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ToolButtonContent({ icon, label, showLabel, swatch }) {
|
|
116
|
+
const renderLabel = label != null && labelEverShown(showLabel);
|
|
117
|
+
const labelClass = cx("a1-toolbar__label", ...labelVisibilityClasses(showLabel));
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
{swatch ? <span className="a1-toolbar__swatch" style={{ background: swatch }} aria-hidden="true" /> : null}
|
|
121
|
+
{icon ? <Icon name={icon} size="sm" /> : null}
|
|
122
|
+
{renderLabel ? <span className={labelClass}>{label}</span> : null}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A two-state toggle button (e.g. bold on/off). */
|
|
128
|
+
export function ToolbarToggle({
|
|
129
|
+
icon,
|
|
130
|
+
label,
|
|
131
|
+
swatch,
|
|
132
|
+
pressed = false,
|
|
133
|
+
onChange,
|
|
134
|
+
showLabel = false,
|
|
135
|
+
disabled = false,
|
|
136
|
+
className = "",
|
|
137
|
+
...rest
|
|
138
|
+
}) {
|
|
139
|
+
return (
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className={cx("a1-toolbar__button", className)}
|
|
143
|
+
aria-pressed={pressed}
|
|
144
|
+
aria-label={toolAriaLabel(showLabel, label)}
|
|
145
|
+
title={label}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
onClick={() => onChange?.(!pressed)}
|
|
148
|
+
{...rest}
|
|
149
|
+
>
|
|
150
|
+
<ToolButtonContent icon={icon} label={label} showLabel={showLabel} swatch={swatch} />
|
|
151
|
+
</button>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A plain action button inside a Toolbar (no pressed state). */
|
|
156
|
+
export function ToolbarButton({
|
|
157
|
+
icon,
|
|
158
|
+
label,
|
|
159
|
+
swatch,
|
|
160
|
+
onClick,
|
|
161
|
+
showLabel = false,
|
|
162
|
+
disabled = false,
|
|
163
|
+
className = "",
|
|
164
|
+
...rest
|
|
165
|
+
}) {
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
className={cx("a1-toolbar__button", className)}
|
|
170
|
+
aria-label={toolAriaLabel(showLabel, label)}
|
|
171
|
+
title={label}
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
onClick={onClick}
|
|
174
|
+
{...rest}
|
|
175
|
+
>
|
|
176
|
+
<ToolButtonContent icon={icon} label={label} showLabel={showLabel} swatch={swatch} />
|
|
177
|
+
</button>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A toolbar button that opens a dropdown `Menu` of choices (e.g. a list-type
|
|
183
|
+
* picker: none / bulleted / numbered / checklist, or a text-size picker). The
|
|
184
|
+
* button carries a small
|
|
185
|
+
* caret to signal that it opens a menu. When `value` is set, the matching item
|
|
186
|
+
* is marked active and — unless an explicit `icon` is given — the button shows
|
|
187
|
+
* that item's icon so the toolbar reflects the current selection.
|
|
188
|
+
*/
|
|
189
|
+
export function ToolbarMenu({
|
|
190
|
+
icon,
|
|
191
|
+
label,
|
|
192
|
+
value,
|
|
193
|
+
onChange,
|
|
194
|
+
items = [],
|
|
195
|
+
showLabel = false,
|
|
196
|
+
disabled = false,
|
|
197
|
+
"aria-label": ariaLabel,
|
|
198
|
+
className = "",
|
|
199
|
+
...rest
|
|
200
|
+
}) {
|
|
201
|
+
const [open, setOpen] = useState(false);
|
|
202
|
+
const btnRef = useRef(null);
|
|
203
|
+
const active = items.find((it) => it.value === value);
|
|
204
|
+
const buttonIcon = icon ?? active?.icon;
|
|
205
|
+
const name = ariaLabel ?? label;
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<>
|
|
209
|
+
<button
|
|
210
|
+
ref={btnRef}
|
|
211
|
+
type="button"
|
|
212
|
+
className={cx("a1-toolbar__button", "a1-toolbar__menu-button", className)}
|
|
213
|
+
aria-haspopup="menu"
|
|
214
|
+
aria-expanded={open}
|
|
215
|
+
aria-label={ariaLabel ?? toolAriaLabel(showLabel, label)}
|
|
216
|
+
title={label}
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
onClick={() => setOpen((o) => !o)}
|
|
219
|
+
{...rest}
|
|
220
|
+
>
|
|
221
|
+
<ToolButtonContent icon={buttonIcon} label={label} showLabel={showLabel} />
|
|
222
|
+
<Icon name="arrow_drop_down" size="sm" className="a1-toolbar__caret" />
|
|
223
|
+
</button>
|
|
224
|
+
<Menu open={open} onClose={() => setOpen(false)} anchorRef={btnRef} aria-label={name}>
|
|
225
|
+
{items.map((it) => (
|
|
226
|
+
<MenuItem
|
|
227
|
+
key={String(it.value)}
|
|
228
|
+
icon={it.icon}
|
|
229
|
+
active={it.value === value}
|
|
230
|
+
disabled={it.disabled}
|
|
231
|
+
onClick={() => { onChange?.(it.value); setOpen(false); }}
|
|
232
|
+
>
|
|
233
|
+
{it.label ?? String(it.value)}
|
|
234
|
+
</MenuItem>
|
|
235
|
+
))}
|
|
236
|
+
</Menu>
|
|
237
|
+
</>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A single-select button group (radio semantics) — e.g. text alignment, or a
|
|
243
|
+
* compact `columns`-wide grid like a 3×3 crop-direction picker. Mutually
|
|
244
|
+
* exclusive options with clearly highlighted selection, in far less space than a
|
|
245
|
+
* ChoiceGroup.
|
|
246
|
+
*/
|
|
247
|
+
export function ToolbarGroup({
|
|
248
|
+
value,
|
|
249
|
+
onChange,
|
|
250
|
+
options = [],
|
|
251
|
+
columns,
|
|
252
|
+
showLabels = false,
|
|
253
|
+
labelMode = "all",
|
|
254
|
+
"aria-label": ariaLabel,
|
|
255
|
+
disabled = false,
|
|
256
|
+
className = "",
|
|
257
|
+
...rest
|
|
258
|
+
}) {
|
|
259
|
+
const btnRefs = useRef([]);
|
|
260
|
+
const grid = typeof columns === "number" && columns > 0;
|
|
261
|
+
const selectedIndex = options.findIndex((o) => o.value === value);
|
|
262
|
+
|
|
263
|
+
function move(index, select = true) {
|
|
264
|
+
const n = options.length;
|
|
265
|
+
const idx = ((index % n) + n) % n;
|
|
266
|
+
btnRefs.current[idx]?.focus();
|
|
267
|
+
if (select) onChange?.(options[idx].value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleKeyDown(e, i) {
|
|
271
|
+
const n = options.length;
|
|
272
|
+
let next = null;
|
|
273
|
+
let clampVertical = false;
|
|
274
|
+
switch (e.key) {
|
|
275
|
+
case "ArrowRight": next = i + 1; break;
|
|
276
|
+
case "ArrowLeft": next = i - 1; break;
|
|
277
|
+
case "ArrowDown": next = grid ? i + columns : i + 1; clampVertical = grid; break;
|
|
278
|
+
case "ArrowUp": next = grid ? i - columns : i - 1; clampVertical = grid; break;
|
|
279
|
+
case "Home": next = 0; break;
|
|
280
|
+
case "End": next = n - 1; break;
|
|
281
|
+
default: return;
|
|
282
|
+
}
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
// Vertical moves in a grid clamp (don't wrap to a wrong column); everything
|
|
285
|
+
// else wraps around the linear order.
|
|
286
|
+
if (clampVertical) {
|
|
287
|
+
if (next < 0 || next >= n) return;
|
|
288
|
+
move(next);
|
|
289
|
+
} else {
|
|
290
|
+
move(next);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const style = grid ? { "--a1-toolbar-grid-columns": columns } : undefined;
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
role="radiogroup"
|
|
299
|
+
aria-label={ariaLabel}
|
|
300
|
+
className={cx("a1-toolbar__group", grid && "a1-toolbar__group--grid", className)}
|
|
301
|
+
style={style}
|
|
302
|
+
{...rest}
|
|
303
|
+
>
|
|
304
|
+
{options.map((opt, i) => {
|
|
305
|
+
const selected = i === selectedIndex;
|
|
306
|
+
// Roving tabindex: the selected option (or the first, when none is
|
|
307
|
+
// selected) is the single tab stop for the group.
|
|
308
|
+
const tabIndex = selected || (selectedIndex === -1 && i === 0) ? 0 : -1;
|
|
309
|
+
const optLabel = opt.label ?? String(opt.value);
|
|
310
|
+
// `labelMode="selected"` shows the label only on the selected option;
|
|
311
|
+
// the rest fall back to icon/swatch-only (and "none" to its icon).
|
|
312
|
+
const optShowLabel = labelMode === "selected" ? selected : showLabels;
|
|
313
|
+
const icon = opt.icon ?? (!labelAlwaysShown(optShowLabel) && isNoneValue(opt.value) ? TOOLBAR_NONE_ICON : undefined);
|
|
314
|
+
return (
|
|
315
|
+
<button
|
|
316
|
+
key={String(opt.value)}
|
|
317
|
+
ref={(el) => { btnRefs.current[i] = el; }}
|
|
318
|
+
type="button"
|
|
319
|
+
role="radio"
|
|
320
|
+
aria-checked={selected}
|
|
321
|
+
aria-label={toolAriaLabel(optShowLabel, optLabel)}
|
|
322
|
+
title={optLabel}
|
|
323
|
+
tabIndex={tabIndex}
|
|
324
|
+
disabled={disabled || opt.disabled}
|
|
325
|
+
className="a1-toolbar__button"
|
|
326
|
+
onClick={() => onChange?.(opt.value)}
|
|
327
|
+
onKeyDown={(e) => handleKeyDown(e, i)}
|
|
328
|
+
>
|
|
329
|
+
<ToolButtonContent icon={icon} label={opt.label} showLabel={optShowLabel} swatch={opt.swatch} />
|
|
330
|
+
</button>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
Toolbar
|
|
3
|
+
══════════════════════════════════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
/* ── Labelled field wrapper ────────────────────────────────────────────────── */
|
|
6
|
+
/* Optional caption above the bar — matches the compact ChoiceGroup label so a
|
|
7
|
+
Toolbar sits naturally next to form controls. */
|
|
8
|
+
.a1-toolbar-field {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
/* Keep the label and bar at their natural width so a stretching flex parent
|
|
12
|
+
(e.g. a column Stack with the default `align-items: stretch`) doesn't force
|
|
13
|
+
the bar to full width. A `fullWidth` bar still fills (it sets its own
|
|
14
|
+
`inline-size: 100%`). */
|
|
15
|
+
align-items: flex-start;
|
|
16
|
+
gap: var(--component-choice-group-compact-group-gap);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.a1-toolbar-field__label {
|
|
20
|
+
font-family: var(--component-paragraph-font-family);
|
|
21
|
+
/* The smallest body step. */
|
|
22
|
+
font-size: var(--semantic-font-size-body-xs);
|
|
23
|
+
font-weight: var(--component-field-label-font-weight);
|
|
24
|
+
color: var(--semantic-color-text-default);
|
|
25
|
+
line-height: var(--semantic-font-line-height-body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.a1-toolbar {
|
|
29
|
+
/* Variant layer — every value resolves to a Style Dictionary token. */
|
|
30
|
+
--a1-toolbar-item-size: var(--base-spacing-32);
|
|
31
|
+
--a1-toolbar-bg: var(--semantic-color-surface-raised);
|
|
32
|
+
/* Unselected tools sit back in muted text; the selected tool steps forward
|
|
33
|
+
with default text on a darker neutral background — low-contrast, neutral. */
|
|
34
|
+
--a1-toolbar-fg: var(--semantic-color-text-muted);
|
|
35
|
+
--a1-toolbar-hover-bg: color-mix(in srgb, var(--semantic-color-text-default) 8%, transparent);
|
|
36
|
+
--a1-toolbar-selected-bg: color-mix(in srgb, var(--semantic-color-text-default) 16%, transparent);
|
|
37
|
+
--a1-toolbar-selected-fg: var(--semantic-color-text-default);
|
|
38
|
+
/* No boundary by default; the accessible theme and high-contrast modes raise
|
|
39
|
+
it so the bar is clearly delimited against the page surface. */
|
|
40
|
+
--a1-toolbar-border-width: 0;
|
|
41
|
+
--a1-toolbar-border-color: var(--semantic-color-border-default);
|
|
42
|
+
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
/* Tools wrap onto new rows when they run out of width — never overflow the
|
|
46
|
+
container and never scroll horizontally. */
|
|
47
|
+
flex-wrap: wrap;
|
|
48
|
+
max-inline-size: 100%;
|
|
49
|
+
gap: var(--base-spacing-2);
|
|
50
|
+
padding: var(--base-spacing-2);
|
|
51
|
+
background: var(--a1-toolbar-bg);
|
|
52
|
+
border: var(--a1-toolbar-border-width) solid var(--a1-toolbar-border-color);
|
|
53
|
+
border-radius: var(--base-radius-md);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Accessible theme: add a clear boundary at high contrast. */
|
|
57
|
+
[data-theme='accessible'] .a1-toolbar {
|
|
58
|
+
--a1-toolbar-border-width: var(--component-divider-size-sm);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* OS high-contrast / forced-colors: add the same boundary. */
|
|
62
|
+
@media (prefers-contrast: more) {
|
|
63
|
+
.a1-toolbar {
|
|
64
|
+
--a1-toolbar-border-width: var(--component-divider-size-sm);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@media (forced-colors: active) {
|
|
69
|
+
.a1-toolbar {
|
|
70
|
+
--a1-toolbar-border-width: var(--component-divider-size-sm);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── Full width (fill container, tools share the space) ────────────────────── */
|
|
75
|
+
.a1-toolbar--full-width {
|
|
76
|
+
display: flex;
|
|
77
|
+
inline-size: 100%;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Tools grow to share the available space; dividers keep their natural size. */
|
|
81
|
+
.a1-toolbar--full-width > :not(.a1-toolbar__divider) {
|
|
82
|
+
flex: 1 1 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* A stretched (non-grid) group distributes its own buttons across its share. */
|
|
86
|
+
.a1-toolbar--full-width .a1-toolbar__group:not(.a1-toolbar__group--grid) > .a1-toolbar__button {
|
|
87
|
+
flex: 1 1 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ── Overlay (floating, elevated bar over page content) ────────────────────── */
|
|
91
|
+
.a1-toolbar--overlay {
|
|
92
|
+
background: var(--semantic-color-surface-card);
|
|
93
|
+
border: var(--component-divider-size-xs) solid var(--semantic-color-border-default);
|
|
94
|
+
box-shadow: var(--semantic-shadow-lg);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ── Buttons (toggle / action / group items) ───────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
.a1-toolbar__button {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
gap: var(--base-spacing-4);
|
|
104
|
+
min-inline-size: var(--a1-toolbar-item-size);
|
|
105
|
+
block-size: var(--a1-toolbar-item-size);
|
|
106
|
+
padding-inline: var(--base-spacing-6);
|
|
107
|
+
margin: 0;
|
|
108
|
+
border: none;
|
|
109
|
+
border-radius: var(--base-radius-sm);
|
|
110
|
+
background: transparent;
|
|
111
|
+
color: var(--a1-toolbar-fg);
|
|
112
|
+
font-family: var(--component-paragraph-font-family);
|
|
113
|
+
font-size: var(--semantic-font-size-body-xs);
|
|
114
|
+
line-height: 1;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.a1-toolbar__button:hover {
|
|
119
|
+
background: var(--a1-toolbar-hover-bg);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.a1-toolbar__button:focus-visible {
|
|
123
|
+
outline: none;
|
|
124
|
+
box-shadow: 0 0 0 var(--component-button-focus-ring-width) var(--component-field-focus-ring-color);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Selected state for toggles (aria-pressed) and group items (aria-checked) —
|
|
128
|
+
default text on a darker neutral fill. */
|
|
129
|
+
.a1-toolbar__button[aria-pressed="true"],
|
|
130
|
+
.a1-toolbar__button[aria-checked="true"] {
|
|
131
|
+
background: var(--a1-toolbar-selected-bg);
|
|
132
|
+
color: var(--a1-toolbar-selected-fg);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* A toggle (aria-pressed) can be turned off, so it keeps hover feedback when on.
|
|
136
|
+
A radio group item (aria-checked) can't be deselected, so it shows no hover or
|
|
137
|
+
active change — clicking it does nothing. */
|
|
138
|
+
.a1-toolbar__button[aria-pressed="true"]:hover {
|
|
139
|
+
background: color-mix(in srgb, var(--semantic-color-text-default) 22%, transparent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.a1-toolbar__button[aria-checked="true"]:hover,
|
|
143
|
+
.a1-toolbar__button[aria-checked="true"]:active {
|
|
144
|
+
background: var(--a1-toolbar-selected-bg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.a1-toolbar__button:disabled {
|
|
148
|
+
opacity: 0.5;
|
|
149
|
+
cursor: not-allowed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.a1-toolbar__label {
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Colour swatch shown inside a tool to preview a specific colour. The border
|
|
157
|
+
keeps a light swatch from bleeding into the toolbar surface. */
|
|
158
|
+
.a1-toolbar__swatch {
|
|
159
|
+
flex: none;
|
|
160
|
+
inline-size: var(--base-spacing-16);
|
|
161
|
+
block-size: var(--base-spacing-16);
|
|
162
|
+
border-radius: var(--base-radius-sm);
|
|
163
|
+
border: var(--component-divider-size-xs) solid var(--semantic-color-border-default);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Responsive label visibility (showLabel/showLabels as a breakpoint object).
|
|
167
|
+
The component emits one cascading class per breakpoint (xs → xl); the last
|
|
168
|
+
matching media query wins, mirroring Divider's responsive pattern. */
|
|
169
|
+
.a1-toolbar__label--hide-xs { display: none; }
|
|
170
|
+
.a1-toolbar__label--show-xs { display: inline; }
|
|
171
|
+
|
|
172
|
+
@media (--bp-sm-up) {
|
|
173
|
+
.a1-toolbar__label--hide-sm { display: none; }
|
|
174
|
+
.a1-toolbar__label--show-sm { display: inline; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@media (--bp-md-up) {
|
|
178
|
+
.a1-toolbar__label--hide-md { display: none; }
|
|
179
|
+
.a1-toolbar__label--show-md { display: inline; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@media (--bp-lg-up) {
|
|
183
|
+
.a1-toolbar__label--hide-lg { display: none; }
|
|
184
|
+
.a1-toolbar__label--show-lg { display: inline; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@media (--bp-xl) {
|
|
188
|
+
.a1-toolbar__label--hide-xl { display: none; }
|
|
189
|
+
.a1-toolbar__label--show-xl { display: inline; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ── Menu button (opens a dropdown Menu) ───────────────────────────────────── */
|
|
193
|
+
|
|
194
|
+
.a1-toolbar__menu-button {
|
|
195
|
+
inline-size: auto;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.a1-toolbar__caret {
|
|
199
|
+
/* Pull the caret tight to the icon/label so the menu button stays compact. */
|
|
200
|
+
margin-inline-start: calc(-1 * var(--base-spacing-2));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ── Group (segmented) + grid ──────────────────────────────────────────────── */
|
|
204
|
+
|
|
205
|
+
.a1-toolbar__group {
|
|
206
|
+
display: inline-flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
/* A wide single group wraps its own buttons rather than overflowing the bar. */
|
|
209
|
+
flex-wrap: wrap;
|
|
210
|
+
gap: var(--base-spacing-2);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.a1-toolbar__group--grid {
|
|
214
|
+
display: grid;
|
|
215
|
+
grid-template-columns: repeat(var(--a1-toolbar-grid-columns, 3), var(--a1-toolbar-item-size));
|
|
216
|
+
gap: var(--base-spacing-2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── Divider ───────────────────────────────────────────────────────────────── */
|
|
220
|
+
|
|
221
|
+
.a1-toolbar__divider {
|
|
222
|
+
align-self: stretch;
|
|
223
|
+
inline-size: var(--component-divider-size-xs);
|
|
224
|
+
min-block-size: var(--a1-toolbar-item-size);
|
|
225
|
+
margin-inline: var(--base-spacing-2);
|
|
226
|
+
background: var(--semantic-color-border-default);
|
|
227
|
+
border-radius: var(--base-radius-pill);
|
|
228
|
+
}
|
|
229
|
+
|