@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
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
--a1-section-border-size: 0;
|
|
14
14
|
--a1-section-border-style: solid;
|
|
15
15
|
--a1-section-border-color: transparent;
|
|
16
|
-
|
|
16
|
+
/* Per-side widths default to the full border size (all sides). The sided
|
|
17
|
+
modifier zeroes them so individual sides can be opted back in. */
|
|
18
|
+
--a1-section-border-top: var(--a1-section-border-size);
|
|
19
|
+
--a1-section-border-right: var(--a1-section-border-size);
|
|
20
|
+
--a1-section-border-bottom: var(--a1-section-border-size);
|
|
21
|
+
--a1-section-border-left: var(--a1-section-border-size);
|
|
22
|
+
border-style: var(--a1-section-border-style);
|
|
23
|
+
border-color: var(--a1-section-border-color);
|
|
24
|
+
border-width: var(--a1-section-border-top) var(--a1-section-border-right) var(--a1-section-border-bottom) var(--a1-section-border-left);
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
.a1-section.a1-inverse {
|
|
@@ -42,6 +50,18 @@
|
|
|
42
50
|
.a1-section--border-strong { --a1-section-border-color: var(--semantic-color-border-strong); }
|
|
43
51
|
.a1-section--border-accent { --a1-section-border-color: var(--semantic-color-text-accent); }
|
|
44
52
|
|
|
53
|
+
/* Per-side borders: start from no sides, then opt each chosen side back in. */
|
|
54
|
+
.a1-section--border-sided {
|
|
55
|
+
--a1-section-border-top: 0;
|
|
56
|
+
--a1-section-border-right: 0;
|
|
57
|
+
--a1-section-border-bottom: 0;
|
|
58
|
+
--a1-section-border-left: 0;
|
|
59
|
+
}
|
|
60
|
+
.a1-section--border-side-top { --a1-section-border-top: var(--a1-section-border-size); }
|
|
61
|
+
.a1-section--border-side-right { --a1-section-border-right: var(--a1-section-border-size); }
|
|
62
|
+
.a1-section--border-side-bottom { --a1-section-border-bottom: var(--a1-section-border-size); }
|
|
63
|
+
.a1-section--border-side-left { --a1-section-border-left: var(--a1-section-border-size); }
|
|
64
|
+
|
|
45
65
|
/* ── Radius ────────────────────────────────────────────────────────────────── */
|
|
46
66
|
|
|
47
67
|
.a1-section--radius-none { border-radius: 0; }
|
|
@@ -92,21 +112,24 @@
|
|
|
92
112
|
|
|
93
113
|
/* ── Content gap ──────────────────────────────────────────────────────────── */
|
|
94
114
|
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
|
|
101
|
-
justify-items: var(--a1-section-justify-items);
|
|
102
|
-
}
|
|
103
|
-
|
|
115
|
+
/* A gap is the grid gap only when the section is a grid — i.e. when it is
|
|
116
|
+
aligned (or height-hero). Without alignment the section flows as block
|
|
117
|
+
(block children fill the width; inline content keeps its natural size) and
|
|
118
|
+
the gap becomes vertical rhythm via margins, since `gap` has no effect on
|
|
119
|
+
display:block. This keeps natural-width content (a Button, a Badge) from
|
|
120
|
+
being stretched by the grid when no alignment is requested. */
|
|
104
121
|
.a1-section--gap-xs { gap: var(--semantic-spacing-gap-xs); }
|
|
105
122
|
.a1-section--gap-sm { gap: var(--semantic-spacing-gap-sm); }
|
|
106
123
|
.a1-section--gap-md { gap: var(--semantic-spacing-gap-md); }
|
|
107
124
|
.a1-section--gap-lg { gap: var(--semantic-spacing-gap-lg); }
|
|
108
125
|
.a1-section--gap-xl { gap: var(--semantic-spacing-gap-xl); }
|
|
109
126
|
|
|
127
|
+
.a1-section--gap-xs:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-xs); }
|
|
128
|
+
.a1-section--gap-sm:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-sm); }
|
|
129
|
+
.a1-section--gap-md:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-md); }
|
|
130
|
+
.a1-section--gap-lg:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-lg); }
|
|
131
|
+
.a1-section--gap-xl:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-xl); }
|
|
132
|
+
|
|
110
133
|
/* ── Height ────────────────────────────────────────────────────────────────── */
|
|
111
134
|
|
|
112
135
|
.a1-section--height-screen {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A detent (snap stop): a bare value, or a value with an optional display `label`
|
|
5
|
+
* and/or `icon` (a Material Symbols name shown in the label row instead of text).
|
|
6
|
+
* Provide a `label` alongside an `icon` to give screen readers a text alternative.
|
|
7
|
+
*/
|
|
8
|
+
export type SliderDetent = number | { value: number; label?: React.ReactNode; icon?: string };
|
|
9
|
+
|
|
10
|
+
export interface SliderProps
|
|
11
|
+
extends Omit<
|
|
12
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
13
|
+
"value" | "defaultValue" | "onChange" | "min" | "max" | "step"
|
|
14
|
+
> {
|
|
15
|
+
/** Controlled value (in the value domain — a detent value when `detents` is set). */
|
|
16
|
+
value?: number;
|
|
17
|
+
/** Uncontrolled initial value. Defaults to the first detent, or `min`. */
|
|
18
|
+
defaultValue?: number;
|
|
19
|
+
/** Called with the new value on every change. */
|
|
20
|
+
onChange?: (value: number) => void;
|
|
21
|
+
/** Minimum (continuous mode). Default: 0 */
|
|
22
|
+
min?: number;
|
|
23
|
+
/** Maximum (continuous mode). Default: 100 */
|
|
24
|
+
max?: number;
|
|
25
|
+
/** Step granularity (continuous mode). Default: 1 */
|
|
26
|
+
step?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Optional snap stops. Pass numbers, or `{ value, label }` to show labels under
|
|
29
|
+
* the track (e.g. `[{value:0,label:'None'},{value:1,label:'XS'},…]`). The thumb
|
|
30
|
+
* snaps between detents and the keyboard moves one detent at a time.
|
|
31
|
+
*/
|
|
32
|
+
detents?: SliderDetent[];
|
|
33
|
+
/**
|
|
34
|
+
* Visible field label rendered above the control and associated with it (also
|
|
35
|
+
* the accessible name). Sized to match the field family per `size`. Use
|
|
36
|
+
* `aria-label` / `aria-labelledby` instead for an invisible name.
|
|
37
|
+
*/
|
|
38
|
+
label?: React.ReactNode;
|
|
39
|
+
/**
|
|
40
|
+
* Density, matching the field family. Default: "default". Scales the label,
|
|
41
|
+
* detent labels, track, and thumb so a Slider sits naturally beside fields.
|
|
42
|
+
*/
|
|
43
|
+
size?: "compact" | "default" | "comfortable";
|
|
44
|
+
/**
|
|
45
|
+
* Selection colour. "default" uses the action colour; "subtle" uses neutrals
|
|
46
|
+
* for the fill, thumb, and active detent. Default: "default"
|
|
47
|
+
*/
|
|
48
|
+
variant?: "default" | "subtle";
|
|
49
|
+
/** Show the floating value bubble while dragging/focused. Default: true */
|
|
50
|
+
showValue?: boolean;
|
|
51
|
+
/** Preferred bubble side; it flips to stay in the viewport. Default: "above" */
|
|
52
|
+
valuePosition?: "above" | "below";
|
|
53
|
+
/** Format the bubble + `aria-valuetext`. Defaults to the detent label or the number. */
|
|
54
|
+
formatValue?: (value: number) => React.ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* An alternate label shown in the value bubble (visual only — `aria-valuetext`
|
|
57
|
+
* is unchanged). A node is used as-is; a function receives the current value
|
|
58
|
+
* and the active detent. When omitted, the bubble keeps its current content
|
|
59
|
+
* (the formatted value, detent label/icon, or the raw value). Useful for a
|
|
60
|
+
* longer spoken-out size name (e.g. "Small") while the detent stays "SM".
|
|
61
|
+
*/
|
|
62
|
+
bubbleLabel?: React.ReactNode | ((value: number, detent: { value: number; label?: React.ReactNode; icon?: string } | null) => React.ReactNode);
|
|
63
|
+
/** Disable the slider. Default: false */
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
/** Form field name. */
|
|
66
|
+
name?: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
className?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export declare function Slider(props: SliderProps): React.ReactElement;
|
|
@@ -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,230 @@
|
|
|
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
|
+
--a1-slider-track-height: var(--base-spacing-6);
|
|
29
|
+
--a1-slider-thumb-size: var(--base-spacing-16);
|
|
30
|
+
--a1-slider-label-size: var(--semantic-font-size-form-label-compact);
|
|
31
|
+
--a1-slider-label-weight: var(--component-field-compact-label-font-weight);
|
|
32
|
+
--a1-slider-detent-size: var(--semantic-font-size-body-xs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.a1-slider--comfortable {
|
|
36
|
+
--a1-slider-track-height: var(--base-spacing-12);
|
|
37
|
+
--a1-slider-thumb-size: var(--base-spacing-24);
|
|
38
|
+
--a1-slider-label-size: var(--semantic-font-size-form-label-comfortable);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Subtle variant — neutral fill/thumb/active instead of the action colour. */
|
|
42
|
+
.a1-slider--subtle {
|
|
43
|
+
--a1-slider-fill-color: var(--semantic-color-text-default);
|
|
44
|
+
--a1-slider-thumb-color: var(--semantic-color-text-default);
|
|
45
|
+
--a1-slider-active-color: var(--semantic-color-text-default);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ── Field label ─────────────────────────────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
.a1-slider__field-label {
|
|
51
|
+
font-family: var(--component-paragraph-font-family);
|
|
52
|
+
font-size: var(--a1-slider-label-size);
|
|
53
|
+
font-weight: var(--a1-slider-label-weight);
|
|
54
|
+
color: var(--semantic-color-text-default);
|
|
55
|
+
line-height: var(--semantic-font-line-height-body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── Control (track + thumb + bubble + ticks) ──────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
.a1-slider__control {
|
|
61
|
+
position: relative;
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
block-size: var(--a1-slider-thumb-size);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.a1-slider__input {
|
|
68
|
+
-webkit-appearance: none;
|
|
69
|
+
appearance: none;
|
|
70
|
+
margin: 0;
|
|
71
|
+
inline-size: 100%;
|
|
72
|
+
block-size: var(--a1-slider-thumb-size);
|
|
73
|
+
background: transparent;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.a1-slider__input:focus-visible {
|
|
78
|
+
outline: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Track — WebKit/Blink. Fill is drawn as a gradient up to --a1-slider-pct. */
|
|
82
|
+
.a1-slider__input::-webkit-slider-runnable-track {
|
|
83
|
+
block-size: var(--a1-slider-track-height);
|
|
84
|
+
border-radius: var(--base-radius-pill);
|
|
85
|
+
background: linear-gradient(
|
|
86
|
+
to right,
|
|
87
|
+
var(--a1-slider-fill-color) 0%,
|
|
88
|
+
var(--a1-slider-fill-color) calc(var(--a1-slider-pct) * 100%),
|
|
89
|
+
var(--a1-slider-track-color) calc(var(--a1-slider-pct) * 100%),
|
|
90
|
+
var(--a1-slider-track-color) 100%
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Track — Firefox. */
|
|
95
|
+
.a1-slider__input::-moz-range-track {
|
|
96
|
+
block-size: var(--a1-slider-track-height);
|
|
97
|
+
border-radius: var(--base-radius-pill);
|
|
98
|
+
background: var(--a1-slider-track-color);
|
|
99
|
+
}
|
|
100
|
+
.a1-slider__input::-moz-range-progress {
|
|
101
|
+
block-size: var(--a1-slider-track-height);
|
|
102
|
+
border-radius: var(--base-radius-pill);
|
|
103
|
+
background: var(--a1-slider-fill-color);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Thumb — WebKit/Blink. margin-top centers it on the thinner track. */
|
|
107
|
+
.a1-slider__input::-webkit-slider-thumb {
|
|
108
|
+
-webkit-appearance: none;
|
|
109
|
+
appearance: none;
|
|
110
|
+
inline-size: var(--a1-slider-thumb-size);
|
|
111
|
+
block-size: var(--a1-slider-thumb-size);
|
|
112
|
+
margin-block-start: calc((var(--a1-slider-track-height) - var(--a1-slider-thumb-size)) / 2);
|
|
113
|
+
border-radius: var(--base-radius-pill);
|
|
114
|
+
background: var(--a1-slider-thumb-color);
|
|
115
|
+
border: var(--base-spacing-2) solid var(--a1-slider-thumb-ring);
|
|
116
|
+
box-shadow: var(--component-switch-thumb-shadow);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Thumb — Firefox. */
|
|
120
|
+
.a1-slider__input::-moz-range-thumb {
|
|
121
|
+
inline-size: var(--a1-slider-thumb-size);
|
|
122
|
+
block-size: var(--a1-slider-thumb-size);
|
|
123
|
+
border-radius: var(--base-radius-pill);
|
|
124
|
+
background: var(--a1-slider-thumb-color);
|
|
125
|
+
border: var(--base-spacing-2) solid var(--a1-slider-thumb-ring);
|
|
126
|
+
box-shadow: var(--component-switch-thumb-shadow);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Focus ring on the thumb (base shadow + accent ring). */
|
|
130
|
+
.a1-slider__input:focus-visible::-webkit-slider-thumb {
|
|
131
|
+
box-shadow: var(--component-switch-thumb-shadow), 0 0 0 var(--component-button-focus-ring-width) var(--component-field-focus-ring-color);
|
|
132
|
+
}
|
|
133
|
+
.a1-slider__input:focus-visible::-moz-range-thumb {
|
|
134
|
+
box-shadow: var(--component-switch-thumb-shadow), 0 0 0 var(--component-button-focus-ring-width) var(--component-field-focus-ring-color);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── Detent ticks ──────────────────────────────────────────────────────────── */
|
|
138
|
+
|
|
139
|
+
.a1-slider__ticks {
|
|
140
|
+
position: absolute;
|
|
141
|
+
inset-inline: 0;
|
|
142
|
+
inset-block-start: 50%;
|
|
143
|
+
block-size: 0;
|
|
144
|
+
pointer-events: none;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.a1-slider__tick {
|
|
148
|
+
position: absolute;
|
|
149
|
+
inline-size: var(--base-spacing-4);
|
|
150
|
+
block-size: var(--base-spacing-4);
|
|
151
|
+
border-radius: var(--base-radius-pill);
|
|
152
|
+
background: var(--semantic-color-border-default);
|
|
153
|
+
transform: translate(-50%, -50%);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.a1-slider__tick[data-active] {
|
|
157
|
+
background: var(--semantic-color-surface-page);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ── Value bubble ──────────────────────────────────────────────────────────── */
|
|
161
|
+
|
|
162
|
+
.a1-slider__bubble {
|
|
163
|
+
position: absolute;
|
|
164
|
+
left: calc(var(--a1-slider-thumb-size) / 2 + var(--a1-slider-pct) * (100% - var(--a1-slider-thumb-size)));
|
|
165
|
+
/* Anchor by progress so the bubble never spills past the control: left-aligned
|
|
166
|
+
at the start (0%), centered mid-track (-50%), right-aligned at the end
|
|
167
|
+
(-100%) — mirrors the detent-label end alignment. */
|
|
168
|
+
transform: translateX(calc(var(--a1-slider-pct) * -100%));
|
|
169
|
+
padding-block: var(--base-spacing-2);
|
|
170
|
+
padding-inline: var(--base-spacing-8);
|
|
171
|
+
border-radius: var(--base-radius-md);
|
|
172
|
+
background: var(--semantic-color-surface-inverse);
|
|
173
|
+
color: var(--semantic-color-text-inverse);
|
|
174
|
+
font-family: var(--component-paragraph-font-family);
|
|
175
|
+
font-size: var(--semantic-font-size-body-sm);
|
|
176
|
+
line-height: var(--semantic-font-line-height-body);
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
pointer-events: none;
|
|
179
|
+
z-index: 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.a1-slider__bubble--above { inset-block-end: calc(100% + var(--base-spacing-4)); }
|
|
183
|
+
.a1-slider__bubble--below { inset-block-start: calc(100% + var(--base-spacing-4)); }
|
|
184
|
+
|
|
185
|
+
/* Little caret pointing at the thumb. As the bubble is anchored by progress, the
|
|
186
|
+
caret tracks the thumb position within the bubble (0% → 100%). */
|
|
187
|
+
.a1-slider__bubble::after {
|
|
188
|
+
content: "";
|
|
189
|
+
position: absolute;
|
|
190
|
+
inset-inline-start: calc(var(--a1-slider-pct) * 100%);
|
|
191
|
+
inline-size: var(--base-spacing-8);
|
|
192
|
+
block-size: var(--base-spacing-8);
|
|
193
|
+
background: var(--semantic-color-surface-inverse);
|
|
194
|
+
transform: translateX(-50%) rotate(45deg);
|
|
195
|
+
}
|
|
196
|
+
.a1-slider__bubble--above::after { inset-block-start: calc(100% - var(--base-spacing-4)); }
|
|
197
|
+
.a1-slider__bubble--below::after { inset-block-end: calc(100% - var(--base-spacing-4)); }
|
|
198
|
+
|
|
199
|
+
/* ── Detent labels ─────────────────────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
.a1-slider__labels {
|
|
202
|
+
position: relative;
|
|
203
|
+
block-size: var(--a1-slider-detent-size);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.a1-slider__label {
|
|
207
|
+
position: absolute;
|
|
208
|
+
inset-block-start: 0;
|
|
209
|
+
font-family: var(--component-paragraph-font-family);
|
|
210
|
+
font-size: var(--a1-slider-detent-size);
|
|
211
|
+
line-height: 1;
|
|
212
|
+
color: var(--semantic-color-text-muted);
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.a1-slider__label[data-active] {
|
|
217
|
+
color: var(--a1-slider-active-color);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── Disabled ──────────────────────────────────────────────────────────────── */
|
|
221
|
+
|
|
222
|
+
.a1-slider--disabled {
|
|
223
|
+
--a1-slider-fill-color: var(--semantic-color-border-default);
|
|
224
|
+
--a1-slider-thumb-color: var(--semantic-color-border-default);
|
|
225
|
+
opacity: 0.6;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.a1-slider--disabled .a1-slider__input {
|
|
229
|
+
cursor: not-allowed;
|
|
230
|
+
}
|
|
@@ -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;
|