@helloimjolopez-pathway/pathway-components 0.3.2 → 0.4.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;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Scroll-to-Enable's standalone demo. Imports the real module, so there is
3
+ * nothing here that can fall out of step with the shipped component: see
4
+ * components/_demo/harness.jsx for why the newer demos are generated.
5
+ */
6
+ import React, { useState } from "react";
7
+ import { DemoChrome, isEmbed, mount } from "../_demo/harness.jsx";
8
+ import { ScrollToEnable } from "./scroll-to-enable.jsx";
9
+
10
+ const U = (n) => `var(--semantic-layout-units-${n})`;
11
+ const C = (n) => `var(--semantic-color-${n})`;
12
+ const Y = (n) => `var(--semantic-type-${n})`;
13
+
14
+ const FCRA = [
15
+ "In connection with your employment application with [Org Name] (the “Organization”), this notice is intended to inform you that a consumer report will be obtained on you from a consumer reporting agency for employment purposes. These purposes may include hiring, promotion, reassignment or retention.",
16
+ "The consumer report may contain information about your character, general reputation, personal characteristics and mode of living. It may include, but is not limited to, criminal history, motor vehicle records, education and employment verification, and credit history, where permitted by law.",
17
+ "You have the right, upon written request made within a reasonable time, to request whether a consumer report has been run about you, and to request a copy of your report. You may also request the nature and scope of any investigative consumer report.",
18
+ "This online application is protected by a secure certificate authority, which supports up to a TLS 1.2 256-bit encryption process. All information provided on this form is secure.",
19
+ "A summary of your rights under the Fair Credit Reporting Act is available on request, and a copy will be provided with any adverse action notice.",
20
+ ];
21
+ const SHORT = [FCRA[0]];
22
+ const LABEL = "I acknowledge receipt of the disclosure regarding background investigation and certify that I have read and understand this document.";
23
+
24
+ const body = (paras) => paras.map((p, i) => <p key={i}>{p}</p>);
25
+ const card = {
26
+ background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
27
+ borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
28
+ display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
29
+ };
30
+ const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
31
+ const caption = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
32
+
33
+ /* The real screen it lives on: the background check form, step 3. */
34
+ function InContext() {
35
+ const [short, setShort] = useState(false);
36
+ const [done, setDone] = useState(false);
37
+ return (
38
+ <section style={{ ...card, padding: 0, overflow: "hidden" }}>
39
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: `${U("padding-tight")} ${U("padding-relaxed")}`,
40
+ background: C("fill-static-brand-strong"), color: C("foreground-static-brand-on-strong"), fontSize: Y("font-size-s") }}>
41
+ <span>[Org Name] Background Check</span><span>Step 3 of 6</span>
42
+ </div>
43
+ <div style={{ padding: U("padding-relaxed"), display: "flex", justifyContent: "center" }}>
44
+ <div style={{ width: "100%", maxWidth: 456, display: "flex", flexDirection: "column", gap: U("gap-base") }}>
45
+ <div>
46
+ <h2 style={{ ...h2, fontSize: Y("font-size-xl") }}>FCRA Notice</h2>
47
+ <p style={{ margin: 0, fontSize: Y("font-size-s"), color: C("foreground-static-neutral-base") }}>Please read and acknowledge receipt of the disclosure.</p>
48
+ </div>
49
+ <ScrollToEnable key={`${short}-${done}`} title="Terms & Conditions" agreementLabel={LABEL}
50
+ onContinue={() => setDone(true)} onPrint={() => {}}>
51
+ {body(short ? SHORT : FCRA)}
52
+ </ScrollToEnable>
53
+ {done && <p role="status" style={{ margin: 0, ...caption }}>Continue accepted: the form would move to step 4.</p>}
54
+ <div style={{ display: "flex", gap: U("gap-tight"), alignItems: "center", flexWrap: "wrap", ...caption }}>
55
+ Notice:
56
+ {[["Long", false], ["Short, fits", true]].map(([name, v]) => (
57
+ <button key={name} type="button" aria-pressed={short === v} onClick={() => { setShort(v); setDone(false); }}
58
+ style={{ font: "inherit", padding: `${U("padding-xxxtight")} ${U("padding-tight")}`, borderRadius: U("cornerradius-base"),
59
+ border: `1px solid ${short === v ? C("stroke-action-primary-strong-rest") : C("stroke-static-neutral-base")}`,
60
+ background: short === v ? C("fill-action-primary-subtle-rest") : "transparent", color: C("foreground-static-neutral-base"), cursor: "pointer" }}>
61
+ {name}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </section>
68
+ );
69
+ }
70
+
71
+ function Cell({ label, children }) {
72
+ return (
73
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-tight"), minWidth: 0 }}>
74
+ <span style={caption}>{label}</span>
75
+ {children}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function States() {
81
+ const grid = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(min(340px, 100%), 1fr))", gap: U("gap-relaxed") };
82
+ const inline = ["Unread", "Reading", "Read", "Agreed", "Blocked", "Agreement Missing"];
83
+ const page = ["Unread", "Blocked", "Read", "Agreed", "Agreement Missing"];
84
+ const sheet = ["Sheet Unread", "Sheet Blocked", "Sheet Read"];
85
+ const make = (s, layout) => (
86
+ <ScrollToEnable layout={layout} demoState={s} title={layout === "sheet" ? "FCRA Notice" : "Terms & Conditions"}
87
+ agreementLabel={LABEL} noticeHeight={layout === "sheet" && s.startsWith("Sheet") ? 180 : undefined} onPrint={() => {}}>
88
+ {body(FCRA)}
89
+ </ScrollToEnable>
90
+ );
91
+ return (
92
+ <>
93
+ <section style={card}>
94
+ <h2 style={h2}>Inline (viewport 768 and up)</h2>
95
+ <div style={grid}>{inline.map((s) => <Cell key={s} label={s}>{make(s, "inline")}</Cell>)}</div>
96
+ </section>
97
+ <section style={card}>
98
+ <h2 style={h2}>Sheet: the page (viewport under 768)</h2>
99
+ <div style={grid}>{page.map((s) => <Cell key={s} label={s}>{make(s, "sheet")}</Cell>)}</div>
100
+ </section>
101
+ <section style={card}>
102
+ <h2 style={h2}>Sheet: open</h2>
103
+ <div style={grid}>{sheet.map((s) => <Cell key={s} label={s}>{make(s, "sheet")}</Cell>)}</div>
104
+ </section>
105
+ </>
106
+ );
107
+ }
108
+
109
+ function App() {
110
+ return (
111
+ <DemoChrome title="Scroll-to-Enable"
112
+ blurb={"The person reaches the end of a legal notice before agreeing and continuing. Nothing is disabled: " +
113
+ "press Continue or the checkbox early and it says what to do and moves focus there. " +
114
+ "Inline at 768 and wider; switch to Mobile for the sheet. Tab into the notice and use the arrow keys; Esc closes the sheet."}>
115
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
116
+ <InContext />
117
+ {/* The Tablet and Mobile frames show the real screen only: the state
118
+ grids force each layout and are reference, not responsive. */}
119
+ {!isEmbed() && <States />}
120
+ </div>
121
+ </DemoChrome>
122
+ );
123
+ }
124
+
125
+ mount(<App />);
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Scroll-to-Enable, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Scroll-to-Enable
5
+ * implementation; the standalone demo (scroll-to-enable.html, generated from
6
+ * scroll-to-enable-demo.jsx) and the Storybook stories both consume this.
7
+ *
8
+ * Spec: components/scroll-to-enable/scroll-to-enable-spec.md
9
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40003308-10049
10
+ * (inline), 40003308:11167 (mobile page), 40019965:1078 (sheet)
11
+ *
12
+ * NOTHING IS EVER DISABLED. The old Figma version disabled Continue, Done
13
+ * Reading and the checkbox until the notice had been scrolled. A disabled
14
+ * control cannot be focused, gives no reason and is skipped by screen readers,
15
+ * so every gate here is enforced on activation instead: the control responds,
16
+ * an inline message says what is needed, and focus moves to where it can be
17
+ * done. Do not add `disabled` or `aria-disabled` to anything in this file.
18
+ *
19
+ * THREE PARTS ARE BUILT HERE FOR NOW. Reading progress, the message and the
20
+ * sheet (with its header) stand in for Progress Bar, Help Text, Step
21
+ * Navigation Header and Sheet, none of which are in the repo yet. They are not
22
+ * exported. When those components ship, swap them in (spec §17).
23
+ */
24
+
25
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
26
+ import { Button } from "../button/button.jsx";
27
+ import { Checkbox } from "../checkbox/checkbox.jsx";
28
+ import { Scrollable } from "../scrollbar/scrollbar.jsx";
29
+ import { Icon } from "../icon/icon.jsx";
30
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
31
+
32
+ // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
33
+ // SC = semantic-color (themes/*.css)
34
+ // SL = semantic-layout-units (layout.css)
35
+ // CL = contextual-layout-units (layout-contextual.css)
36
+ // RL = responsive-layout (layout-responsive.css, per breakpoint)
37
+ // ST = semantic-type (type.css)
38
+ const SC = (p) => `var(--semantic-color-${p})`;
39
+ const SL = (p) => `var(--semantic-layout-units-${p})`;
40
+ const CL = (p) => `var(--contextual-layout-units-${p})`;
41
+ const RL = (p) => `var(--responsive-layout-${p})`;
42
+ const ST = (p) => `var(--semantic-type-${p})`;
43
+
44
+ // One composed type style per Figma text style. Tracking is named differently
45
+ // in Figma and the CSS (Figma Standard/Wide/ExtraWide = CSS wide/spacious/
46
+ // extraspacious, 0.1/0.3/0.6), so each line says which Figma style it is.
47
+ const TYPE = (size, lineHeight, weight, tracking) => ({
48
+ fontFamily: ST("family-brand"),
49
+ fontSize: ST(`font-size-${size}`),
50
+ lineHeight: ST(`line-height-${lineHeight}`),
51
+ fontWeight: ST(`weight-${weight}`),
52
+ letterSpacing: ST(`letter-spacing-${tracking}`),
53
+ });
54
+
55
+ // ─── LAYOUT CONSTANTS ─────────────────────────────────────────────────────────
56
+ export const T = {
57
+ // Colour
58
+ noticeFill: SC("fill-surface-elevated"),
59
+ noticeStroke: SC("stroke-static-neutral-faint"),
60
+ hintFill: SC("fill-surface-overlay"),
61
+ hintStroke: SC("stroke-static-neutral-bold"),
62
+ messageFill: SC("fill-static-negative-subtle"),
63
+ messageFg: SC("foreground-static-negative-on-subtle"),
64
+ // One token in every state and theme. It does NOT turn green at 100%:
65
+ // Fill/Static/Positive/Strong is 1.85:1 on the page in Amplify Light.
66
+ progressFill: SC("fill-static-brand-strong"),
67
+ progressTrack: SC("stroke-static-neutral-faint"),
68
+ sheetFill: SC("fill-surface-overlay"),
69
+ scrim: SC("scrim-base"),
70
+ fgStrong: SC("foreground-static-neutral-strong"),
71
+ fgBase: SC("foreground-static-neutral-base"),
72
+ focusRing: SC("stroke-focusring-base"),
73
+ // Shadow
74
+ hintShadow: "var(--elevation-lift)",
75
+ sheetShadow: "var(--elevation-sheet)",
76
+ // Geometry
77
+ borderWidth: SL("borderwidth-thin"),
78
+ noticeRadius: SL("cornerradius-base"),
79
+ pillRadius: SL("cornerradius-full"),
80
+ messageRadius: SL("cornerradius-small"),
81
+ sheetRadius: RL("sheet-corner-radius-cornerradius"),
82
+ focusWidth: CL("focusring-width"),
83
+ focusOffset: CL("focusring-offset"),
84
+ // Spacing
85
+ sectionGap: SL("gap-medium"),
86
+ progressGap: SL("gap-xtight"),
87
+ titleGap: SL("gap-tight"),
88
+ agreementGap: SL("gap-xtight"),
89
+ paragraphGap: SL("gap-base"),
90
+ toolbarPad: `${SL("padding-xxtight")} ${SL("padding-xtight")} ${SL("padding-xxxtight")}`,
91
+ contentPad: `${SL("padding-xtight")} ${SL("padding-base")} ${SL("padding-base")}`,
92
+ hintPad: `${SL("padding-xxxtight")} ${SL("padding-xtight")}`,
93
+ hintGap: SL("gap-xxtight"),
94
+ hintOffset: SL("padding-xtight"),
95
+ messagePad: SL("padding-xtight"),
96
+ messageGap: SL("gap-tight"),
97
+ sheetPad: `${RL("sheet-padding-top")} ${RL("sheet-padding-horizontal")} ${SL("padding-wide")}`,
98
+ sheetGap: SL("gap-base"),
99
+ sheetHeaderPad: SL("padding-medium"),
100
+ // Type (Figma text style in the comment)
101
+ noticeType: TYPE("s", "s-single", "regular", "spacious"), // Text/Body/Small/Regular
102
+ titleType: TYPE("r", "r-single", "medium", "wide"), // Heading/Section/Small/Medium
103
+ progressType: TYPE("xs", "xs-single", "regular", "extraspacious"), // Label/Progress/Small/Regular
104
+ hintType: TYPE("xs", "xs-single", "semibold", "extraspacious"), // Label/Badge/Base/Semibold
105
+ messageType: TYPE("xs", "xs-single", "semibold", "extraspacious"), // Text/Supporting/Base/Semibold
106
+ sheetTitleType: TYPE("m", "m-relaxed", "semibold", "wide"), // Heading/Section/Base/Semibold
107
+ // Motion
108
+ progressMotion: "width var(--motion-duration-2) var(--motion-easing-standard)",
109
+ scrimMotion: "pds-ste-fade var(--motion-duration-3) var(--motion-easing-standard)",
110
+ sheetMotion: "pds-ste-rise var(--motion-duration-4) var(--motion-easing-emphasized)",
111
+ // Measurements (widths and heights are not tokens, CLAUDE.md §5)
112
+ noticeHeight: 200, // scroll area, Figma Scroll Area fixed height
113
+ progressHeight: 4, // Figma Progress Bar height
114
+ minWidth: 320,
115
+ readThreshold: 8, // px from the end that counts as read
116
+ breakpoint: 768, // --breakpoints-tablet-768px: inline at and above, sheet below
117
+ sheetFrameHeight: 560, // documentation frames only: room for a contained sheet
118
+ };
119
+
120
+ /** Default message copy. Each one says what to do next. */
121
+ export const MESSAGES = {
122
+ scrollInline: "Scroll to the end of the notice before you continue.",
123
+ readAgreement: "Read the agreement before you agree to it.",
124
+ scrollSheet: "Scroll to the end of the notice to finish reading.",
125
+ agree: "Check the box to confirm you have read the notice.",
126
+ };
127
+
128
+ /**
129
+ * Documentation only: the Figma states, for stories and the demo. Each seeds
130
+ * the internal state once; the component stays fully interactive after that.
131
+ * "Blocked" picks the inline or sheet-page message by layout. The Sheet states
132
+ * keep the sheet inside the component's own frame instead of the viewport.
133
+ */
134
+ export const DEMO_STATES = {
135
+ "Unread": {},
136
+ "Reading": { progress: 0.5 },
137
+ "Read": { progress: 1, read: true },
138
+ "Agreed": { progress: 1, read: true, checked: true },
139
+ "Blocked": { msg: "blocked" },
140
+ "Agreement Missing": { progress: 1, read: true, msg: "agree", missing: true },
141
+ "Sheet Unread": { sheetOpen: true },
142
+ "Sheet Blocked": { sheetOpen: true, progress: 0.4, sheetMsg: "scrollSheet" },
143
+ "Sheet Read": { sheetOpen: true, progress: 1 },
144
+ };
145
+
146
+ // Keyframes and the paragraph reset cannot be inline styles. Injected once,
147
+ // the same way Scrollable and Button add theirs.
148
+ function ensureStyles() {
149
+ if (typeof document === "undefined" || document.getElementById("pds-ste-styles")) return;
150
+ const s = document.createElement("style");
151
+ s.id = "pds-ste-styles";
152
+ s.textContent =
153
+ "@keyframes pds-ste-fade{from{opacity:0}to{opacity:1}}" +
154
+ "@keyframes pds-ste-rise{from{transform:translateY(100%)}to{transform:translateY(0)}}" +
155
+ ".pds-ste__region{outline:none}" +
156
+ ".pds-ste__region p{margin:0}";
157
+ document.head.appendChild(s);
158
+ }
159
+
160
+ const canMatch = () => typeof window !== "undefined" && typeof window.matchMedia === "function";
161
+ const WIDE = `(min-width: ${T.breakpoint}px)`;
162
+
163
+ /** "inline" at the 768 breakpoint and above, "sheet" below. */
164
+ function useAutoLayout(layout) {
165
+ const [wide, setWide] = useState(() => (canMatch() ? window.matchMedia(WIDE).matches : true));
166
+ useEffect(() => {
167
+ if (layout !== "auto" || !canMatch()) return;
168
+ const mq = window.matchMedia(WIDE);
169
+ setWide(mq.matches);
170
+ const onChange = (e) => setWide(e.matches);
171
+ mq.addEventListener("change", onChange);
172
+ return () => mq.removeEventListener("change", onChange);
173
+ }, [layout]);
174
+ if (layout === "inline" || layout === "sheet") return layout;
175
+ return wide ? "inline" : "sheet";
176
+ }
177
+
178
+ /** Focus an element if it can take focus. */
179
+ const focusEl = (el) => { if (el && typeof el.focus === "function") el.focus(); };
180
+
181
+ // ─── INTERNAL PARTS (not exported; see the header) ────────────────────────────
182
+
183
+ function ReadingProgress({ value, labelId }) {
184
+ const noMotion = useReducedMotion();
185
+ const pct = Math.round(value * 100);
186
+ return (
187
+ <div className="pds-ste__progress" style={{ display: "flex", flexDirection: "column", gap: T.progressGap }}>
188
+ <div style={{ display: "flex", justifyContent: "space-between", ...T.progressType, color: T.fgBase }}>
189
+ <span id={labelId}>Reading progress</span>
190
+ <span aria-hidden="true">{pct}%</span>
191
+ </div>
192
+ <div role="progressbar" aria-labelledby={labelId} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}
193
+ style={{ height: T.progressHeight, borderRadius: T.pillRadius, background: T.progressTrack, overflow: "hidden" }}>
194
+ <div style={{ width: `${pct}%`, height: "100%", borderRadius: T.pillRadius, background: T.progressFill,
195
+ transition: noMotion ? "none" : T.progressMotion }} />
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /* The container always renders so aria-describedby always has a target. The
202
+ alert inside is keyed by `nonce`, so a repeat tap announces again. */
203
+ function FieldMessage({ id, text, nonce }) {
204
+ return (
205
+ <div id={id}>
206
+ {text && (
207
+ <div key={nonce} role="alert" style={{
208
+ display: "flex", alignItems: "flex-start", gap: T.messageGap, padding: T.messagePad,
209
+ borderRadius: T.messageRadius, background: T.messageFill, color: T.messageFg, ...T.messageType,
210
+ }}>
211
+ <Icon name="error" size="M" fill={1} />
212
+ <span>{text}</span>
213
+ </div>
214
+ )}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ function Hint() {
220
+ return (
221
+ <div aria-hidden="true" style={{ position: "absolute", left: 0, right: 0, bottom: T.hintOffset,
222
+ display: "flex", justifyContent: "center", pointerEvents: "none" }}>
223
+ <span style={{ display: "inline-flex", alignItems: "center", gap: T.hintGap, padding: T.hintPad,
224
+ borderRadius: T.pillRadius, background: T.hintFill, boxShadow: T.hintShadow,
225
+ border: `${T.borderWidth} solid ${T.hintStroke}`, ...T.hintType, color: T.fgStrong }}>
226
+ <Icon name="arrow_downward" size="M" />Scroll to continue
227
+ </span>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ /* The notice box: optional Print, the repo Scrollable, the focusable region
233
+ inside it, and the hint. Reports progress 0..1 through onProgress. */
234
+ function Notice({ children, height, titleId, regionRef, showPrint, onPrint, showHint, onProgress, startAt }) {
235
+ const boxRef = useRef(null);
236
+ const [ring, setRing] = useState(false);
237
+
238
+ const report = useCallback((view) => {
239
+ if (!view) return;
240
+ const max = view.scrollHeight - view.clientHeight;
241
+ onProgress(max <= T.readThreshold ? 1 : Math.min(1, view.scrollTop / (max - T.readThreshold)));
242
+ }, [onProgress]);
243
+
244
+ // Measure on mount and whenever the box resizes: a notice that fits is read.
245
+ useLayoutEffect(() => {
246
+ const view = boxRef.current && boxRef.current.querySelector(".pds-scrollable__view");
247
+ if (!view) return;
248
+ if (startAt != null) view.scrollTop = (view.scrollHeight - view.clientHeight) * startAt;
249
+ report(view);
250
+ if (typeof ResizeObserver === "undefined") return;
251
+ const ro = new ResizeObserver(() => report(view));
252
+ ro.observe(view);
253
+ return () => ro.disconnect();
254
+ }, [report, height, startAt]);
255
+
256
+ const onFocus = (e) => {
257
+ let visible = true;
258
+ try { visible = e.target.matches(":focus-visible") || e.target.dataset.moved === "1"; } catch (_) { /* old engines: show it */ }
259
+ delete e.target.dataset.moved;
260
+ setRing(visible);
261
+ };
262
+
263
+ return (
264
+ <div ref={boxRef} className="pds-ste__notice"
265
+ // Scrollable exposes no scroll callback; the scroll event is caught here.
266
+ onScrollCapture={(e) => report(e.target)}
267
+ style={{ position: "relative", background: T.noticeFill, border: `${T.borderWidth} solid ${T.noticeStroke}`,
268
+ borderRadius: T.noticeRadius,
269
+ outline: ring ? `${T.focusWidth} solid ${T.focusRing}` : "none", outlineOffset: T.focusOffset }}>
270
+ {showPrint && (
271
+ <div style={{ padding: T.toolbarPad }}>
272
+ <Button buttonStyle="Naked" type="Secondary" size="S" text="Print" showLeadingIcon leadingIcon="print" onClick={onPrint} />
273
+ </div>
274
+ )}
275
+ <Scrollable style={{ height }}>
276
+ <div ref={regionRef} className="pds-ste__region" role="region" tabIndex={0} aria-labelledby={titleId}
277
+ onFocus={onFocus} onBlur={() => setRing(false)}
278
+ style={{ padding: T.contentPad, display: "flex", flexDirection: "column", gap: T.paragraphGap, ...T.noticeType, color: T.fgStrong }}>
279
+ {children}
280
+ </div>
281
+ </Scrollable>
282
+ {showHint && <Hint />}
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /* Modal bottom sheet on the Radix Dialog anatomy: Overlay, Content, Title, Close. */
288
+ function Sheet({ title, titleId, stepLabel, onClose, children, footer, initialFocusRef, contained }) {
289
+ const noMotion = useReducedMotion();
290
+ const contentRef = useRef(null);
291
+
292
+ useEffect(() => {
293
+ if (contained) return undefined; // documentation frames: no scroll lock, no focus grab
294
+ const prevOverflow = document.body.style.overflow;
295
+ document.body.style.overflow = "hidden";
296
+ const t = setTimeout(() => focusEl(initialFocusRef.current), 0);
297
+ return () => { clearTimeout(t); document.body.style.overflow = prevOverflow; };
298
+ }, [initialFocusRef, contained]);
299
+
300
+ const onKeyDown = (e) => {
301
+ if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
302
+ if (e.key !== "Tab" || !contentRef.current) return;
303
+ const els = contentRef.current.querySelectorAll("button, input, [tabindex='0']");
304
+ if (!els.length) return;
305
+ const first = els[0], last = els[els.length - 1];
306
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); focusEl(last); }
307
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); focusEl(first); }
308
+ };
309
+
310
+ return (
311
+ <div className="pds-ste-sheet" style={{ position: contained ? "absolute" : "fixed", inset: 0, zIndex: contained ? 1 : 1000, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
312
+ <div className="pds-ste-sheet__scrim" onClick={onClose}
313
+ style={{ position: "absolute", inset: 0, background: T.scrim, animation: noMotion ? "none" : T.scrimMotion }} />
314
+ <div ref={contentRef} role="dialog" aria-modal="true" aria-labelledby={titleId} onKeyDown={onKeyDown}
315
+ style={{ position: "relative", background: T.sheetFill, boxShadow: T.sheetShadow,
316
+ borderRadius: `${T.sheetRadius} ${T.sheetRadius} 0 0`, padding: T.sheetPad,
317
+ display: "flex", flexDirection: "column", gap: T.sheetGap, maxHeight: "85vh",
318
+ animation: noMotion ? "none" : T.sheetMotion }}>
319
+ <div className="pds-ste-sheet__header" style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
320
+ paddingLeft: T.sheetHeaderPad, paddingRight: T.sheetHeaderPad }}>
321
+ <span style={{ ...T.progressType, color: T.fgBase }}>{stepLabel}</span>
322
+ <Button buttonStyle="Naked" type="Secondary" size="S" showText={false} showLeadingIcon leadingIcon="close"
323
+ text="Close" ariaLabel="Close" onClick={onClose} />
324
+ </div>
325
+ <h2 id={titleId} style={{ margin: 0, textAlign: "center", ...T.sheetTitleType, color: T.fgStrong }}>{title}</h2>
326
+ {children}
327
+ {footer}
328
+ </div>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ // ─── SCROLL-TO-ENABLE ─────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * @param {string} title Notice title; also the sheet title and the region's name
337
+ * @param {ReactNode} children The notice content (paragraphs)
338
+ * @param {string} agreementLabel Checkbox label
339
+ * @param {function} onContinue Called when the notice is read and the box is ticked
340
+ * @param {"auto"|"inline"|"sheet"} layout auto picks by viewport at 768
341
+ * @param {number} noticeHeight Scroll area height in px (default 200)
342
+ * @param {boolean} showPrint Show Print in the inline layout (never in the sheet)
343
+ * @param {function} onPrint Print handler; defaults to window.print()
344
+ * @param {string} continueLabel
345
+ * @param {string} readAgreementLabel Sheet trigger before reading
346
+ * @param {string} readLabel Sheet trigger after reading
347
+ * @param {string} doneReadingLabel
348
+ * @param {string} stepLabel Optional label at the left of the sheet header
349
+ * @param {object} messages Overrides for MESSAGES
350
+ * @param {function} onAgreeChange Called with the checkbox value when it changes
351
+ * @param {string} demoState Documentation only: start in a Figma state (DEMO_STATES)
352
+ * @param {string} className
353
+ */
354
+ export function ScrollToEnable({
355
+ title,
356
+ children,
357
+ agreementLabel,
358
+ onContinue,
359
+ layout = "auto",
360
+ noticeHeight = T.noticeHeight,
361
+ showPrint = true,
362
+ onPrint,
363
+ continueLabel = "Continue",
364
+ readAgreementLabel = "Read Agreement",
365
+ readLabel = "Agreement read",
366
+ doneReadingLabel = "Done Reading",
367
+ stepLabel,
368
+ messages,
369
+ onAgreeChange,
370
+ demoState,
371
+ className = "",
372
+ }) {
373
+ ensureStyles();
374
+ const mode = useAutoLayout(layout);
375
+ const uid = useId().replace(/:/g, "");
376
+ const ids = {
377
+ title: `${uid}-title`, progress: `${uid}-rp`, msg: `${uid}-msg`, sheetTitle: `${uid}-sheet-title`,
378
+ sheetMsg: `${uid}-sheet-msg`, checkbox: `${uid}-cb`,
379
+ };
380
+ const copy = { ...MESSAGES, ...messages };
381
+ const seed = (demoState && DEMO_STATES[demoState]) || {};
382
+ const contained = !!demoState;
383
+ const seedMsg = seed.msg === "blocked" ? (mode === "inline" ? "scrollInline" : "readAgreement") : (seed.msg || null);
384
+
385
+ const [progress, setProgress] = useState(seed.progress || 0);
386
+ const [read, setRead] = useState(!!seed.read);
387
+ const [checked, setChecked] = useState(!!seed.checked);
388
+ const [msg, setMsg] = useState(seedMsg); // key of MESSAGES on the page
389
+ const [sheetMsg, setSheetMsg] = useState(seed.sheetMsg || null);
390
+ const [nonce, setNonce] = useState(0);
391
+ const [sheetOpen, setSheetOpen] = useState(!!seed.sheetOpen);
392
+ const [missing, setMissing] = useState(!!seed.missing);
393
+
394
+ const regionRef = useRef(null);
395
+ const sheetRegionRef = useRef(null);
396
+ const triggerRef = useRef(null);
397
+
398
+ const say = (key) => { setMsg(key); setNonce((n) => n + 1); };
399
+ const moveFocus = (el) => { if (el) { el.dataset.moved = "1"; focusEl(el); } };
400
+ const triggerButton = () => triggerRef.current && triggerRef.current.querySelector("button");
401
+
402
+ // Progress never goes back; reaching the end inline marks the notice read.
403
+ const onInlineProgress = useCallback((v) => {
404
+ setProgress((p) => Math.max(p, v));
405
+ if (v >= 1) { setRead(true); setMsg((m) => (m === "scrollInline" ? null : m)); }
406
+ }, []);
407
+ const onSheetProgress = useCallback((v) => {
408
+ setProgress((p) => Math.max(p, v));
409
+ if (v >= 1) setSheetMsg(null);
410
+ }, []);
411
+
412
+ const blocked = () => {
413
+ if (mode === "inline") { say("scrollInline"); moveFocus(regionRef.current); }
414
+ else { say("readAgreement"); focusEl(triggerButton()); }
415
+ };
416
+
417
+ const onCheck = (next) => {
418
+ if (!read) { blocked(); return; }
419
+ setChecked(next);
420
+ if (next) { setMsg(null); setMissing(false); }
421
+ if (onAgreeChange) onAgreeChange(next);
422
+ };
423
+
424
+ const onContinueClick = () => {
425
+ if (!read) { blocked(); return; }
426
+ if (!checked) { setMissing(true); say("agree"); focusEl(document.getElementById(ids.checkbox)); return; }
427
+ if (onContinue) onContinue();
428
+ };
429
+
430
+ const closeSheet = () => { setSheetOpen(false); setSheetMsg(null); setTimeout(() => focusEl(triggerButton()), 0); };
431
+
432
+ const onDoneReading = () => {
433
+ if (progress < 1) { setSheetMsg("scrollSheet"); setNonce((n) => n + 1); moveFocus(sheetRegionRef.current); return; }
434
+ setRead(true); setMsg(null); closeSheet();
435
+ };
436
+
437
+ const print = onPrint || (() => { if (typeof window !== "undefined") window.print(); });
438
+
439
+ const agreement = (
440
+ <div className="pds-ste__agreement" style={{ display: "flex", flexDirection: "column", gap: T.agreementGap }}>
441
+ <Checkbox id={ids.checkbox} checked={checked} error={missing && !checked} label={agreementLabel}
442
+ onChange={onCheck} describedBy={ids.msg} />
443
+ <FieldMessage id={ids.msg} text={msg ? copy[msg] : null} nonce={nonce} />
444
+ </div>
445
+ );
446
+
447
+ const actions = (
448
+ <div className="pds-ste__actions" style={{ display: "flex", justifyContent: "center" }}>
449
+ <Button buttonStyle="Fill" type="Primary" size="L" text={continueLabel} onClick={onContinueClick} aria-describedby={ids.msg} />
450
+ </div>
451
+ );
452
+
453
+ const root = { display: "flex", flexDirection: "column", gap: T.sectionGap, minWidth: T.minWidth, width: "100%" };
454
+
455
+ if (mode === "inline") {
456
+ return (
457
+ <div className={`pds-ste ${className}`} style={root}>
458
+ <ReadingProgress value={progress} labelId={ids.progress} />
459
+ <div className="pds-ste__notice-group" style={{ display: "flex", flexDirection: "column", gap: T.titleGap }}>
460
+ <div id={ids.title} style={{ ...T.titleType, color: T.fgStrong }}>{title}</div>
461
+ <Notice height={noticeHeight} titleId={ids.title} regionRef={regionRef} showPrint={showPrint}
462
+ onPrint={print} showHint={!read} onProgress={onInlineProgress} startAt={seed.read ? 1 : seed.progress}>
463
+ {children}
464
+ </Notice>
465
+ </div>
466
+ {agreement}
467
+ {actions}
468
+ </div>
469
+ );
470
+ }
471
+
472
+ return (
473
+ <div className={`pds-ste ${className}`}
474
+ style={contained && sheetOpen ? { ...root, position: "relative", minHeight: T.sheetFrameHeight } : root}>
475
+ <span ref={triggerRef} style={{ display: "flex" }}>
476
+ <Button buttonStyle="Fill" type="Tertiary" size="M" text={read ? readLabel : readAgreementLabel}
477
+ showTrailingIcon trailingIcon={read ? "check" : "open_in_new"} aria-haspopup="dialog"
478
+ onClick={() => { setSheetOpen(true); setSheetMsg(null); }} />
479
+ </span>
480
+ {agreement}
481
+ {actions}
482
+ {sheetOpen && (
483
+ <Sheet title={title} titleId={ids.sheetTitle} stepLabel={stepLabel} onClose={closeSheet} initialFocusRef={sheetRegionRef} contained={contained}
484
+ footer={
485
+ <div style={{ display: "flex", justifyContent: "center" }}>
486
+ <Button buttonStyle="Fill" type="Primary" size="L" text={doneReadingLabel} onClick={onDoneReading} aria-describedby={ids.sheetMsg} />
487
+ </div>
488
+ }>
489
+ <Notice height={noticeHeight} titleId={ids.sheetTitle} regionRef={sheetRegionRef} showPrint={false}
490
+ showHint={progress < 1} onProgress={onSheetProgress} startAt={seed.sheetOpen ? seed.progress : undefined}>
491
+ {children}
492
+ </Notice>
493
+ <FieldMessage id={ids.sheetMsg} text={sheetMsg ? copy[sheetMsg] : null} nonce={nonce} />
494
+ </Sheet>
495
+ )}
496
+ </div>
497
+ );
498
+ }
499
+
500
+ export default ScrollToEnable;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -18,7 +18,8 @@
18
18
  "./nav-shell": "./components/nav-shell/nav-shell.jsx",
19
19
  "./page-template": "./components/page-template/page-template.jsx",
20
20
  "./tooltip": "./components/tooltip/tooltip.jsx",
21
- "./badge": "./components/badge/badge.jsx"
21
+ "./badge": "./components/badge/badge.jsx",
22
+ "./scroll-to-enable": "./components/scroll-to-enable/scroll-to-enable.jsx"
22
23
  },
23
24
  "peerDependencies": {
24
25
  "react": ">=17",