@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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/components/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +26 -4
- package/README.md +0 -3
|
@@ -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;
|