@helloimjolopez-pathway/pathway-components 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/bottom-sheet/bottom-sheet-demo.jsx +145 -0
- package/components/bottom-sheet/bottom-sheet.jsx +433 -0
- package/components/checkbox/checkbox.jsx +392 -0
- package/components/dropdown/dropdown.jsx +482 -0
- package/components/scroll-to-enable/scroll-to-enable-demo.jsx +125 -0
- package/components/scroll-to-enable/scroll-to-enable.jsx +500 -0
- package/package.json +5 -2
|
@@ -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
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Pathway Design System React components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -18,7 +18,10 @@
|
|
|
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",
|
|
23
|
+
"./bottom-sheet": "./components/bottom-sheet/bottom-sheet.jsx",
|
|
24
|
+
"./dropdown": "./components/dropdown/dropdown.jsx"
|
|
22
25
|
},
|
|
23
26
|
"peerDependencies": {
|
|
24
27
|
"react": ">=17",
|