@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.
- package/package.json +1 -1
- package/src/components/accordion/Accordion.d.ts +8 -0
- package/src/components/accordion/Accordion.jsx +9 -1
- package/src/components/accordion/accordion.css +40 -6
- package/src/components/button/button.css +7 -3
- package/src/components/figure/Figure.d.ts +30 -4
- package/src/components/figure/Figure.jsx +57 -9
- package/src/components/figure/figure.css +80 -8
- 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/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/slider/Slider.d.ts +71 -0
- package/src/components/slider/Slider.jsx +243 -0
- package/src/components/slider/slider.css +230 -0
- package/src/components/split-button/SplitButton.d.ts +39 -0
- package/src/components/split-button/SplitButton.jsx +92 -0
- package/src/components/split-button/split-button.css +40 -0
- package/src/components/toolbar/Toolbar.d.ts +124 -0
- package/src/components/toolbar/Toolbar.jsx +327 -0
- package/src/components/toolbar/toolbar.css +229 -0
- package/src/index.js +13 -1
- package/src/tokens.css +4 -2
|
@@ -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
|
+
}
|