@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.1

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,49 @@
1
+ /**
2
+ * useReducedMotion: does this viewer want motion suppressed?
3
+ *
4
+ * WHY A HOOK AND NOT A MEDIA QUERY. The obvious fix for
5
+ * `prefers-reduced-motion` is a stylesheet rule, and that is exactly what
6
+ * `widget.jsx` does, correctly, because its transitions live in a `<style>`
7
+ * block on a class. Every other component in this family sets `transition`
8
+ * as an INLINE STYLE, and an inline style outranks any stylesheet rule short
9
+ * of `!important`. So a media query cannot reach them, and on 2026-10-13 the
10
+ * Accessibility Gate found Dropdown, Action Icon and Dashboard animating at
11
+ * 150ms for a viewer who had asked for no animation. The value has to be
12
+ * decided in JS because that is where the value is written.
13
+ *
14
+ * Read synchronously in the initialiser rather than in an effect: an effect
15
+ * runs after the first paint, so a reduced-motion viewer would see one frame
16
+ * of the thing they opted out of. The listener is still there for a viewer who
17
+ * changes the setting while the page is open.
18
+ *
19
+ * The `typeof window` guard is for a server render and for the jsdom pass in
20
+ * `check-stories-render.js`, where `matchMedia` may not exist.
21
+ */
22
+ import { useEffect, useState } from "react";
23
+
24
+ const QUERY = "(prefers-reduced-motion: reduce)";
25
+
26
+ const current = () =>
27
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
28
+ ? window.matchMedia(QUERY).matches
29
+ : false;
30
+
31
+ export function useReducedMotion() {
32
+ const [reduced, setReduced] = useState(current);
33
+
34
+ useEffect(() => {
35
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
36
+ const mq = window.matchMedia(QUERY);
37
+ setReduced(mq.matches);
38
+ const onChange = (e) => setReduced(e.matches);
39
+ mq.addEventListener("change", onChange);
40
+ return () => mq.removeEventListener("change", onChange);
41
+ }, []);
42
+
43
+ return reduced;
44
+ }
45
+
46
+ /** `motion(reduced, "background 150ms ease")` → that string, or "none". */
47
+ export function motionOr(reduced, transition) {
48
+ return reduced ? "none" : transition;
49
+ }
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Button, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Button
5
+ * implementation; the standalone demo (button.html) and the Storybook
6
+ * stories both consume this.
7
+ *
8
+ * Spec: components/button/button-spec.md
9
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40003293-93741
10
+ */
11
+
12
+ import React, { useState, useEffect } from "react";
13
+
14
+ // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
15
+ // Every name below is MODELESS. The mode is resolved by selector from
16
+ // themes/light.css and themes/midnight.css, so there is no "light-mode" segment
17
+ // and no separate *inverse* token family: an inverted button is this same
18
+ // button inside a [data-theme="midnight"] region. See CLAUDE.md §2.0.
19
+ //
20
+ // SC = semantic-color (themes/light.css + themes/midnight.css)
21
+ // SL = semantic-layout-units (layout.css)
22
+ // CL = contextual-layout-units(layout-contextual.css, component metrics)
23
+ // ST = semantic-type (type.css: a SCALE, composed below, not composites)
24
+ const SC = (p) => `var(--semantic-color-${p})`;
25
+ const SL = (p) => `var(--semantic-layout-units-${p})`;
26
+ const CL = (p) => `var(--contextual-layout-units-${p})`;
27
+ const ST = (p) => `var(--semantic-type-${p})`;
28
+
29
+ // ─── FILL TOKENS ──────────────────────────────────────────────────────────────
30
+ // Background colour by (buttonStyle, type, interactionState).
31
+ // Outlined/Naked use transparent base + light overlay on hover/pressed.
32
+ // Tertiary's own token family was deleted because it duplicated the quiet
33
+ // primary ramp offset by one step, so Tertiary now reads
34
+ // Fill/Action/Primary/Subtle. That group was called "Primary Dim" until
35
+ // 2026-09-15, when the space-joined suffix was promoted to a real segment
36
+ // because "Primary Dim" read as a tone name rather than a variant of Primary.
37
+ // Negative moved under Status. Disabled is ONE token per tier, not one per type: a
38
+ // disabled control is the same colour whatever it would have been.
39
+ export const FILL = {
40
+ Fill: {
41
+ Primary: { base: SC("fill-action-primary-strong-rest"), hover: SC("fill-action-primary-strong-hover"), pressed: SC("fill-action-primary-strong-pressed"), disabled: SC("fill-action-disabled") },
42
+ Secondary: { base: SC("fill-action-secondary-rest"), hover: SC("fill-action-secondary-hover"), pressed: SC("fill-action-secondary-pressed"), disabled: SC("fill-action-disabled") },
43
+ Tertiary: { base: SC("fill-action-primary-subtle-rest"), hover: SC("fill-action-primary-subtle-hover"), pressed: SC("fill-action-primary-subtle-pressed"), disabled: SC("fill-action-disabled") },
44
+ Negative: { base: SC("fill-action-status-negative-strong-rest"), hover: SC("fill-action-status-negative-strong-hover"), pressed: SC("fill-action-status-negative-strong-pressed"), disabled: SC("fill-action-disabled") },
45
+ },
46
+ // Outlined and Naked rest on nothing and tint on interaction. The Dim ramps
47
+ // are exactly that tint, which is what the retired *inverse* hovers were for.
48
+ Outlined: {
49
+ Primary: { base: "transparent", hover: SC("fill-action-primary-subtle-hover"), pressed: SC("fill-action-primary-subtle-pressed"), disabled: "transparent" },
50
+ Secondary: { base: "transparent", hover: SC("fill-action-secondary-hover"), pressed: SC("fill-action-secondary-pressed"), disabled: "transparent" },
51
+ Tertiary: { base: "transparent", hover: SC("fill-action-primary-subtle-hover"), pressed: SC("fill-action-primary-subtle-pressed"), disabled: "transparent" },
52
+ Negative: { base: "transparent", hover: SC("fill-action-status-negative-subtle-hover"), pressed: SC("fill-action-status-negative-subtle-pressed"), disabled: "transparent" },
53
+ },
54
+ };
55
+ FILL.Naked = FILL.Outlined;
56
+
57
+ // ─── FOREGROUND TOKENS ────────────────────────────────────────────────────────
58
+ // Text and Icon were merged into one Foreground tier. That merge is deliberate,
59
+ // not a rename for tidiness: a button is ONE interactive surface, so its label
60
+ // and its icon must not resolve through two ramps that can drift apart.
61
+ //
62
+ // On a solid fill the foreground is Mono, which has only a rest step, white
63
+ // stays white through hover and pressed, so all three states share it.
64
+ export const FG = {
65
+ Fill: {
66
+ // READ OFF FIGMA 2026-10-02, node 40003293:93741. Every Fill/Primary variant
67
+ // binds the paint style Foreground/Action/Primary/On Strong on the label AND
68
+ // on both icon vectors, in Active, Hover, Focused and Pressed alike, which is
69
+ // why there is one token here rather than three.
70
+ //
71
+ // This said foreground-static-neutral-MONO, and had since before the
72
+ // September tier rename carried it forward unexamined. Mono is an inversion
73
+ // anchor, not a rung: #ffffff in Light but #181b2b in Midnight. So a primary
74
+ // button's label went near-black on a brand fill in Midnight, 3.04:1, under
75
+ // the 4.5 floor. It survived because both tokens are #ffffff in Light, so
76
+ // every screenshot looked right, and because the token gates check that a
77
+ // name RESOLVES, never that it is the right name.
78
+ Primary: { base: SC("foreground-action-primary-on-strong"), hover: SC("foreground-action-primary-on-strong"), pressed: SC("foreground-action-primary-on-strong"), disabled: SC("foreground-action-disabled") },
79
+ Secondary: { base: SC("foreground-action-secondary-rest"), hover: SC("foreground-action-secondary-hover"), pressed: SC("foreground-action-secondary-pressed"), disabled: SC("foreground-action-disabled") },
80
+ Tertiary: { base: SC("foreground-action-primary-on-subtle-rest"), hover: SC("foreground-action-primary-on-subtle-hover"), pressed: SC("foreground-action-primary-on-subtle-pressed"), disabled: SC("foreground-action-disabled") },
81
+ // Same correction, same reason. Figma binds
82
+ // Foreground/Action/Status/Negative/On Strong here. Mono measured 2.85:1 on
83
+ // the negative fill in Midnight, which fails even the 3:1 non-text floor.
84
+ Negative: { base: SC("foreground-action-status-negative-on-strong"), hover: SC("foreground-action-status-negative-on-strong"), pressed: SC("foreground-action-status-negative-on-strong"), disabled: SC("foreground-action-disabled") },
85
+ },
86
+ Outlined: {
87
+ Primary: { base: SC("foreground-action-primary-on-subtle-rest"), hover: SC("foreground-action-primary-on-subtle-hover"), pressed: SC("foreground-action-primary-on-subtle-pressed"), disabled: SC("foreground-action-disabled") },
88
+ Secondary: { base: SC("foreground-action-secondary-rest"), hover: SC("foreground-action-secondary-hover"), pressed: SC("foreground-action-secondary-pressed"), disabled: SC("foreground-action-disabled") },
89
+ Tertiary: { base: SC("foreground-action-primary-on-subtle-rest"), hover: SC("foreground-action-primary-on-subtle-hover"), pressed: SC("foreground-action-primary-on-subtle-pressed"), disabled: SC("foreground-action-disabled") },
90
+ Negative: { base: SC("foreground-action-status-negative-on-subtle-rest"), hover: SC("foreground-action-status-negative-on-subtle-hover"), pressed: SC("foreground-action-status-negative-on-subtle-pressed"), disabled: SC("foreground-action-disabled") },
91
+ },
92
+ };
93
+ FG.Naked = FG.Outlined;
94
+
95
+ // Label and icon read the same tier now, so these are the same table. They stay
96
+ // as two names because the render code addresses them separately.
97
+ const TEXT = FG;
98
+ const ICON = FG;
99
+
100
+ // ─── STROKE TOKENS (Outlined only) ────────────────────────────────────────────
101
+ // Fill/Action/Primary/Subtle has no matching STROKE ramp, so Tertiary borrows
102
+ // the Primary stroke. That group was called "Primary Dim" before 2026-09-15.
103
+ export const STROKE = {
104
+ Primary: { base: SC("stroke-action-primary-strong-rest"), hover: SC("stroke-action-primary-strong-hover"), pressed: SC("stroke-action-primary-strong-pressed"), disabled: SC("stroke-action-disabled") },
105
+ Secondary: { base: SC("stroke-action-secondary-rest"), hover: SC("stroke-action-secondary-hover"), pressed: SC("stroke-action-secondary-pressed"), disabled: SC("stroke-action-disabled") },
106
+ Tertiary: { base: SC("stroke-action-primary-strong-rest"), hover: SC("stroke-action-primary-strong-hover"), pressed: SC("stroke-action-primary-strong-pressed"), disabled: SC("stroke-action-disabled") },
107
+ Negative: { base: SC("stroke-action-status-negative-rest"), hover: SC("stroke-action-status-negative-hover"), pressed: SC("stroke-action-status-negative-pressed"), disabled: SC("stroke-action-disabled") },
108
+ };
109
+
110
+ // ─── LAYOUT & FOCUS TOKENS ────────────────────────────────────────────────────
111
+ // Touch target: 48×48 minimum with 6px outer padding (WCAG 2.5.5 target size).
112
+ // Focus ring: 6px white halo + 9px brand ring (box-shadow on Container.Main).
113
+ // Figma spec: DROP_SHADOW spread:9 color:#a0b5e6 + spread:6 color:#ffffff
114
+ // Component metrics live in layout-contextual.css under their OWN prefix,
115
+ // --contextual-layout-units-*, not as a "contextual" branch of the semantic
116
+ // layout scale. The focus ring moved out of Contextual to Stroke/FocusRing.
117
+ export const T = {
118
+ // button-radius was deleted 2026-09-17: it duplicated Button/Corner Radius/Radius
119
+ // at the same 8px and carried ZERO Figma bindings, while the other carries 2,380.
120
+ // The dead one was the one this file referenced.
121
+ radius: CL("button-cornerradius"), // 8px
122
+ border: CL("button-borderwidth-rest"), // 1px
123
+ // Outlined buttons thicken their border on hover, focus and pressed. Figma
124
+ // applies Button/Border Width/Selected (1.5px) to 48 distinct Outlined variants
125
+ // across exactly those three states, and this component drew 1px in all of
126
+ // them, so every outlined hover was 0.5px thinner than the design.
127
+ //
128
+ // The token name says "Selected" and its usage says hover/focus/pressed. There
129
+ // is no selected or toggled state on this component. Named here for what it
130
+ // does; the token name is flagged in the spec as a mismatch to settle.
131
+ borderEmphasis: CL("button-borderwidth-selected"), // 1.5px
132
+ gap: CL("button-gap-horizontal"), // 8px
133
+ touch: { pad: 6, min: 48 },
134
+ // Focus ring drawn as outline + outline-offset rather than stacked shadows.
135
+ //
136
+ // Three reasons this changed on 2026-09-14:
137
+ // 1. It hardcoded #ffffff for the gap, so in Midnight Mode the ring sat in a
138
+ // white halo on a dark surface.
139
+ // 2. The 6px gap + 3px ring ignored Focus Ring/Width and Focus Ring/Offset,
140
+ // which existed and said 2px and 2px. Three tokens, zero references.
141
+ // 3. `outline` follows the element's own border-radius automatically, so the
142
+ // focus shape can never disagree with the button's corner. A shadow-based
143
+ // ring needs its own radius token, which is one more thing to keep in
144
+ // step and was already wrong.
145
+ //
146
+ // The offset is not decoration. Stroke/FocusRing/Base measures 3.19:1 against
147
+ // the canvas but only 1.69:1 against the Primary fill, so a ring drawn on the
148
+ // fill would fail WCAG 2.4.11. The offset puts it on the surface instead.
149
+ focusOutline: `${CL("focusring-width")} solid ${SC("stroke-focusring-base")}`,
150
+ focusOffset: CL("focusring-offset"),
151
+ };
152
+
153
+ // Type is a scale now, not 111 composites, so each size composes its own five
154
+ // values. The px in each comment is what the retired Label/Button composite
155
+ // resolved to, kept so a drift here is obvious.
156
+ const LABEL = (size, lineHeight, tracking) => ({
157
+ fontFamily: ST("family-brand"),
158
+ fontSize: ST(`font-size-${size}`),
159
+ fontWeight: ST("weight-medium"),
160
+ lineHeight: ST(`line-height-${lineHeight}`),
161
+ // Tracking is NOT uniform across the sizes and the helper used to pretend it
162
+ // was. Figma's Label/Button styles read, per size: Large 0.1, Base 0.1,
163
+ // Small 0.3, XSmall 0.6, tracking OPENS UP as the type gets smaller, which is
164
+ // what keeps a 12px label legible. It is passed in per size for that reason.
165
+ //
166
+ // It previously asked the SEMANTIC scale for a "compact" step, which does not
167
+ // exist there: that step is a primitive only. The reference resolved to
168
+ // nothing, the browser fell back to `normal`, and every button rendered at 0
169
+ // tracking against Figma's 0.1 to 0.6. Naming a token that does not exist
170
+ // fails silently, which is why it went unnoticed.
171
+ letterSpacing: ST(`letter-spacing-${tracking}`),
172
+ });
173
+
174
+ // ─── SIZE TABLE ────────────────────────────────────────────────────────────────
175
+ export const SIZES = {
176
+ L: {
177
+ padH: CL("button-padding-large-horizontal"),
178
+ padV: CL("button-padding-large-vertical"),
179
+ iconWrap: 26,
180
+ iconInner: 18,
181
+ ...LABEL("m", "m-single", "wide"), // was Label/Button/Large, 18 / 24
182
+ },
183
+ M: {
184
+ padH: CL("button-padding-medium-horizontal"),
185
+ padV: CL("button-padding-medium-vertical"),
186
+ iconWrap: 24,
187
+ iconInner: 16,
188
+ ...LABEL("r", "r-single", "wide"), // was Label/Button/Base, 16 / 22
189
+ },
190
+ S: {
191
+ padH: CL("button-padding-small-horizontal"),
192
+ padV: CL("button-padding-small-vertical"),
193
+ iconWrap: 20,
194
+ iconInner: 14,
195
+ ...LABEL("s", "s-single", "spacious"), // was Label/Button/Small, 14 / 20
196
+ },
197
+ // XS, added 2026-06-11 (Figma node 40007881:21300). 44×44 touch target preserved;
198
+ // padding 8/6. Figma defines XS for Primary/Secondary/Negative across
199
+ // Fill/Outlined/Naked: NOT Tertiary (see spec §Gaps).
200
+ XS: {
201
+ padH: CL("button-padding-xsmall-horizontal"),
202
+ padV: CL("button-padding-xsmall-vertical"),
203
+ iconWrap: 16,
204
+ iconInner: 12,
205
+ ...LABEL("xs", "xs-single", "extraspacious"), // was Label/Button/XSmall, 12 / 18
206
+ },
207
+ };
208
+
209
+ // ─── SPINNER SPOKE DATA (from Pathway Spinner component) ───────────────────────
210
+ const SPINNER_SPOKES = [
211
+ { d: "M6 1V3", opacity: 1.00 },
212
+ { d: "M8.1 3.9L9.55 2.45", opacity: 0.87 },
213
+ { d: "M9 6H11", opacity: 0.75 },
214
+ { d: "M8.1 8.1L9.55 9.55", opacity: 0.62 },
215
+ { d: "M6 9V11", opacity: 0.50 },
216
+ { d: "M2.45 9.55L3.9 8.1", opacity: 0.37 },
217
+ { d: "M1 6H3", opacity: 0.25 },
218
+ { d: "M2.45 2.45L3.9 3.9", opacity: 0.12 },
219
+ ];
220
+
221
+ // Inject the keyframe animation once into the document head.
222
+ let _keyframeInjected = false;
223
+ function ensureSpinKeyframe() {
224
+ if (_keyframeInjected || typeof document === "undefined") return;
225
+ const style = document.createElement("style");
226
+ style.textContent =
227
+ "@keyframes pw-btn-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }" +
228
+ "@media (prefers-reduced-motion: reduce) { .pw-btn-spinner { animation: none !important; } }";
229
+ document.head.appendChild(style);
230
+ _keyframeInjected = true;
231
+ }
232
+
233
+ // ─── ButtonSpinner ─────────────────────────────────────────────────────────────
234
+ // Inline 8-spoke spinner for the loading state. Uses currentColor so the
235
+ // caller controls colour via the CSS `color` property.
236
+ export function ButtonSpinner({ size = 20 }) {
237
+ return (
238
+ <svg
239
+ className="pw-btn-spinner"
240
+ width={size}
241
+ height={size}
242
+ viewBox="0 0 12 12"
243
+ aria-hidden="true"
244
+ style={{
245
+ animation: "pw-btn-spin var(--motion-duration-loop-fast) var(--motion-easing-linear) infinite",
246
+ flexShrink: 0,
247
+ display: "block",
248
+ }}
249
+ >
250
+ {SPINNER_SPOKES.map((spoke, i) => (
251
+ <path
252
+ key={i}
253
+ d={spoke.d}
254
+ stroke="currentColor"
255
+ strokeWidth="1"
256
+ strokeLinecap="round"
257
+ fill="none"
258
+ opacity={spoke.opacity}
259
+ />
260
+ ))}
261
+ </svg>
262
+ );
263
+ }
264
+
265
+ // ─── Button ────────────────────────────────────────────────────────────────────
266
+ /**
267
+ * Props:
268
+ * buttonStyle, "Fill" | "Outlined" | "Naked" (default: "Fill")
269
+ * size, "L" | "M" | "S" | "XS" (default: "M")
270
+ * type, "Primary" | "Secondary" | "Tertiary" | "Negative" (default: "Primary")
271
+ * text, button label (default: "Button")
272
+ * leadingIcon, Material Symbols ligature string or React node | null
273
+ * trailingIcon, Material Symbols ligature string or React node | null
274
+ * showLeadingIcon, bool (default: false)
275
+ * showTrailingIcon, bool (default: false)
276
+ * showText, bool (default: true)
277
+ * loading, bool, replaces content with spinner, sets aria-busy
278
+ * disabled, bool, prevents interaction, sets aria-disabled
279
+ * forceState, "hover"|"pressed"|"focused"|"disabled"|"loading"|null
280
+ * Visual-only override for Storybook StateMatrix. Does not
281
+ * affect real interaction in production.
282
+ * onClick, (e) => void
283
+ * ariaLabel, overrides accessible name (required when showText=false)
284
+ * className, extra CSS class on the outer <button>
285
+ */
286
+ export function Button({
287
+ buttonStyle = "Fill",
288
+ size = "M",
289
+ type = "Primary",
290
+ text = "Button",
291
+ leadingIcon = null,
292
+ trailingIcon = null,
293
+ showLeadingIcon = false,
294
+ showTrailingIcon = false,
295
+ showText = true,
296
+ loading = false,
297
+ disabled = false,
298
+ forceState = null,
299
+ onClick,
300
+ ariaLabel,
301
+ className = "",
302
+ /* Anything else goes straight onto the <button>. Needed so a composite can
303
+ use a real Button as the trigger for something and still put the ARIA that
304
+ describes that relationship on the element itself: without this, a Dropdown
305
+ wrapping a Button had nowhere to attach aria-haspopup and aria-expanded,
306
+ which is how the Glance filter ended up as a hand-rolled button instead of
307
+ this component. */
308
+ ...rest
309
+ }) {
310
+ const [hovered, setHovered] = useState(false);
311
+ const [pressed, setPressed] = useState(false);
312
+ const [focused, setFocused] = useState(false);
313
+
314
+ useEffect(() => { ensureSpinKeyframe(); }, []);
315
+
316
+ // forceState drives visual simulation in Storybook; real props win in production
317
+ const forceLoading = forceState === "loading";
318
+ const forceDisabled = forceState === "disabled";
319
+ const isDisabled = disabled || loading || forceDisabled || forceLoading;
320
+
321
+ // Resolve the token-lookup key: forceState overrides live interaction when set
322
+ const stateKey = (() => {
323
+ if (forceState && !disabled && !loading) {
324
+ if (forceDisabled || forceLoading) return "disabled";
325
+ if (forceState === "focused") return "base"; // focused = base bg + ring
326
+ if (forceState === "hover") return "hover";
327
+ if (forceState === "pressed") return "pressed";
328
+ }
329
+ return isDisabled ? "disabled"
330
+ : pressed ? "pressed"
331
+ : hovered ? "hover"
332
+ : "base";
333
+ })();
334
+
335
+ // Show focus ring only on keyboard navigation (not mouse click) or when forced
336
+ const showFocusRing = !isDisabled && (forceState === "focused" || focused);
337
+
338
+ const fillTokens = FILL[buttonStyle]?.[type] || FILL.Fill.Primary;
339
+ const textTokens = TEXT[buttonStyle]?.[type] || TEXT.Fill.Primary;
340
+ const iconTokens = ICON[buttonStyle]?.[type] || ICON.Fill.Primary;
341
+ const strokeTokens = STROKE[type] || STROKE.Primary;
342
+ const sz = SIZES[size] || SIZES.M;
343
+
344
+ const bgColor = fillTokens[stateKey];
345
+ const textColor = textTokens[stateKey];
346
+ const iconColor = iconTokens[stateKey];
347
+ const strokeColor = strokeTokens[stateKey];
348
+
349
+ const hasStroke = buttonStyle === "Outlined";
350
+
351
+ // Container.Main styles (the visible button surface)
352
+ const containerStyle = {
353
+ display: "inline-flex",
354
+ alignItems: "center",
355
+ justifyContent: "center",
356
+ paddingLeft: sz.padH,
357
+ paddingRight: sz.padH,
358
+ paddingTop: sz.padV,
359
+ paddingBottom: sz.padV,
360
+ borderRadius: T.radius,
361
+ backgroundColor: bgColor,
362
+ // Hover, focus and pressed take the thicker weight, matching the 48 Outlined
363
+ // variants in Figma. Disabled and loading keep the rest weight.
364
+ border: hasStroke
365
+ ? `${(!isDisabled && !loading && (stateKey === "hover" || stateKey === "pressed" || showFocusRing))
366
+ ? T.borderEmphasis : T.border} solid ${strokeColor}`
367
+ : "none",
368
+ // Figma's Container.Main has gap 0. The 8px separation is realised as
369
+ // padding INSIDE the icon and label containers (4px each side of each),
370
+ // so applying it again as a flex gap double-counts it: the icon drifts to
371
+ // 12px from the label, and the button ends up 16px on the icon side
372
+ // against 12px on the label side. Keep this 0 and let the slots pad
373
+ // themselves - see labelStyle and iconWrapStyle below.
374
+ gap: 0,
375
+ outline: showFocusRing ? T.focusOutline : "none",
376
+ outlineOffset: showFocusRing ? T.focusOffset : undefined,
377
+ transition: "background-color var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard), box-shadow var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)",
378
+ color: iconColor, // propagated to icons via currentColor
379
+ };
380
+
381
+ // Label styles. Figma's Container.Label carries pad=[0,4,0,4]; that half-gap
382
+ // on each side is what separates it from an adjacent icon and what keeps a
383
+ // text-only button symmetric. Derived from the gap token so the two cannot
384
+ // drift: half either side sums to the full gap between slots.
385
+ const halfGap = `calc(${T.gap} / 2)`;
386
+ const labelStyle = {
387
+ fontFamily: sz.fontFamily,
388
+ fontWeight: sz.fontWeight,
389
+ lineHeight: sz.lineHeight,
390
+ letterSpacing: sz.letterSpacing,
391
+ fontSize: sz.fontSize,
392
+ color: textColor,
393
+ whiteSpace: "nowrap",
394
+ paddingLeft: halfGap,
395
+ paddingRight: halfGap,
396
+ transition: "color var(--motion-duration-2) var(--motion-easing-standard)",
397
+ userSelect: "none",
398
+ };
399
+
400
+ // Icon slot wrapper, iconWrap = outer slot size, iconInner = rendered glyph size.
401
+ // fontVariationSettings overrides the global opsz:24 from preview-head.html, // all button icon sizes (14 to 18px) sit below the minimum opsz axis value (20),
402
+ // so opsz:20 is the correct optical size for the tightest stroke rendering.
403
+ const iconWrapStyle = {
404
+ display: "inline-flex",
405
+ alignItems: "center",
406
+ justifyContent: "center",
407
+ width: sz.iconWrap,
408
+ height: sz.iconWrap,
409
+ flexShrink: 0,
410
+ fontSize: sz.iconInner,
411
+ color: iconColor,
412
+ transition: "color var(--motion-duration-2) var(--motion-easing-standard)",
413
+ fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20",
414
+ };
415
+
416
+ // Outer button element, handles the touch-target zone
417
+ const outerStyle = {
418
+ display: "inline-flex",
419
+ alignItems: "center",
420
+ justifyContent: "center",
421
+ padding: T.touch.pad,
422
+ minHeight: T.touch.min,
423
+ minWidth: T.touch.min,
424
+ background: "transparent",
425
+ border: "none",
426
+ borderRadius: T.radius,
427
+ cursor: isDisabled ? "not-allowed" : "pointer",
428
+ outline: "none",
429
+ opacity: 1,
430
+ textDecoration: "none",
431
+ };
432
+
433
+ return (
434
+ <button
435
+ type="button"
436
+ {...rest}
437
+ onClick={!isDisabled ? onClick : undefined}
438
+ disabled={isDisabled}
439
+ /* EVERY LABELLED BUTTON ANNOUNCED AS JUST "button" until 2026-10-08.
440
+ This read `ariaLabel || (showText ? undefined : text)`, on the
441
+ reasonable-sounding rule that a button showing its text does not need
442
+ a label because the text IS the name. That is true of an ordinary
443
+ button and was false here: the text sits inside
444
+ `pw-button__container`, which carried `aria-hidden="true"`, so the
445
+ name computation skipped it and the only named case was the
446
+ icon-only one, which is backwards.
447
+
448
+ The container's `aria-hidden` has been removed rather than patched
449
+ around, because the accessible name should come from the visible text:
450
+ that way it cannot drift from it, and it satisfies WCAG 2.5.3 Label in
451
+ Name without the caller doing anything. The icon spans carry their own
452
+ `aria-hidden`, so the ligature text ("add") was never the reason.
453
+
454
+ `text` is still used as the label in the two cases where there is no
455
+ visible text to read: icon-only, and loading, where the content is
456
+ replaced by a spinner. */
457
+ aria-label={ariaLabel
458
+ || (showText && !(loading || forceLoading) ? undefined : text)}
459
+ aria-busy={(loading || forceLoading) || undefined}
460
+ aria-disabled={isDisabled || undefined}
461
+ onMouseEnter={() => !isDisabled && setHovered(true)}
462
+ onMouseLeave={() => { setHovered(false); setPressed(false); }}
463
+ onMouseDown={() => !isDisabled && setPressed(true)}
464
+ onMouseUp={() => setPressed(false)}
465
+ onFocus={(e) => {
466
+ // Only show focus ring when navigating via keyboard (focus-visible heuristic).
467
+ // Mouse clicks focus the button but should not trigger the focus ring.
468
+ if (e.target.matches?.(':focus-visible')) setFocused(true);
469
+ }}
470
+ onBlur={() => { setFocused(false); setPressed(false); }}
471
+ onKeyDown={(e) => { if ((e.key === " " || e.key === "Enter") && !isDisabled) setPressed(true); }}
472
+ onKeyUp={() => setPressed(false)}
473
+ className={className}
474
+ style={outerStyle}
475
+ >
476
+ <span style={containerStyle} className="pw-button__container">
477
+ {(loading || forceLoading) ? (
478
+ // Loading state, spinner only, no label or icons
479
+ <ButtonSpinner size={sz.iconInner * 1.2} />
480
+ ) : (
481
+ <>
482
+ {showLeadingIcon && (
483
+ <span
484
+ style={iconWrapStyle}
485
+ className="material-symbols-rounded pw-button__icon pw-button__icon--leading"
486
+ aria-hidden="true"
487
+ >
488
+ {leadingIcon || "add"}
489
+ </span>
490
+ )}
491
+ {showText && (
492
+ <span style={labelStyle} className="pw-button__label">
493
+ {text}
494
+ </span>
495
+ )}
496
+ {showTrailingIcon && (
497
+ <span
498
+ style={iconWrapStyle}
499
+ className="material-symbols-rounded pw-button__icon pw-button__icon--trailing"
500
+ aria-hidden="true"
501
+ >
502
+ {trailingIcon || "arrow_forward"}
503
+ </span>
504
+ )}
505
+ </>
506
+ )}
507
+ </span>
508
+ </button>
509
+ );
510
+ }
511
+
512
+ export default Button;