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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/color-scheme.css +2 -0
  3. package/src/components/accordion/Accordion.d.ts +8 -0
  4. package/src/components/accordion/Accordion.jsx +9 -1
  5. package/src/components/accordion/accordion.css +46 -6
  6. package/src/components/autocomplete/Autocomplete.d.ts +53 -0
  7. package/src/components/autocomplete/Autocomplete.jsx +380 -0
  8. package/src/components/autocomplete/autocomplete.css +346 -0
  9. package/src/components/banner/Banner.d.ts +9 -2
  10. package/src/components/banner/Banner.jsx +32 -6
  11. package/src/components/banner/banner.css +81 -0
  12. package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
  13. package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
  14. package/src/components/bottom-sheet/bottom-sheet.css +113 -0
  15. package/src/components/button/button.css +7 -3
  16. package/src/components/code/Code.jsx +6 -1
  17. package/src/components/data-table/DataTable.jsx +11 -1
  18. package/src/components/data-table/data-table.css +19 -0
  19. package/src/components/figure/Figure.d.ts +37 -4
  20. package/src/components/figure/Figure.jsx +78 -9
  21. package/src/components/figure/figure.css +105 -8
  22. package/src/components/grid/Grid.d.ts +1 -1
  23. package/src/components/grid/Grid.jsx +2 -0
  24. package/src/components/grid/grid.css +5 -0
  25. package/src/components/icon-button/IconButton.d.ts +2 -2
  26. package/src/components/icon-button/IconButton.jsx +3 -2
  27. package/src/components/icon-button/icon-button.css +11 -1
  28. package/src/components/menu/Menu.jsx +12 -0
  29. package/src/components/menu/menu.css +17 -6
  30. package/src/components/page-layout/page-layout.css +10 -4
  31. package/src/components/page-nav/PageNav.jsx +29 -8
  32. package/src/components/page-nav/page-nav.css +13 -0
  33. package/src/components/paragraph/Paragraph.d.ts +2 -0
  34. package/src/components/paragraph/Paragraph.jsx +4 -0
  35. package/src/components/paragraph/paragraph.css +6 -6
  36. package/src/components/section/Section.d.ts +6 -0
  37. package/src/components/section/Section.jsx +19 -0
  38. package/src/components/section/section.css +33 -10
  39. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  40. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  41. package/src/components/segmented-control/segmented.css +31 -1
  42. package/src/components/slider/Slider.d.ts +71 -0
  43. package/src/components/slider/Slider.jsx +243 -0
  44. package/src/components/slider/slider.css +238 -0
  45. package/src/components/split-button/SplitButton.d.ts +39 -0
  46. package/src/components/split-button/SplitButton.jsx +94 -0
  47. package/src/components/split-button/split-button.css +40 -0
  48. package/src/components/tabs/tabs.css +3 -0
  49. package/src/components/toolbar/Toolbar.d.ts +131 -0
  50. package/src/components/toolbar/Toolbar.jsx +335 -0
  51. package/src/components/toolbar/toolbar.css +229 -0
  52. package/src/components/top-header/top-header.css +2 -0
  53. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  54. package/src/index.d.ts +71 -0
  55. package/src/index.js +15 -1
  56. package/src/themes.css +293 -0
  57. package/src/tokens.css +26 -3
