@gtivr4/a1-design-system-react 0.13.3 → 0.15.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.
@@ -0,0 +1,92 @@
1
+ import "./split-button.css";
2
+ import { useRef, useState } from "react";
3
+ import { Button } from "../button/Button.jsx";
4
+ import { Menu, MenuItem } from "../menu/Menu.jsx";
5
+ import { Icon } from "../icon/Icon.jsx";
6
+
7
+ const variants = ["primary", "secondary", "tertiary", "destructive", "success"];
8
+ const sizes = ["sm", "md", "lg"];
9
+
10
+ /**
11
+ * SplitButton — a primary action with an attached caret target that opens a Menu
12
+ * of related/secondary actions. The main button runs the default action; the
13
+ * toggle (caret) on its inline-end opens the menu. Built from the A1 `Button` and
14
+ * `Menu`, so styling, keyboard, and focus come from those components.
15
+ *
16
+ * `actions` is `{ id, label, icon?, disabled?, onClick? }[]`.
17
+ */
18
+ export function SplitButton({
19
+ children,
20
+ onClick,
21
+ variant = "primary",
22
+ size = "md",
23
+ icon,
24
+ iconPosition = "start",
25
+ loading = false,
26
+ disabled = false,
27
+ actions = [],
28
+ menuLabel = "More actions",
29
+ toggleLabel = "More actions",
30
+ className = "",
31
+ ...rest
32
+ }) {
33
+ const [open, setOpen] = useState(false);
34
+ const toggleRef = useRef(null);
35
+
36
+ const resolvedVariant = variants.includes(variant) ? variant : "primary";
37
+ const resolvedSize = sizes.includes(size) ? size : "md";
38
+ const isInert = disabled || loading;
39
+
40
+ // The toggle reuses the Button class layer (variant/size design tokens) on a
41
+ // raw <button> so it can be ref'd as the menu anchor (Button doesn't forward refs).
42
+ const toggleClasses = [
43
+ "a1-button",
44
+ `a1-button--${resolvedVariant}`,
45
+ resolvedSize !== "md" && `a1-button--${resolvedSize}`,
46
+ "a1-split-button__toggle",
47
+ ].filter(Boolean).join(" ");
48
+
49
+ return (
50
+ <div
51
+ className={["a1-split-button", isInert && "a1-split-button--disabled", className].filter(Boolean).join(" ")}
52
+ {...rest}
53
+ >
54
+ <Button
55
+ className="a1-split-button__main"
56
+ variant={resolvedVariant}
57
+ size={resolvedSize}
58
+ icon={icon}
59
+ iconPosition={iconPosition}
60
+ loading={loading}
61
+ disabled={disabled}
62
+ onClick={onClick}
63
+ >
64
+ {children}
65
+ </Button>
66
+ <button
67
+ ref={toggleRef}
68
+ type="button"
69
+ className={toggleClasses}
70
+ aria-label={toggleLabel}
71
+ aria-haspopup="menu"
72
+ aria-expanded={open}
73
+ disabled={isInert}
74
+ onClick={() => setOpen((current) => !current)}
75
+ >
76
+ <Icon name="arrow_drop_down" className="a1-split-button__caret" aria-hidden="true" />
77
+ </button>
78
+ <Menu open={open} onClose={() => setOpen(false)} anchorRef={toggleRef} aria-label={menuLabel}>
79
+ {actions.map((action) => (
80
+ <MenuItem
81
+ key={action.id}
82
+ icon={action.icon}
83
+ disabled={action.disabled}
84
+ onClick={() => { action.onClick?.(); setOpen(false); }}
85
+ >
86
+ {action.label}
87
+ </MenuItem>
88
+ ))}
89
+ </Menu>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,40 @@
1
+ /* ══════════════════════════════════════════════════════════════════════════
2
+ Split Button — a primary action joined to a caret toggle that opens a Menu.
3
+ ══════════════════════════════════════════════════════════════════════════ */
4
+
5
+ .a1-split-button {
6
+ display: inline-flex;
7
+ align-items: stretch;
8
+ }
9
+
10
+ /* The two targets sit flush; the main rounds its inline-start corners, the
11
+ toggle rounds its inline-end corners, so together they read as one pill. */
12
+ .a1-split-button__main {
13
+ --a1-button-border-radius: var(--component-button-border-radius) 0 0 var(--component-button-border-radius);
14
+ }
15
+
16
+ .a1-split-button__toggle {
17
+ --a1-button-border-radius: 0 var(--component-button-border-radius) var(--component-button-border-radius) 0;
18
+ /* A snug caret target rather than a full text button's inline padding. */
19
+ --a1-button-padding-inline: var(--base-spacing-8);
20
+ position: relative;
21
+ }
22
+
23
+ /* Hairline divider between the two targets, drawn in the button's own foreground
24
+ so it reads on every variant. */
25
+ .a1-split-button__toggle::before {
26
+ content: "";
27
+ position: absolute;
28
+ inset-block: var(--base-spacing-8);
29
+ inset-inline-start: 0;
30
+ inline-size: var(--component-divider-size-sm);
31
+ background: color-mix(in srgb, var(--a1-button-foreground) 35%, transparent);
32
+ }
33
+
34
+ .a1-split-button__caret {
35
+ font-size: var(--component-button-icon-size);
36
+ }
37
+
38
+ .a1-split-button--disabled {
39
+ cursor: not-allowed;
40
+ }
@@ -0,0 +1,124 @@
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
+ "aria-label"?: string;
120
+ disabled?: boolean;
121
+ className?: string;
122
+ }
123
+ /** A single-select button group (radio semantics); set `columns` for a grid. */
124
+ export declare function ToolbarGroup(props: ToolbarGroupProps): React.ReactElement;
@@ -0,0 +1,327 @@
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() {
112
+ return <span className="a1-toolbar__divider" role="separator" aria-orientation="vertical" />;
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
+ }) {
200
+ const [open, setOpen] = useState(false);
201
+ const btnRef = useRef(null);
202
+ const active = items.find((it) => it.value === value);
203
+ const buttonIcon = icon ?? active?.icon;
204
+ const name = ariaLabel ?? label;
205
+
206
+ return (
207
+ <>
208
+ <button
209
+ ref={btnRef}
210
+ type="button"
211
+ className={cx("a1-toolbar__button", "a1-toolbar__menu-button", className)}
212
+ aria-haspopup="menu"
213
+ aria-expanded={open}
214
+ aria-label={ariaLabel ?? toolAriaLabel(showLabel, label)}
215
+ title={label}
216
+ disabled={disabled}
217
+ onClick={() => setOpen((o) => !o)}
218
+ >
219
+ <ToolButtonContent icon={buttonIcon} label={label} showLabel={showLabel} />
220
+ <Icon name="arrow_drop_down" size="sm" className="a1-toolbar__caret" />
221
+ </button>
222
+ <Menu open={open} onClose={() => setOpen(false)} anchorRef={btnRef} aria-label={name}>
223
+ {items.map((it) => (
224
+ <MenuItem
225
+ key={String(it.value)}
226
+ icon={it.icon}
227
+ active={it.value === value}
228
+ disabled={it.disabled}
229
+ onClick={() => { onChange?.(it.value); setOpen(false); }}
230
+ >
231
+ {it.label ?? String(it.value)}
232
+ </MenuItem>
233
+ ))}
234
+ </Menu>
235
+ </>
236
+ );
237
+ }
238
+
239
+ /**
240
+ * A single-select button group (radio semantics) — e.g. text alignment, or a
241
+ * compact `columns`-wide grid like a 3×3 crop-direction picker. Mutually
242
+ * exclusive options with clearly highlighted selection, in far less space than a
243
+ * ChoiceGroup.
244
+ */
245
+ export function ToolbarGroup({
246
+ value,
247
+ onChange,
248
+ options = [],
249
+ columns,
250
+ showLabels = false,
251
+ "aria-label": ariaLabel,
252
+ disabled = false,
253
+ className = "",
254
+ }) {
255
+ const btnRefs = useRef([]);
256
+ const grid = typeof columns === "number" && columns > 0;
257
+ const selectedIndex = options.findIndex((o) => o.value === value);
258
+
259
+ function move(index, select = true) {
260
+ const n = options.length;
261
+ const idx = ((index % n) + n) % n;
262
+ btnRefs.current[idx]?.focus();
263
+ if (select) onChange?.(options[idx].value);
264
+ }
265
+
266
+ function handleKeyDown(e, i) {
267
+ const n = options.length;
268
+ let next = null;
269
+ let clampVertical = false;
270
+ switch (e.key) {
271
+ case "ArrowRight": next = i + 1; break;
272
+ case "ArrowLeft": next = i - 1; break;
273
+ case "ArrowDown": next = grid ? i + columns : i + 1; clampVertical = grid; break;
274
+ case "ArrowUp": next = grid ? i - columns : i - 1; clampVertical = grid; break;
275
+ case "Home": next = 0; break;
276
+ case "End": next = n - 1; break;
277
+ default: return;
278
+ }
279
+ e.preventDefault();
280
+ // Vertical moves in a grid clamp (don't wrap to a wrong column); everything
281
+ // else wraps around the linear order.
282
+ if (clampVertical) {
283
+ if (next < 0 || next >= n) return;
284
+ move(next);
285
+ } else {
286
+ move(next);
287
+ }
288
+ }
289
+
290
+ const style = grid ? { "--a1-toolbar-grid-columns": columns } : undefined;
291
+
292
+ return (
293
+ <div
294
+ role="radiogroup"
295
+ aria-label={ariaLabel}
296
+ className={cx("a1-toolbar__group", grid && "a1-toolbar__group--grid", className)}
297
+ style={style}
298
+ >
299
+ {options.map((opt, i) => {
300
+ const selected = i === selectedIndex;
301
+ // Roving tabindex: the selected option (or the first, when none is
302
+ // selected) is the single tab stop for the group.
303
+ const tabIndex = selected || (selectedIndex === -1 && i === 0) ? 0 : -1;
304
+ const optLabel = opt.label ?? String(opt.value);
305
+ const icon = opt.icon ?? (!labelAlwaysShown(showLabels) && isNoneValue(opt.value) ? TOOLBAR_NONE_ICON : undefined);
306
+ return (
307
+ <button
308
+ key={String(opt.value)}
309
+ ref={(el) => { btnRefs.current[i] = el; }}
310
+ type="button"
311
+ role="radio"
312
+ aria-checked={selected}
313
+ aria-label={toolAriaLabel(showLabels, optLabel)}
314
+ title={optLabel}
315
+ tabIndex={tabIndex}
316
+ disabled={disabled || opt.disabled}
317
+ className="a1-toolbar__button"
318
+ onClick={() => onChange?.(opt.value)}
319
+ onKeyDown={(e) => handleKeyDown(e, i)}
320
+ >
321
+ <ToolButtonContent icon={icon} label={opt.label} showLabel={showLabels} swatch={opt.swatch} />
322
+ </button>
323
+ );
324
+ })}
325
+ </div>
326
+ );
327
+ }