@helloimjolopez-pathway/pathway-components 0.3.2 → 0.5.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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Checkbox, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Checkbox
5
+ * implementation; the standalone demo (checkbox.html) and the Storybook
6
+ * stories both consume this.
7
+ *
8
+ * Spec: components/checkbox/checkbox-spec.md
9
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40002324-54532
10
+ */
11
+
12
+ import React, { useRef, useEffect, useState } from "react";
13
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
14
+
15
+ // ─── SEMANTIC TOKEN CSS VARIABLES ─────────────────────────────────────────────
16
+ // All colours and radii come from semantic tokens only. No raw hex, no
17
+ // primitive vars. See checkbox-spec.md §5 for the full token table.
18
+ //
19
+ // Note: some tokens in §5.3 of the spec are missing from the token file.
20
+ // Fallbacks are used where noted and will be updated once Figma is re-synced.
21
+ const V = {
22
+ // Box fill, standard checked / indeterminate
23
+ fillCheckedBase: "var(--semantic-color-fill-action-primary-strong-rest)",
24
+ fillCheckedHover: "var(--semantic-color-fill-action-primary-strong-hover)",
25
+ fillCheckedFocused: "var(--semantic-color-fill-action-primary-strong-hover)",
26
+ fillCheckedPressed: "var(--semantic-color-fill-action-primary-strong-pressed)",
27
+ fillCheckedDisabled: "var(--semantic-color-fill-action-disabled)",
28
+
29
+ // Box border, standard unchecked
30
+ borderBase: "var(--semantic-color-stroke-action-secondary-rest)",
31
+ borderHover: "var(--semantic-color-stroke-action-secondary-hover)",
32
+ borderFocused: "var(--semantic-color-stroke-action-secondary-hover)",
33
+ borderPressed: "var(--semantic-color-stroke-action-secondary-pressed)",
34
+ borderDisabled: "var(--semantic-color-stroke-action-disabled)",
35
+
36
+ // Checkmark / dash icon, standard.
37
+ //
38
+ // MUST be an ON-STRONG foreground, because the box it sits in is filled with
39
+ // fill-action-primary-strong-*. This was foreground-action-primary-ON-SUBTLE-rest,
40
+ // which is #345499 sitting on #3a5aaa: the same blue, so the whole square
41
+ // read as a solid blue block with no visible tick. Reported 2026-10-02.
42
+ //
43
+ // Figma binds Foreground/Static/Neutral/MONO here instead, which is correct
44
+ // in Light (#ffffff) and wrong in Midnight: mono is an INVERSION ANCHOR, not
45
+ // a rung, so it flips to #181b2b and would put a near-black tick on a blue
46
+ // box. on-strong is #ffffff in both modes, which is what a foreground on a
47
+ // strong fill is for. Recorded in the manifest as a Figma defect.
48
+ iconPrimary: "var(--semantic-color-foreground-action-primary-on-strong)",
49
+
50
+ // Checkmark / dash icon: error. Same fix: the error box is filled with
51
+ // fill-action-status-negative-strong-*, so the tick needs the on-strong
52
+ // foreground. Was on-subtle-rest: #722121 on #b03a3a.
53
+ iconError: "var(--semantic-color-foreground-action-status-negative-on-strong)",
54
+
55
+ // State-layer, unchecked hover / focused
56
+ stateLayerUncheckedHover: "var(--semantic-color-fill-action-secondary-hover)",
57
+
58
+ // State-layer, checked hover / focused / pressed
59
+ stateLayerCheckedHover: "var(--semantic-color-fill-action-primary-subtle-hover)",
60
+ stateLayerCheckedFocused: "var(--semantic-color-fill-action-primary-subtle-hover)",
61
+ stateLayerCheckedPressed: "var(--semantic-color-fill-action-primary-subtle-pressed)",
62
+
63
+ // Box fill: error checked / indeterminate
64
+ fillErrorBase: "var(--semantic-color-fill-action-status-negative-strong-rest)",
65
+ fillErrorHover: "var(--semantic-color-fill-action-status-negative-strong-hover)",
66
+ fillErrorFocused: "var(--semantic-color-fill-action-status-negative-strong-hover)",
67
+ fillErrorPressed: "var(--semantic-color-fill-action-status-negative-strong-pressed)",
68
+ fillErrorDisabled: "var(--semantic-color-fill-action-disabled)",
69
+
70
+ // Box border: error unchecked
71
+ borderErrorBase: "var(--semantic-color-stroke-action-status-negative-rest)",
72
+ borderErrorHover: "var(--semantic-color-stroke-action-status-negative-hover)",
73
+ borderErrorFocused: "var(--semantic-color-stroke-action-status-negative-hover)",
74
+ borderErrorPressed: "var(--semantic-color-stroke-action-status-negative-pressed)",
75
+ borderErrorDisabled: "var(--semantic-color-stroke-action-disabled)",
76
+
77
+ // State-layer: error hover (both unchecked and checked)
78
+ stateLayerErrorHover: "var(--semantic-color-fill-action-status-negative-subtle-hover)",
79
+
80
+ // Highlight.
81
+ //
82
+ // THIS IS NOT A RESTING TINT. It used to be one here, and that was wrong in
83
+ // both directions: it painted a background the Figma variant does not have,
84
+ // and it did nothing on hover, which is the only place the variant actually
85
+ // differs. Reported 2026-10-02.
86
+ //
87
+ // Figma has three Highlight variants, all Unselected:
88
+ // Type=Highlight Unselected, State=Active no state layer at all
89
+ // Type=Highlight Hovered, State=Hovered state layer #e2e9f7
90
+ // Type=Hhighlight Focused, State=Focused state layer #e2e9f7 (sic)
91
+ // The box fill and both border colours are IDENTICAL to the standard
92
+ // unselected variants (#77726b rest, #67625c hover). So the entire meaning of
93
+ // Highlight is one swap: the unchecked hover/focus state layer goes from the
94
+ // warm grey Fill/Action/Secondary/Hover (#f3f0ec) to the pale blue
95
+ // Fill/Action/Primary/Subtle/Hover (#e2e9f7). It marks a row as brand-live on
96
+ // interaction; it does not mark it at rest.
97
+ stateLayerHighlightHover: "var(--semantic-color-fill-action-primary-subtle-hover)",
98
+
99
+ // Label text
100
+ labelColor: "var(--semantic-color-foreground-static-neutral-base)",
101
+
102
+ // Geometry
103
+ // cornerradius.small = 4px, matches the 4px box radius in the Figma spec.
104
+ // (cornerradius.xsmall = 2px which is too small; small is the correct value.)
105
+ //
106
+ // NOT wrapped in calc(... * 1px). The layout tokens already carry their unit
107
+ // (`--semantic-layout-units-cornerradius-small: 4px`), so multiplying by 1px
108
+ // produced calc(4px * 1px): px times px is not a length, so the browser
109
+ // dropped the declaration and the box rendered with square corners. Silent,
110
+ // because an invalid value falls back to the initial one rather than erroring.
111
+ // Style Dictionary emits layout tokens as unitless numbers, so we multiply
112
+ // by 1px inside calc() to get a valid CSS length.
113
+ radius: "var(--semantic-layout-units-cornerradius-small)",
114
+ radiusFull: "100px", // state-layer pill: no dedicated token, 100px safely rounds any 44px circle
115
+ };
116
+
117
+ // ─── GEOMETRY ─────────────────────────────────────────────────────────────────
118
+ export const BOX_SIZE = { default: 18, s: 16 };
119
+ export const TARGET_SIZE = 44; // touch target, always 44px (WCAG 2.5.5)
120
+ export const BORDER_WIDTH = 1.5; // px
121
+ export const LABEL_GAP = 16; // px, gap between state-layer and label text
122
+
123
+ // ─── ICONS ────────────────────────────────────────────────────────────────────
124
+ // Both icons use the same 18×18 viewBox so they scale cleanly when size="s".
125
+ // Paths are centred in the viewBox to match Figma geometry.
126
+
127
+ export function IconCheck({ color, size }) {
128
+ return (
129
+ <svg
130
+ width={size} height={size} viewBox="0 0 18 18"
131
+ fill="none" aria-hidden="true" focusable="false"
132
+ >
133
+ <path
134
+ d="M4 9.5L7.5 13L14 6"
135
+ stroke={color}
136
+ strokeWidth={BORDER_WIDTH}
137
+ strokeLinecap="round"
138
+ strokeLinejoin="round"
139
+ />
140
+ </svg>
141
+ );
142
+ }
143
+
144
+ export function IconDash({ color, size }) {
145
+ return (
146
+ <svg
147
+ width={size} height={size} viewBox="0 0 18 18"
148
+ fill="none" aria-hidden="true" focusable="false"
149
+ >
150
+ <path
151
+ d="M5 9H13"
152
+ stroke={color}
153
+ strokeWidth={BORDER_WIDTH}
154
+ strokeLinecap="round"
155
+ />
156
+ </svg>
157
+ );
158
+ }
159
+
160
+ // ─── CHECKBOX ─────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Checkbox
164
+ *
165
+ * @param {boolean} checked - Whether the checkbox is checked
166
+ * @param {boolean} indeterminate - Shows dash icon; overrides checked visually
167
+ * @param {boolean} error - Error / negative styling
168
+ * @param {boolean} highlight - Brand-tinted hover/focus state-layer for selectable
169
+ * rows. Unchecked only, and no effect at rest.
170
+ * @param {boolean} secondary - Secondary indeterminate (muted colour), use with indeterminate only
171
+ * @param {boolean} disabled - Non-interactive
172
+ * @param {"default"|"s"} size - Visual size of the control
173
+ * @param {string} label - Optional visible label
174
+ * @param {function} onChange - (checked: boolean) => void
175
+ * @param {string} id - For <label htmlFor> association
176
+ * @param {string} name - Form field name
177
+ * @param {string} value - Form field value
178
+ * @param {string} className - Additional CSS class on the root element
179
+ * @param {string} [describedBy] - aria-describedby for error messages (error state)
180
+ */
181
+ export function Checkbox({
182
+ checked = false,
183
+ indeterminate = false,
184
+ error = false,
185
+ highlight = false,
186
+ secondary = false,
187
+ disabled = false,
188
+ size = "default",
189
+ label,
190
+ onChange,
191
+ id,
192
+ name,
193
+ value,
194
+ className = "",
195
+ describedBy,
196
+ }) {
197
+ const noMotion = useReducedMotion();
198
+ const inputRef = useRef(null);
199
+ const [hovered, setHovered] = useState(false);
200
+ const [focused, setFocused] = useState(false);
201
+ const [pressed, setPressed] = useState(false);
202
+
203
+ // The indeterminate state can only be set on the DOM element: not via HTML.
204
+ useEffect(() => {
205
+ if (inputRef.current) {
206
+ inputRef.current.indeterminate = indeterminate;
207
+ }
208
+ }, [indeterminate]);
209
+
210
+ const boxSize = BOX_SIZE[size] || BOX_SIZE.default;
211
+
212
+ // ─── Derived box fill ──────────────────────────────────────────────────────
213
+ let boxFill = "transparent";
214
+ if (disabled && (checked || indeterminate)) {
215
+ boxFill = error ? V.fillErrorDisabled : V.fillCheckedDisabled;
216
+ } else if (!disabled && (checked || indeterminate)) {
217
+ if (error) {
218
+ boxFill = pressed ? V.fillErrorPressed
219
+ : focused ? V.fillErrorFocused
220
+ : hovered ? V.fillErrorHover
221
+ : V.fillErrorBase;
222
+ } else {
223
+ boxFill = pressed ? V.fillCheckedPressed
224
+ : focused ? V.fillCheckedFocused
225
+ : hovered ? V.fillCheckedHover
226
+ : V.fillCheckedBase;
227
+ }
228
+ }
229
+
230
+ // ─── Derived box border colour ─────────────────────────────────────────────
231
+ // When checked or indeterminate, border matches fill (visually absorbed).
232
+ // When unchecked, border shows the action.secondary (or negative) stroke token.
233
+ let borderColor;
234
+ if (checked || indeterminate) {
235
+ borderColor = boxFill; // border hidden under fill
236
+ } else if (disabled) {
237
+ borderColor = error ? V.borderErrorDisabled : V.borderDisabled;
238
+ } else if (error) {
239
+ borderColor = pressed ? V.borderErrorPressed
240
+ : focused ? V.borderErrorFocused
241
+ : hovered ? V.borderErrorHover
242
+ : V.borderErrorBase;
243
+ } else {
244
+ borderColor = pressed ? V.borderPressed
245
+ : focused ? V.borderFocused
246
+ : hovered ? V.borderHover
247
+ : V.borderBase;
248
+ }
249
+
250
+ // ─── Derived state-layer background ───────────────────────────────────────
251
+ let stateLayerBg = "transparent";
252
+ if (!disabled) {
253
+ if (pressed) {
254
+ // pressed: same tint as hover: no separate pressed state-layer in spec
255
+ stateLayerBg = (checked || indeterminate)
256
+ ? V.stateLayerCheckedPressed
257
+ : (error ? V.stateLayerErrorHover : V.stateLayerUncheckedHover);
258
+ } else if (hovered || focused) {
259
+ if (checked || indeterminate) {
260
+ stateLayerBg = error
261
+ ? V.stateLayerErrorHover
262
+ : (focused ? V.stateLayerCheckedFocused : V.stateLayerCheckedHover);
263
+ } else {
264
+ // Unchecked hover/focus. Highlight swaps the warm grey layer for the
265
+ // pale blue one; error still wins over both. See V.stateLayerHighlightHover.
266
+ stateLayerBg = error
267
+ ? V.stateLayerErrorHover
268
+ : (highlight ? V.stateLayerHighlightHover : V.stateLayerUncheckedHover);
269
+ }
270
+ }
271
+ // No `else if (highlight)` branch: Figma's highlight variant has no resting
272
+ // state layer, so at rest a highlighted checkbox is indistinguishable from
273
+ // a standard one. That is the design.
274
+ }
275
+
276
+ // ─── Focus ring ───────────────────────────────────────────────────────────
277
+ // Error: spec defines a 3px rgba red shadow on the state-layer.
278
+ // Standard: border colour change on the box is sufficient (above).
279
+ const focusShadow = focused && error
280
+ ? "0 0 0 3px var(--semantic-color-fill-static-negative-subtle)"
281
+ // Was a hand-mixed red at 10 percent. Fill/Static/Negative/Subtle is the
282
+ // tone's own pale tint and it inverts with the theme, so the ring stays
283
+ // visible on a dark canvas instead of vanishing into it.
284
+ : "none";
285
+
286
+ // ─── Icon colour ──────────────────────────────────────────────────────────
287
+ const iconColor = error ? V.iconError : V.iconPrimary;
288
+ const showIcon = checked || indeterminate;
289
+
290
+ return (
291
+ <label
292
+ className={`pds-checkbox${size === "s" ? " pds-checkbox--s" : ""}${error ? " pds-checkbox--error" : ""}${highlight ? " pds-checkbox--highlight" : ""}${disabled ? " pds-checkbox--disabled" : ""}${className ? ` ${className}` : ""}`}
293
+ style={{
294
+ display: "inline-flex",
295
+ alignItems: "center",
296
+ gap: label ? LABEL_GAP : 0,
297
+ cursor: disabled ? "not-allowed" : "pointer",
298
+ userSelect: "none",
299
+ fontFamily: "'Red Hat Text', sans-serif",
300
+ }}
301
+ onMouseEnter={() => !disabled && setHovered(true)}
302
+ onMouseLeave={() => { setHovered(false); setPressed(false); }}
303
+ onMouseDown={() => !disabled && setPressed(true)}
304
+ onMouseUp={() => setPressed(false)}
305
+ >
306
+ {/* 44×44 touch target / state-layer */}
307
+ <span
308
+ aria-hidden="true"
309
+ style={{
310
+ position: "relative",
311
+ display: "inline-flex",
312
+ alignItems: "center",
313
+ justifyContent: "center",
314
+ width: TARGET_SIZE,
315
+ height: TARGET_SIZE,
316
+ borderRadius: V.radiusFull,
317
+ backgroundColor: stateLayerBg,
318
+ transition: noMotion ? "none" : "background-color var(--motion-duration-3) var(--motion-easing-standard)",
319
+ boxShadow: focusShadow,
320
+ flexShrink: 0,
321
+ }}
322
+ >
323
+ {/* Visually hidden native input, provides semantics and keyboard behaviour */}
324
+ <input
325
+ ref={inputRef}
326
+ type="checkbox"
327
+ id={id}
328
+ name={name}
329
+ value={value}
330
+ checked={indeterminate ? false : checked}
331
+ disabled={disabled}
332
+ aria-describedby={describedBy}
333
+ onChange={e => onChange && onChange(e.target.checked)}
334
+ onFocus={() => setFocused(true)}
335
+ onBlur={() => setFocused(false)}
336
+ style={{
337
+ position: "absolute",
338
+ width: boxSize,
339
+ height: boxSize,
340
+ opacity: 0,
341
+ margin: 0,
342
+ padding: 0,
343
+ cursor: disabled ? "not-allowed" : "pointer",
344
+ zIndex: 1,
345
+ }}
346
+ />
347
+
348
+ {/* Visual checkbox box */}
349
+ <span
350
+ aria-hidden="true"
351
+ style={{
352
+ display: "inline-flex",
353
+ alignItems: "center",
354
+ justifyContent: "center",
355
+ width: boxSize,
356
+ height: boxSize,
357
+ borderRadius: V.radius,
358
+ border: `${BORDER_WIDTH}px solid ${borderColor}`,
359
+ backgroundColor: boxFill,
360
+ boxSizing: "border-box",
361
+ transition: noMotion ? "none" : "background-color var(--motion-duration-2) var(--motion-easing-decelerate), border-color var(--motion-duration-2) var(--motion-easing-decelerate)",
362
+ flexShrink: 0,
363
+ }}
364
+ >
365
+ {showIcon && (
366
+ indeterminate
367
+ ? <IconDash color={iconColor} size={boxSize} />
368
+ : <IconCheck color={iconColor} size={boxSize} />
369
+ )}
370
+ </span>
371
+ </span>
372
+
373
+ {/* Label text */}
374
+ {label && (
375
+ <span
376
+ style={{
377
+ fontSize: "var(--semantic-type-font-size-s)",
378
+ lineHeight: "var(--semantic-type-line-height-s-single)",
379
+ fontWeight: 400,
380
+ color: disabled
381
+ ? "var(--semantic-color-foreground-static-neutral-base)"
382
+ : V.labelColor,
383
+ }}
384
+ >
385
+ {label}
386
+ </span>
387
+ )}
388
+ </label>
389
+ );
390
+ }
391
+
392
+ export default Checkbox;