@@ -0,0 +1,243 @@
1
+ import "./slider.css";
2
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { Icon } from "../icon/Icon.jsx";
4
+
5
+ // Density sizes mirror the field family so a Slider sits naturally beside fields.
6
+ const VALID_SLIDER_SIZES = ["compact", "default", "comfortable"];
7
+
8
+ /** Normalize the `detents` prop to `{ value, label, icon }[]` (or null when absent). */
9
+ function normalizeDetents(detents) {
10
+ if (!Array.isArray(detents) || detents.length === 0) return null;
11
+ return detents.map((d) =>
12
+ typeof d === "number"
13
+ ? { value: d, label: undefined, icon: undefined }
14
+ : { value: d.value, label: d.label, icon: d.icon },
15
+ );
16
+ }
17
+
18
+ /** Index of the detent closest to `value`. */
19
+ function nearestDetentIndex(detents, value) {
20
+ let best = 0;
21
+ let bestDist = Infinity;
22
+ detents.forEach((d, i) => {
23
+ const dist = Math.abs(d.value - value);
24
+ if (dist < bestDist) { bestDist = dist; best = i; }
25
+ });
26
+ return best;
27
+ }
28
+
29
+ /** Position (and the matching CSS for ticks/labels) of a thumb at fraction `p`
30
+ * (0–1), accounting for the thumb's width so it tracks the real thumb centre. */
31
+ function thumbLeft(p) {
32
+ return `calc(var(--a1-slider-thumb-size) / 2 + ${p} * (100% - var(--a1-slider-thumb-size)))`;
33
+ }
34
+
35
+ /**
36
+ * Slider — a range input with an optional set of **detents** (labeled stops the
37
+ * thumb snaps to, e.g. None / XS / SM / LG) and a value bubble that follows the
38
+ * thumb while you drag or focus it, flipping above/below to stay in the viewport.
39
+ *
40
+ * Built on a native `<input type="range">`, so keyboard support (arrows, Home/End,
41
+ * Page Up/Down), focus, touch, and form semantics come for free. The accessible
42
+ * value is announced via `aria-valuetext` (the detent label when present).
43
+ */
44
+ export function Slider({
45
+ value,
46
+ defaultValue,
47
+ onChange,
48
+ min = 0,
49
+ max = 100,
50
+ step = 1,
51
+ detents,
52
+ label,
53
+ size = "default",
54
+ variant = "default",
55
+ "aria-label": ariaLabel,
56
+ "aria-labelledby": ariaLabelledBy,
57
+ showValue = true,
58
+ valuePosition = "above",
59
+ formatValue,
60
+ bubbleLabel,
61
+ disabled = false,
62
+ name,
63
+ id,
64
+ className = "",
65
+ ...rest
66
+ }) {
67
+ const reactId = useId();
68
+ const sliderId = id ?? `a1-slider-${reactId}`;
69
+ const dets = normalizeDetents(detents);
70
+
71
+ const isControlled = value !== undefined;
72
+ const [internal, setInternal] = useState(() =>
73
+ defaultValue !== undefined ? defaultValue : dets ? dets[0].value : min,
74
+ );
75
+ const current = isControlled ? value : internal;
76
+
77
+ // In detent mode the native input works in index space (0..n-1, step 1).
78
+ const detentIndex = dets ? nearestDetentIndex(dets, current) : null;
79
+ const inputMin = dets ? 0 : min;
80
+ const inputMax = dets ? dets.length - 1 : max;
81
+ const inputStep = dets ? 1 : step;
82
+ const inputValue = dets ? detentIndex : current;
83
+ const pct = inputMax === inputMin ? 0 : (inputValue - inputMin) / (inputMax - inputMin);
84
+
85
+ const controlRef = useRef(null);
86
+ const [interacting, setInteracting] = useState(false);
87
+ const [placement, setPlacement] = useState(valuePosition);
88
+
89
+ function commit(next) {
90
+ if (!isControlled) setInternal(next);
91
+ onChange?.(next);
92
+ }
93
+
94
+ function handleInput(e) {
95
+ const raw = Number(e.target.value);
96
+ commit(dets ? dets[Math.round(raw)].value : raw);
97
+ }
98
+
99
+ const activeDetent = dets ? dets[detentIndex] : null;
100
+ const formatted = formatValue ? formatValue(current) : null;
101
+
102
+ // Accessible value text — always a string (an icon detent falls back to its
103
+ // label, then its numeric value, so screen readers still announce something).
104
+ const ariaValueText =
105
+ typeof formatted === "string" ? formatted
106
+ : activeDetent?.label != null ? String(activeDetent.label)
107
+ : activeDetent ? String(activeDetent.value)
108
+ : String(current);
109
+
110
+ // Optional bubble-specific label (visual only; aria-valuetext is unchanged).
111
+ // A function receives the current value and active detent; a node is used as-is.
112
+ const resolvedBubbleLabel =
113
+ typeof bubbleLabel === "function" ? bubbleLabel(current, activeDetent)
114
+ : bubbleLabel != null ? bubbleLabel
115
+ : null;
116
+
117
+ // Visual bubble content — the explicit bubble label wins, then a formatted
118
+ // value, then the active detent (label, icon, or value), then the raw value.
119
+ const bubbleContent =
120
+ resolvedBubbleLabel != null ? resolvedBubbleLabel
121
+ : formatted != null ? formatted
122
+ : activeDetent
123
+ ? (activeDetent.label != null
124
+ ? activeDetent.label
125
+ : activeDetent.icon
126
+ ? <Icon name={activeDetent.icon} size="sm" color="inverse" />
127
+ : String(activeDetent.value))
128
+ : String(current);
129
+
130
+ // Keep the bubble in the viewport: honor `valuePosition`, but flip if the
131
+ // chosen side would clip the top/bottom edge.
132
+ const updatePlacement = useCallback(() => {
133
+ const el = controlRef.current;
134
+ if (!el) return;
135
+ const rect = el.getBoundingClientRect();
136
+ const MARGIN = 56;
137
+ if (valuePosition === "above" && rect.top < MARGIN) setPlacement("below");
138
+ else if (valuePosition === "below" && rect.bottom > window.innerHeight - MARGIN) setPlacement("above");
139
+ else setPlacement(valuePosition);
140
+ }, [valuePosition]);
141
+
142
+ useLayoutEffect(() => {
143
+ if (interacting) updatePlacement();
144
+ }, [interacting, current, updatePlacement]);
145
+
146
+ useEffect(() => {
147
+ if (!interacting) return undefined;
148
+ const onMove = () => updatePlacement();
149
+ window.addEventListener("scroll", onMove, true);
150
+ window.addEventListener("resize", onMove);
151
+ return () => {
152
+ window.removeEventListener("scroll", onMove, true);
153
+ window.removeEventListener("resize", onMove);
154
+ };
155
+ }, [interacting, updatePlacement]);
156
+
157
+ const showBubble = showValue && interacting && !disabled;
158
+ const hasLabelRow = !!dets && dets.some((d) => d.label != null || d.icon != null);
159
+
160
+ const resolvedSize = VALID_SLIDER_SIZES.includes(size) ? size : "default";
161
+
162
+ const classes = [
163
+ "a1-slider",
164
+ resolvedSize !== "default" && `a1-slider--${resolvedSize}`,
165
+ variant === "subtle" && "a1-slider--subtle",
166
+ dets && "a1-slider--detents",
167
+ disabled && "a1-slider--disabled",
168
+ className,
169
+ ].filter(Boolean).join(" ");
170
+
171
+ return (
172
+ <div className={classes} style={{ "--a1-slider-pct": pct }}>
173
+ {label != null && (
174
+ <label className="a1-slider__field-label" htmlFor={sliderId}>{label}</label>
175
+ )}
176
+ <div className="a1-slider__control" ref={controlRef}>
177
+ {showBubble && (
178
+ <span className={`a1-slider__bubble a1-slider__bubble--${placement}`} aria-hidden="true">
179
+ {bubbleContent}
180
+ </span>
181
+ )}
182
+ <input
183
+ type="range"
184
+ id={sliderId}
185
+ name={name}
186
+ className="a1-slider__input"
187
+ min={inputMin}
188
+ max={inputMax}
189
+ step={inputStep}
190
+ value={inputValue}
191
+ disabled={disabled}
192
+ // The visible `<label htmlFor>` names the input; only fall back to
193
+ // aria-label when there's no visible label.
194
+ aria-label={label != null ? ariaLabel : (ariaLabel ?? undefined)}
195
+ aria-labelledby={ariaLabelledBy}
196
+ aria-valuetext={ariaValueText}
197
+ onChange={handleInput}
198
+ onFocus={() => setInteracting(true)}
199
+ onBlur={() => setInteracting(false)}
200
+ onPointerDown={() => setInteracting(true)}
201
+ onPointerUp={() => setInteracting(false)}
202
+ onPointerCancel={() => setInteracting(false)}
203
+ {...rest}
204
+ />
205
+ {dets && (
206
+ <div className="a1-slider__ticks" aria-hidden="true">
207
+ {dets.map((d, i) => {
208
+ const p = dets.length === 1 ? 0 : i / (dets.length - 1);
209
+ return (
210
+ <span
211
+ key={i}
212
+ className="a1-slider__tick"
213
+ data-active={p <= pct || undefined}
214
+ style={{ left: thumbLeft(p) }}
215
+ />
216
+ );
217
+ })}
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ {hasLabelRow && (
223
+ <div className="a1-slider__labels" aria-hidden="true">
224
+ {dets.map((d, i) => {
225
+ const p = dets.length === 1 ? 0 : i / (dets.length - 1);
226
+ const transform =
227
+ i === 0 ? "translateX(0)" : i === dets.length - 1 ? "translateX(-100%)" : "translateX(-50%)";
228
+ return (
229
+ <span
230
+ key={i}
231
+ className="a1-slider__label"
232
+ data-active={i === detentIndex || undefined}
233
+ style={{ left: thumbLeft(p), transform }}
234
+ >
235
+ {d.icon ? <Icon name={d.icon} size="sm" /> : (d.label ?? d.value)}
236
+ </span>
237
+ );
238
+ })}
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
+ }
@@ -0,0 +1,238 @@
1
+ /* ══════════════════════════════════════════════════════════════════════════
2
+ Slider
3
+ ══════════════════════════════════════════════════════════════════════════ */
4
+
5
+ .a1-slider {
6
+ /* Variant layer — every value resolves to a Style Dictionary token. */
7
+ --a1-slider-track-height: var(--base-spacing-8);
8
+ --a1-slider-thumb-size: var(--base-spacing-20);
9
+ --a1-slider-track-color: var(--semantic-color-border-subtle);
10
+ --a1-slider-fill-color: var(--semantic-color-action-background);
11
+ --a1-slider-thumb-color: var(--semantic-color-action-background);
12
+ --a1-slider-thumb-ring: var(--semantic-color-surface-page);
13
+ --a1-slider-active-color: var(--semantic-color-text-accent);
14
+ /* Label + detent sizing mirror the field family so a Slider matches fields. */
15
+ --a1-slider-label-size: var(--semantic-font-size-form-label-default);
16
+ --a1-slider-label-weight: var(--component-field-label-font-weight);
17
+ --a1-slider-detent-size: var(--semantic-font-size-body-sm);
18
+
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--base-spacing-4);
22
+ inline-size: 100%;
23
+ }
24
+
25
+ /* ── Sizes (match the field family: compact / default / comfortable) ─────────── */
26
+
27
+ .a1-slider--compact {
28
+ /* Track + thumb are one step up from the smallest scale so the compact slider
29
+ stays grabbable; the label/detent text keeps the compact field-family size. */
30
+ --a1-slider-track-height: var(--base-spacing-8);
31
+ --a1-slider-thumb-size: var(--base-spacing-20);
32
+ --a1-slider-label-size: var(--semantic-font-size-form-label-compact);
33
+ --a1-slider-label-weight: var(--component-field-compact-label-font-weight);
34
+ --a1-slider-detent-size: var(--semantic-font-size-body-xs);
35
+ }
36
+
37
+ .a1-slider--comfortable {
38
+ --a1-slider-track-height: var(--base-spacing-12);
39
+ --a1-slider-thumb-size: var(--base-spacing-24);
40
+ --a1-slider-label-size: var(--semantic-font-size-form-label-comfortable);
41
+ }
42
+
43
+ /* Subtle variant — neutral fill/thumb/active instead of the action colour. */
44
+ .a1-slider--subtle {
45
+ --a1-slider-fill-color: var(--semantic-color-text-default);
46
+ --a1-slider-thumb-color: var(--semantic-color-text-default);
47
+ --a1-slider-active-color: var(--semantic-color-text-default);
48
+ }
49
+
50
+ /* ── Field label ─────────────────────────────────────────────────────────────── */
51
+
52
+ .a1-slider__field-label {
53
+ font-family: var(--component-paragraph-font-family);
54
+ font-size: var(--a1-slider-label-size);
55
+ font-weight: var(--a1-slider-label-weight);
56
+ color: var(--semantic-color-text-default);
57
+ line-height: var(--semantic-font-line-height-body);
58
+ }
59
+
60
+ /* ── Control (track + thumb + bubble + ticks) ──────────────────────────────── */
61
+
62
+ .a1-slider__control {
63
+ position: relative;
64
+ display: flex;
65
+ align-items: center;
66
+ block-size: var(--a1-slider-thumb-size);
67
+ }
68
+
69
+ .a1-slider__input {
70
+ -webkit-appearance: none;
71
+ appearance: none;
72
+ margin: 0;
73
+ inline-size: 100%;
74
+ block-size: var(--a1-slider-thumb-size);
75
+ background: transparent;
76
+ cursor: pointer;
77
+ }
78
+
79
+ .a1-slider__input:focus-visible {
80
+ outline: none;
81
+ }
82
+
83
+ /* Track — WebKit/Blink. Fill is drawn as a gradient up to --a1-slider-pct. */
84
+ .a1-slider__input::-webkit-slider-runnable-track {
85
+ block-size: var(--a1-slider-track-height);
86
+ border-radius: var(--base-radius-pill);
87
+ background: linear-gradient(
88
+ to right,
89
+ var(--a1-slider-fill-color) 0%,
90
+ var(--a1-slider-fill-color) calc(var(--a1-slider-pct) * 100%),
91
+ var(--a1-slider-track-color) calc(var(--a1-slider-pct) * 100%),
92
+ var(--a1-slider-track-color) 100%
93
+ );
94
+ }
95
+
96
+ /* Track — Firefox. */
97
+ .a1-slider__input::-moz-range-track {
98
+ block-size: var(--a1-slider-track-height);
99
+ border-radius: var(--base-radius-pill);
100
+ background: var(--a1-slider-track-color);
101
+ }
102
+ .a1-slider__input::-moz-range-progress {
103
+ block-size: var(--a1-slider-track-height);
104
+ border-radius: var(--base-radius-pill);
105
+ background: var(--a1-slider-fill-color);
106
+ }
107
+
108
+ /* Thumb — WebKit/Blink. margin-top centers it on the thinner track. */
109
+ .a1-slider__input::-webkit-slider-thumb {
110
+ -webkit-appearance: none;
111
+ appearance: none;
112
+ inline-size: var(--a1-slider-thumb-size);
113
+ block-size: var(--a1-slider-thumb-size);
114
+ margin-block-start: calc((var(--a1-slider-track-height) - var(--a1-slider-thumb-size)) / 2);
115
+ border-radius: var(--base-radius-pill);
116
+ background: var(--a1-slider-thumb-color);
117
+ border: var(--base-spacing-2) solid var(--a1-slider-thumb-ring);
118
+ box-shadow: var(--component-switch-thumb-shadow);
119
+ }
120
+
121
+ /* Thumb — Firefox. */
122
+ .a1-slider__input::-moz-range-thumb {
123
+ inline-size: var(--a1-slider-thumb-size);
124
+ block-size: var(--a1-slider-thumb-size);
125
+ border-radius: var(--base-radius-pill);
126
+ background: var(--a1-slider-thumb-color);
127
+ border: var(--base-spacing-2) solid var(--a1-slider-thumb-ring);
128
+ box-shadow: var(--component-switch-thumb-shadow);
129
+ }
130
+
131
+ /* Focus ring on the thumb (base shadow + accent ring). */
132
+ .a1-slider__input:focus-visible::-webkit-slider-thumb {
133
+ box-shadow: var(--component-switch-thumb-shadow), 0 0 0 var(--component-button-focus-ring-width) var(--component-field-focus-ring-color);
134
+ }
135
+ .a1-slider__input:focus-visible::-moz-range-thumb {
136
+ box-shadow: var(--component-switch-thumb-shadow), 0 0 0 var(--component-button-focus-ring-width) var(--component-field-focus-ring-color);
137
+ }
138
+
139
+ /* ── Detent ticks ──────────────────────────────────────────────────────────── */
140
+
141
+ .a1-slider__ticks {
142
+ position: absolute;
143
+ inset-inline: 0;
144
+ inset-block-start: 50%;
145
+ block-size: 0;
146
+ pointer-events: none;
147
+ }
148
+
149
+ .a1-slider__tick {
150
+ position: absolute;
151
+ inline-size: var(--base-spacing-4);
152
+ block-size: var(--base-spacing-4);
153
+ border-radius: var(--base-radius-pill);
154
+ background: var(--semantic-color-border-default);
155
+ transform: translate(-50%, -50%);
156
+ }
157
+
158
+ .a1-slider__tick[data-active] {
159
+ background: var(--semantic-color-surface-page);
160
+ }
161
+
162
+ /* ── Value bubble ──────────────────────────────────────────────────────────── */
163
+
164
+ .a1-slider__bubble {
165
+ position: absolute;
166
+ left: calc(var(--a1-slider-thumb-size) / 2 + var(--a1-slider-pct) * (100% - var(--a1-slider-thumb-size)));
167
+ /* Anchor by progress so the bubble never spills past the control: left-aligned
168
+ at the start (0%), centered mid-track (-50%), right-aligned at the end
169
+ (-100%) — mirrors the detent-label end alignment. */
170
+ transform: translateX(calc(var(--a1-slider-pct) * -100%));
171
+ padding-block: var(--base-spacing-2);
172
+ padding-inline: var(--base-spacing-8);
173
+ border-radius: var(--base-radius-md);
174
+ background: var(--semantic-color-surface-inverse);
175
+ color: var(--semantic-color-text-inverse);
176
+ font-family: var(--component-paragraph-font-family);
177
+ font-size: var(--semantic-font-size-body-sm);
178
+ line-height: var(--semantic-font-line-height-body);
179
+ white-space: nowrap;
180
+ pointer-events: none;
181
+ z-index: 1;
182
+ }
183
+
184
+ .a1-slider__bubble--above { inset-block-end: calc(100% + var(--base-spacing-4)); }
185
+ .a1-slider__bubble--below { inset-block-start: calc(100% + var(--base-spacing-4)); }
186
+
187
+ /* Little caret pointing at the thumb. As the bubble is anchored by progress, the
188
+ caret tracks the thumb position within the bubble (0% → 100%). */
189
+ .a1-slider__bubble::after {
190
+ content: "";
191
+ position: absolute;
192
+ inset-inline-start: calc(var(--a1-slider-pct) * 100%);
193
+ inline-size: var(--base-spacing-8);
194
+ block-size: var(--base-spacing-8);
195
+ background: var(--semantic-color-surface-inverse);
196
+ transform: translateX(-50%) rotate(45deg);
197
+ }
198
+ .a1-slider__bubble--above::after { inset-block-start: calc(100% - var(--base-spacing-4)); }
199
+ .a1-slider__bubble--below::after { inset-block-end: calc(100% - var(--base-spacing-4)); }
200
+
201
+ /* ── Detent labels ─────────────────────────────────────────────────────────── */
202
+
203
+ .a1-slider__labels {
204
+ position: relative;
205
+ block-size: var(--a1-slider-detent-size);
206
+ }
207
+
208
+ .a1-slider__label {
209
+ position: absolute;
210
+ inset-block-start: 0;
211
+ font-family: var(--component-paragraph-font-family);
212
+ font-size: var(--a1-slider-detent-size);
213
+ line-height: 1;
214
+ color: var(--semantic-color-text-muted);
215
+ white-space: nowrap;
216
+ }
217
+
218
+ .a1-slider__label[data-active] {
219
+ color: var(--a1-slider-active-color);
220
+ }
221
+
222
+ /* ── Disabled ──────────────────────────────────────────────────────────────── */
223
+
224
+ .a1-slider--disabled {
225
+ --a1-slider-fill-color: var(--semantic-color-border-default);
226
+ --a1-slider-thumb-color: var(--semantic-color-border-default);
227
+ --a1-slider-active-color: var(--semantic-color-text-muted);
228
+ }
229
+
230
+ .a1-slider--disabled .a1-slider__input,
231
+ .a1-slider--disabled .a1-slider__track,
232
+ .a1-slider--disabled .a1-slider__ticks {
233
+ opacity: 0.6;
234
+ }
235
+
236
+ .a1-slider--disabled .a1-slider__input {
237
+ cursor: not-allowed;
238
+ }
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+
3
+ /** A secondary action shown in the split button's menu. */
4
+ export interface SplitButtonAction {
5
+ id: string;
6
+ label: React.ReactNode;
7
+ /** Optional Material Symbols icon name. */
8
+ icon?: string;
9
+ disabled?: boolean;
10
+ onClick?: () => void;
11
+ }
12
+
13
+ export interface SplitButtonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> {
14
+ /** Main button label. */
15
+ children?: React.ReactNode;
16
+ /** Main (default) action. */
17
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
18
+ /** Visual style, shared by both targets. Default: "primary". */
19
+ variant?: "primary" | "secondary" | "tertiary" | "destructive" | "success";
20
+ /** Size, shared by both targets. Default: "md". */
21
+ size?: "sm" | "md" | "lg";
22
+ /** Optional Material Symbols icon on the main button. */
23
+ icon?: string;
24
+ /** Icon position on the main button. Default: "start". */
25
+ iconPosition?: "start" | "end";
26
+ /** Show a spinner on the main button and make both targets inert. Default: false. */
27
+ loading?: boolean;
28
+ /** Disable both targets. Default: false. */
29
+ disabled?: boolean;
30
+ /** Secondary actions shown in the dropdown menu. */
31
+ actions?: SplitButtonAction[];
32
+ /** Accessible name for the menu. Default: "More actions". */
33
+ menuLabel?: string;
34
+ /** Accessible name for the caret toggle. Default: "More actions". */
35
+ toggleLabel?: string;
36
+ className?: string;
37
+ }
38
+
39
+ export declare function SplitButton(props: SplitButtonProps): React.ReactElement;
@@ -0,0 +1,94 @@
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
+ const rootRef = useRef(null);
36
+
37
+ const resolvedVariant = variants.includes(variant) ? variant : "primary";
38
+ const resolvedSize = sizes.includes(size) ? size : "md";
39
+ const isInert = disabled || loading;
40
+
41
+ // The toggle reuses the Button class layer (variant/size design tokens) on a
42
+ // raw <button> so it can be ref'd as the menu anchor (Button doesn't forward refs).
43
+ const toggleClasses = [
44
+ "a1-button",
45
+ `a1-button--${resolvedVariant}`,
46
+ resolvedSize !== "md" && `a1-button--${resolvedSize}`,
47
+ "a1-split-button__toggle",
48
+ ].filter(Boolean).join(" ");
49
+
50
+ return (
51
+ <div
52
+ ref={rootRef}
53
+ className={["a1-split-button", isInert && "a1-split-button--disabled", className].filter(Boolean).join(" ")}
54
+ {...rest}
55
+ >
56
+ <Button
57
+ className="a1-split-button__main"
58
+ variant={resolvedVariant}
59
+ size={resolvedSize}
60
+ icon={icon}
61
+ iconPosition={iconPosition}
62
+ loading={loading}
63
+ disabled={disabled}
64
+ onClick={onClick}
65
+ >
66
+ {children}
67
+ </Button>
68
+ <button
69
+ ref={toggleRef}
70
+ type="button"
71
+ className={toggleClasses}
72
+ aria-label={toggleLabel}
73
+ aria-haspopup="menu"
74
+ aria-expanded={open}
75
+ disabled={isInert}
76
+ onClick={() => setOpen((current) => !current)}
77
+ >
78
+ <Icon name="arrow_drop_down" className="a1-split-button__caret" aria-hidden="true" />
79
+ </button>
80
+ <Menu open={open} onClose={() => setOpen(false)} anchorRef={rootRef} aria-label={menuLabel}>
81
+ {actions.map((action) => (
82
+ <MenuItem
83
+ key={action.id}
84
+ icon={action.icon}
85
+ disabled={action.disabled}
86
+ onClick={() => { action.onClick?.(); setOpen(false); }}
87
+ >
88
+ {action.label}
89
+ </MenuItem>
90
+ ))}
91
+ </Menu>
92
+ </div>
93
+ );
94
+ }
@@ -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
+ }
@@ -64,6 +64,9 @@
64
64
  .a1-tab-list--scrollable {
65
65
  flex: 1;
66
66
  overflow-x: auto;
67
+ /* Setting overflow-x alone makes the browser compute overflow-y to `auto`,
68
+ which lets a sub-pixel of height add a stray vertical scroll. Pin it. */
69
+ overflow-y: hidden;
67
70
  scrollbar-width: none;
68
71
  }
69
72