@helloimjolopez-pathway/pathway-components 0.3.1 → 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.
- package/components/checkbox/checkbox.jsx +392 -0
- package/components/nav-shell/nav-shell-demo.jsx +4 -3
- package/components/nav-shell/nav-shell.jsx +71 -12
- package/components/scroll-to-enable/scroll-to-enable-demo.jsx +125 -0
- package/components/scroll-to-enable/scroll-to-enable.jsx +500 -0
- package/components/sidenav/sidenav.jsx +11 -3
- package/package.json +3 -2
|
@@ -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;
|
|
@@ -28,15 +28,16 @@ function App() {
|
|
|
28
28
|
// The app switcher's on/off, the same switch as the web component's
|
|
29
29
|
// `app-switcher` attribute. It lives in the page so it works in every width.
|
|
30
30
|
const [appSwitcher, setAppSwitcher] = useState(false);
|
|
31
|
-
const [app, setApp] = useState("financials-home");
|
|
32
31
|
return (
|
|
33
32
|
<DemoChrome title="NavShell" widths={WIDTHS}
|
|
34
33
|
blurb={"TopNav, SideNav and the page area composed into one application frame. " +
|
|
35
34
|
"Desktop pushes the page with a collapsible nav, Tablet locks the 72px rail, " +
|
|
36
35
|
"Mobile opens the nav as an overlay from the hamburger. Tick App switcher to turn on the Accounting app switcher."}>
|
|
37
36
|
<NavShell activeModuleId="accounting" org={ORG} user={USER}
|
|
38
|
-
navSections={NAV_SECTIONS} activeNavId="balance_sheet"
|
|
39
|
-
|
|
37
|
+
navSections={NAV_SECTIONS} activeNavId="balance_sheet"
|
|
38
|
+
// NavShell tracks the current page and app itself. With the switcher on,
|
|
39
|
+
// each Accounting app brings its own side nav.
|
|
40
|
+
appSwitcher={appSwitcher ? ACCOUNTING_APP_SWITCHER : null}>
|
|
40
41
|
<PageTemplate>
|
|
41
42
|
<label style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)",
|
|
42
43
|
fontSize: Y("font-size-s"), lineHeight: Y("line-height-s-single"),
|
|
@@ -75,17 +75,31 @@ function useBreakpoint() {
|
|
|
75
75
|
* defaultCollapsed boolean — initial desktop collapse state (default false)
|
|
76
76
|
* children ReactNode — page content (renders inside Shell.Main)
|
|
77
77
|
*/
|
|
78
|
-
// The Accounting module's apps, as Figma's Accounting.AppSwitcher lists them
|
|
79
|
-
// The web component falls back to this when
|
|
80
|
-
// `appSwitcher` property has been set.
|
|
78
|
+
// The Accounting module's apps, as Figma's Accounting.AppSwitcher lists them,
|
|
79
|
+
// each with its own side nav. The web component falls back to this when
|
|
80
|
+
// `app-switcher` is on and no `appSwitcher` property has been set.
|
|
81
|
+
//
|
|
82
|
+
// Financials Home's nav is Figma's SideNav.Local.Accounting (40016737:141666),
|
|
83
|
+
// icons included. Figma draws no other app's nav, so Accounts Payable,
|
|
84
|
+
// Accounts Receivable and Bank Reconciliation use the item lists the widget
|
|
85
|
+
// demo read from production (Bank Reconciliation takes Bank Account
|
|
86
|
+
// Management's), on the same Figma icons.
|
|
87
|
+
const accountingNav = (app, labels) => labels.map((l) => ({ id: `${app}-${l.toLowerCase().replace(/\s+/g, "-")}`, label: l, icon: ACCOUNTING_ICONS[l] }));
|
|
88
|
+
const ACCOUNTING_ICONS = {
|
|
89
|
+
Dashboard: "space_dashboard", Enter: "input", Manage: "bookmark_manager", View: "visibility",
|
|
90
|
+
Reports: "insert_chart", Modify: "edit_square", Help: "help", Utilities: "tools_wrench",
|
|
91
|
+
"Security Setup": "security",
|
|
92
|
+
};
|
|
93
|
+
const APP_NAV_STANDARD = ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Utilities", "Help"];
|
|
81
94
|
export const ACCOUNTING_APP_SWITCHER = {
|
|
82
95
|
label: "Financials Home",
|
|
83
96
|
icon: "apps",
|
|
84
|
-
home: { id: "financials-home", label: "Financials Home"
|
|
97
|
+
home: { id: "financials-home", label: "Financials Home",
|
|
98
|
+
navItems: accountingNav("fh", ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Help", "Utilities", "Security Setup"]) },
|
|
85
99
|
apps: [
|
|
86
|
-
{ id: "accounts-payable",
|
|
87
|
-
{ id: "accounts-receivable", label: "Accounts Receivable" },
|
|
88
|
-
{ id: "bank-reconciliation", label: "Bank Reconciliation" },
|
|
100
|
+
{ id: "accounts-payable", label: "Accounts Payable", navItems: accountingNav("ap", APP_NAV_STANDARD) },
|
|
101
|
+
{ id: "accounts-receivable", label: "Accounts Receivable", navItems: accountingNav("ar", APP_NAV_STANDARD) },
|
|
102
|
+
{ id: "bank-reconciliation", label: "Bank Reconciliation", navItems: accountingNav("br", APP_NAV_STANDARD) },
|
|
89
103
|
],
|
|
90
104
|
};
|
|
91
105
|
|
|
@@ -132,6 +146,49 @@ export function NavShell({
|
|
|
132
146
|
// opened with a 72 rail inside it and 178px of blank.
|
|
133
147
|
const effectiveCollapsed = isTablet ? true : isMobile ? false : collapsed;
|
|
134
148
|
|
|
149
|
+
// ── Current page and current app ──────────────────────────────────────────
|
|
150
|
+
// Both follow their props when given, and otherwise NavShell tracks them
|
|
151
|
+
// itself, so a tap always moves the selection even when the host only
|
|
152
|
+
// listens to onNavigate (or does not listen at all). Before this the shell
|
|
153
|
+
// was purely controlled: a host that did not echo activeNavId back got a nav
|
|
154
|
+
// whose items looked dead.
|
|
155
|
+
const [navActive, setNavActive] = useState(activeNavId);
|
|
156
|
+
useEffect(() => { setNavActive(activeNavId); }, [activeNavId]);
|
|
157
|
+
|
|
158
|
+
const apps = appSwitcher ? [appSwitcher.home, ...(appSwitcher.apps || [])].filter(Boolean) : [];
|
|
159
|
+
const [appActive, setAppActive] = useState(appSwitcher && (appSwitcher.activeId ?? (appSwitcher.home && appSwitcher.home.id)));
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (appSwitcher && appSwitcher.activeId !== undefined) setAppActive(appSwitcher.activeId);
|
|
162
|
+
}, [appSwitcher && appSwitcher.activeId]);
|
|
163
|
+
const currentApp = apps.find((a) => a.id === appActive);
|
|
164
|
+
|
|
165
|
+
// An app that carries its own nav replaces the shell's: choosing Accounts
|
|
166
|
+
// Payable shows Accounts Payable's items. Apps without one keep the shell's.
|
|
167
|
+
const appNav = currentApp && (currentApp.navItems || currentApp.navSections);
|
|
168
|
+
const sideItems = appNav ? currentApp.navItems : navItems;
|
|
169
|
+
const sideSections = appNav ? currentApp.navSections : navSections;
|
|
170
|
+
// Figma shows the app's Dashboard active on arrival, so when the current page
|
|
171
|
+
// is not one of this app's items, its first item is the active one.
|
|
172
|
+
const appIds = appNav
|
|
173
|
+
? (currentApp.navItems || currentApp.navSections.flatMap((x) => x.items))
|
|
174
|
+
.flatMap((i) => [i.id, ...((i.children || []).map((c) => c.id))])
|
|
175
|
+
: null;
|
|
176
|
+
const sideActive = appIds && !appIds.includes(navActive) ? appIds[0] : navActive;
|
|
177
|
+
|
|
178
|
+
const handleNavigate = useCallback((id) => {
|
|
179
|
+
setNavActive(id);
|
|
180
|
+
onNavigate && onNavigate(id);
|
|
181
|
+
}, [onNavigate]);
|
|
182
|
+
|
|
183
|
+
const handleAppSelect = useCallback((id) => {
|
|
184
|
+
setAppActive(id);
|
|
185
|
+
appSwitcher && appSwitcher.onSelect && appSwitcher.onSelect(id);
|
|
186
|
+
// Land on the new app's first page (its Dashboard).
|
|
187
|
+
const next = apps.find((a) => a.id === id);
|
|
188
|
+
const first = next && (next.navItems ? next.navItems[0] : next.navSections && next.navSections[0] && next.navSections[0].items[0]);
|
|
189
|
+
if (first) handleNavigate(first.id);
|
|
190
|
+
}, [appSwitcher, apps, handleNavigate]);
|
|
191
|
+
|
|
135
192
|
// Shell.Main margin-left: matches current SideNav width for push layout.
|
|
136
193
|
// Mobile: no margin (SideNav is an overlay).
|
|
137
194
|
const mainMarginLeft = isMobile
|
|
@@ -240,17 +297,19 @@ export function NavShell({
|
|
|
240
297
|
}}
|
|
241
298
|
>
|
|
242
299
|
<SideNav
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
300
|
+
// Remount per app so one app's open groups do not carry into the next.
|
|
301
|
+
key={appNav ? currentApp.id : "shell"}
|
|
302
|
+
items={sideItems}
|
|
303
|
+
sections={sideSections}
|
|
304
|
+
activeId={sideActive}
|
|
305
|
+
onNavigate={handleNavigate}
|
|
247
306
|
collapsed={effectiveCollapsed}
|
|
248
307
|
onCollapseChange={handleCollapseChange}
|
|
249
308
|
// Mobile: the TopNav hamburger is the only toggle (NavHeader spec).
|
|
250
309
|
hideCollapseButton={isTablet || isMobile}
|
|
251
310
|
headerEnd={navHeaderEnd}
|
|
252
311
|
footer={navFooter}
|
|
253
|
-
appSwitcher={appSwitcher}
|
|
312
|
+
appSwitcher={appSwitcher ? { ...appSwitcher, activeId: appActive, onSelect: handleAppSelect } : null}
|
|
254
313
|
>
|
|
255
314
|
{navChildren}
|
|
256
315
|
</SideNav>
|
|
@@ -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;
|
|
@@ -235,9 +235,12 @@ export function SideNavItem({
|
|
|
235
235
|
: (isActive || isCollapsedActiveGrouper || isExpandedGrouper) ? T.text.navActive
|
|
236
236
|
: hovered ? T.text.navHover
|
|
237
237
|
: T.text.navBase;
|
|
238
|
+
// Figma SideNavItem State=Trail (an open grouper) binds Secondary/Pressed on
|
|
239
|
+
// the leading icon AND the chevron, as it does on the label. The icon used to
|
|
240
|
+
// stay on Rest/Hover there, so an open group's glyphs read lighter than its label.
|
|
238
241
|
const iconColor =
|
|
239
242
|
isDisabled ? T.icon.navDisabled
|
|
240
|
-
: (isActive || isCollapsedActiveGrouper) ? T.icon.navActive
|
|
243
|
+
: (isActive || isCollapsedActiveGrouper || isExpandedGrouper) ? T.icon.navActive
|
|
241
244
|
: hovered ? T.icon.navHover
|
|
242
245
|
: T.icon.navBase;
|
|
243
246
|
|
|
@@ -581,7 +584,12 @@ const APP_T = {
|
|
|
581
584
|
separator: c("stroke-static-neutral-base"),
|
|
582
585
|
};
|
|
583
586
|
|
|
584
|
-
export function AppSwitcher({ label, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false }) {
|
|
587
|
+
export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false }) {
|
|
588
|
+
// The trigger names WHERE YOU ARE: the current app's label, so after choosing
|
|
589
|
+
// Accounts Payable it reads Accounts Payable. `label` is only the fallback
|
|
590
|
+
// for when no app is current.
|
|
591
|
+
const current = [home, ...apps].filter(Boolean).find(a => a.id === activeId);
|
|
592
|
+
const label = (current && current.label) || fallbackLabel || (home && home.label);
|
|
585
593
|
const [hov, setHov] = useState(false);
|
|
586
594
|
const [press, setPress] = useState(false);
|
|
587
595
|
const [open, setOpen] = useState(false);
|
|
@@ -614,7 +622,7 @@ export function AppSwitcher({ label, icon = "apps", home, apps = [], activeId, o
|
|
|
614
622
|
const active = open || press;
|
|
615
623
|
const fg = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restFg;
|
|
616
624
|
const chevron = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restChevron;
|
|
617
|
-
const homeItem = home || { id: "__home", label };
|
|
625
|
+
const homeItem = home || { id: "__home", label: fallbackLabel };
|
|
618
626
|
|
|
619
627
|
return (
|
|
620
628
|
// Full column width in both states: 218 expanded, the rail's 48 collapsed,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helloimjolopez-pathway/pathway-components",
|
|
3
|
-
"version": "0.
|
|
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",
|