@helloimjolopez-pathway/pathway-components 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Dropdown: a trigger and the menu it opens.
3
+ *
4
+ * WHY THIS EXISTS. The KPI Tiles set nests a FOREIGN component here:
5
+ * `Dropdown` 40003188:9930, a remote library component, eighteen instances
6
+ * inside a Pathway component. Like the Featured icon it is from the same
7
+ * third-party library and cannot be Code Connected from this repo, because a
8
+ * mapping publishes into the file that owns the component.
9
+ *
10
+ * WHAT THE FOREIGN COMPONENT ACTUALLY IS, measured 2026-10-08. Six variants on
11
+ * two axes, `Type` (Button | Icon | Avatar) and `Open` (False | True). The one
12
+ * the KPI Tile uses, `Type=Icon, Open=False`, walks to:
13
+ *
14
+ * Type=Icon, Open=False [COMPONENT 20x20]
15
+ * dots-vertical [INSTANCE 20x20]
16
+ *
17
+ * That is all of it. No box, no fill, no border, no radius: a bare 20px glyph.
18
+ * So eighteen instances of a foreign component were carrying a single icon.
19
+ *
20
+ * WHICH MEANS THE TRIGGER WAS NEVER THE HARD PART. Pathway already has the
21
+ * icon-button primitive in `ActionIcon`, and what the system was missing is the
22
+ * MENU: the `Open=True` half, which nothing in this repo had as a reusable
23
+ * piece. `dashboard.jsx` grew a private `WidgetMenu` for exactly this and it
24
+ * could not be reused.
25
+ *
26
+ * So this component is the pairing. The trigger is whichever of the three
27
+ * types the caller asks for, the menu is one implementation, and `open` is the
28
+ * Figma axis.
29
+ *
30
+ * THE PANEL IS PATHWAY'S OWN `PopoverMenu`, WHICH ALREADY EXISTED.
31
+ *
32
+ * This is the part worth reading. The foreign Dropdown's `Open=True` variants
33
+ * are that library's menu, so copying them would import a second system's
34
+ * panel, and the first version of this file invented a third. Neither was
35
+ * necessary: the `↳ ❇️ Menu` page has had a complete Pathway menu all along and
36
+ * nothing in this repo had ever implemented it.
37
+ *
38
+ * PopoverMenu 40005568:4207 the panel
39
+ * PopoverMenu.Item 40005568:4191 3 variants on State
40
+ * PopoverMenu.SectionLabel 40005748:20503
41
+ * Menu.Divider 40006377:69269
42
+ *
43
+ * So `dashboard.jsx`'s private `WidgetMenu` and this file's first panel were
44
+ * both reinventing a component the design system already had. The measurements
45
+ * below are that component's, read on 2026-10-08:
46
+ *
47
+ * PopoverMenu 212 wide, VERTICAL, pad 8, r=8, Fill/Surface/Elevated,
48
+ * Stroke/Static/Neutral/Base, shadow 0 4 16 -2 at a0.08.
49
+ * Its inner Menu slot is VERTICAL, gap 8, pad 6.
50
+ * Item 200x40, HORIZONTAL, pad 6,14,6,14, r=8, label fs 14.
51
+ * Base fill Fill/Surface/Elevated,
52
+ * label Foreground/Action/Secondary/Rest
53
+ * Hover fill Fill/Action/Selection/Hover,
54
+ * label Foreground/Action/Secondary/Hover
55
+ * Label Wrapper gap 4 pad 0,0,0,8; leading icon 20 box / 14 glyph.
56
+ * Divider VERTICAL pad 6,0,6,0, a line on Stroke/Static/Neutral/Base.
57
+ *
58
+ * ONE GAP: the panel's shadow is 0 4 16 -2 and Pathway's nearest token is
59
+ * --elevation-sheet, 0 4 32 -8. Same offset, different blur, and there is no
60
+ * exact token. Used the nearest rather than inventing one; recorded in the spec.
61
+ */
62
+ import React, { createContext, useContext, useEffect, useId, useRef, useState } from "react";
63
+ import { Icon } from "../icon/icon.jsx";
64
+ import { ActionIcon } from "../icon/action-icon.jsx";
65
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
66
+
67
+ const C = (n) => `var(--semantic-color-${n})`;
68
+ const U = (n) => `var(--semantic-layout-units-${n})`;
69
+ const Y = (n) => `var(--semantic-type-${n})`;
70
+ const M = (n) => `var(--motion-${n})`;
71
+
72
+ export const DROPDOWN_TYPES = ["icon", "button", "avatar"];
73
+
74
+ export const T = {
75
+ panel: C("fill-surface-elevated"),
76
+ panelBorder: C("stroke-static-neutral-base"),
77
+ // Menu.Divider strokes with the BASE neutral, not the faint one.
78
+ divider: C("stroke-static-neutral-base"),
79
+ // PopoverMenu.Item's label is an ACTION foreground, not a static one: the
80
+ // item is interactive, and State=Hover moves it from Rest to Hover.
81
+ item: C("foreground-action-secondary-rest"),
82
+ itemFgHover: C("foreground-action-secondary-hover"),
83
+ // State=Hover fills with Action/SELECTION/Hover, which is the selection
84
+ // family rather than the secondary one. Measured.
85
+ itemHover: C("fill-action-selection-hover"),
86
+ danger: C("foreground-static-negative-on-subtle"),
87
+ subtle: C("foreground-static-neutral-subtle"),
88
+ // The Icon type is a bare 20px glyph in Figma, so its colour is inherited
89
+ // rather than set: it takes whatever the tile around it is using.
90
+ trigger: C("foreground-action-secondary-rest"),
91
+ triggerHover: C("foreground-action-secondary-hover"),
92
+ buttonFill: C("fill-surface-elevated"),
93
+ buttonBorder: C("stroke-action-secondary-rest"),
94
+ avatarFill: C("fill-action-primary-subtle-rest"),
95
+ avatarText: C("foreground-action-primary-on-subtle-rest"),
96
+ };
97
+
98
+ export const L = {
99
+ // Figma: the Icon trigger is 20x20 with a 20x20 glyph and nothing around it.
100
+ iconBox: 20,
101
+ iconGlyph: 20,
102
+ // PopoverMenu: 212 wide, pad 8, r=8. Its inner slot is gap 8, pad 6.
103
+ panelMinW: 212,
104
+ radius: U("cornerradius-base"),
105
+ border: "var(--semantic-layout-units-borderwidth-base)",
106
+ panelPad: U("padding-xtight"),
107
+ slotPad: U("padding-xxtight"),
108
+ slotGap: U("gap-xtight"),
109
+ // PopoverMenu.Item: 40 tall, pad 6,14,6,14, label gap 4 with an 8 inset.
110
+ itemH: 40,
111
+ itemPadV: U("padding-xxtight"),
112
+ // Figma's item pad is 6,14,6,14, and 14 IS a rung: Padding/Medium. This read
113
+ // Padding/Tight, 12, under a comment asserting the ladder went 12 then 16,
114
+ // which is the gap ladder and not the padding ladder. Corrected 2026-10-08.
115
+ itemPadH: U("padding-medium"),
116
+ itemInset: U("padding-xtight"),
117
+ gap: U("gap-xxtight"),
118
+ // Menu.Divider: pad 6,0,6,0.
119
+ dividerPadV: U("padding-xxtight"),
120
+ // Leading icon: a 20 box holding a 14 glyph.
121
+ leadingBox: 20,
122
+ leadingGlyph: 14,
123
+ offset: 4,
124
+ buttonPadV: U("padding-xtight"),
125
+ buttonPadH: U("padding-tight"),
126
+ avatarBox: 28,
127
+ };
128
+
129
+ /**
130
+ * One menu entry.
131
+ *
132
+ * @param {string} label
133
+ * @param {func} onSelect
134
+ * @param {boolean} danger Destructive, so it takes the negative foreground.
135
+ * @param {boolean} checked Draws a leading check. Used for a current value.
136
+ * @param {string} icon Figma's `Leading.Icon` swap, a Material ligature.
137
+ * @param {boolean} disabled
138
+ */
139
+ /**
140
+ * Lets an item close the menu it sits in.
141
+ *
142
+ * WHY A CONTEXT and not a prop: the items are passed in as `children`, so the
143
+ * Dropdown cannot reach their handlers without cloning the tree, and every call
144
+ * site would otherwise have to thread a close function through its own JSX.
145
+ * Found 2026-10-12: choosing an item ran its `onSelect` and left the panel
146
+ * open, so the Widget's menu stayed over the content after Refresh had already
147
+ * fired. Every menu in the system had it, because none of them closed.
148
+ */
149
+ export const DropdownClose = createContext(null);
150
+ /* Exported so a BottomSheet holding DropdownItems can close itself the same
151
+ way when an item is chosen (bottom-sheet.jsx). */
152
+
153
+ export function DropdownItem({ label, onSelect, danger, checked, disabled, note, icon }) {
154
+ const noMotion = useReducedMotion();
155
+ const close = useContext(DropdownClose);
156
+ const [hov, setHov] = useState(false);
157
+ return (
158
+ <button
159
+ type="button"
160
+ role="menuitem"
161
+ disabled={disabled}
162
+ aria-checked={checked === undefined ? undefined : !!checked}
163
+ onClick={(e) => { onSelect?.(e); close?.(); }}
164
+ onMouseEnter={() => setHov(true)}
165
+ onMouseLeave={() => setHov(false)}
166
+ style={{
167
+ display: "flex", alignItems: "center", gap: L.gap, width: "100%",
168
+ minHeight: L.itemH, boxSizing: "border-box",
169
+ textAlign: "left", padding: `${L.itemPadV} ${L.itemPadH}`,
170
+ borderRadius: L.radius,
171
+ background: hov && !disabled ? T.itemHover : "transparent",
172
+ border: "none", cursor: disabled ? "default" : "pointer",
173
+ opacity: disabled ? 0.5 : 1,
174
+ // The label follows State, Rest to Hover, which is why it is an action
175
+ // foreground rather than a static one.
176
+ color: danger ? T.danger : (hov && !disabled ? T.itemFgHover : T.item),
177
+ fontFamily: "inherit",
178
+ fontSize: Y("font-size-s"), lineHeight: Y("line-height-s-single"),
179
+ letterSpacing: Y("letter-spacing-spacious"),
180
+ transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}, color ${M("duration-2")} ${M("easing-standard")}`,
181
+ }}
182
+ >
183
+ {/* Container.LeadingIcon: a 20 box holding a 14 glyph. Figma gates it
184
+ with `Show Leading Icon`, which is `icon` or `checked` here. */}
185
+ {(icon || checked !== undefined) && (
186
+ <span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center",
187
+ width: L.leadingBox, height: L.leadingBox, flexShrink: 0 }}>
188
+ <Icon name={icon || (checked ? "check" : "")} size={L.leadingGlyph}
189
+ style={{ opacity: icon || checked ? 1 : 0 }} />
190
+ </span>
191
+ )}
192
+ <span style={{ flex: 1, minWidth: 0, paddingLeft: icon || checked !== undefined ? 0 : L.itemInset }}>{label}</span>
193
+ {note && <span style={{ color: T.subtle, fontSize: Y("font-size-xxs") }}>{note}</span>}
194
+ </button>
195
+ );
196
+ }
197
+
198
+ /** A rule between groups of items. */
199
+ /** Figma `Menu.Divider` 40006377:69269: a line with 6px above and below. */
200
+ export const DropdownDivider = () => (
201
+ <hr aria-hidden="true" style={{
202
+ border: 0, borderTop: `${L.border} solid ${T.divider}`,
203
+ margin: `${L.dividerPadV} 0`,
204
+ }} />
205
+ );
206
+
207
+ /**
208
+ * @param {"icon"|"button"|"avatar"} type Figma's Type axis.
209
+ * @param {boolean} open Figma's Open axis. Controlled when given, internal
210
+ * when not, so a story can show the open state and a
211
+ * product can own it.
212
+ * @param {node} children `DropdownItem`s and `DropdownDivider`s.
213
+ * @param {string} label The trigger's accessible name. Required for `icon`
214
+ * and `avatar`, which have no visible text.
215
+ * @param {string} text The `button` type's visible label.
216
+ * @param {string} initials The `avatar` type's initials.
217
+ * @param {string} icon The `icon` type's ligature. Figma draws
218
+ * dots-vertical, which is `more_vert`.
219
+ * @param {"start"|"end"} align Which edge of the trigger the panel lines up
220
+ * with. `end` by default: a trigger at the top-right
221
+ * of a card opens leftward or the panel leaves the
222
+ * card.
223
+ */
224
+ export function Dropdown({
225
+ type = "icon",
226
+ open: openProp,
227
+ onOpenChange,
228
+ children,
229
+ label,
230
+ text,
231
+ initials,
232
+ icon = "more_vert",
233
+ /* The Icon trigger's HIT AREA, which is not its glyph. Figma draws a bare
234
+ 20px glyph and puts a 44x44 `TouchTarget` frame around it; the frame is
235
+ there to be the target. Rendering only the glyph gave a 20x20 button,
236
+ under both Figma's 44 and WCAG 2.5.8's 24 floor, and the 44x44 wrapper the
237
+ KPI Tile drew around it was a plain span that swallowed nothing.
238
+ Found 2026-10-08 by measuring the rendered button. */
239
+ targetSize = 44,
240
+ /* HOW THE ICON TRIGGER IS DRAWN, which is not the same question as how big
241
+ its target is. Figma uses two different triggers for the same menu:
242
+
243
+ "bare" a 20px glyph, no box, no fill. The KPI tile's corner control.
244
+ "action" an Action Icon at Size=Base: 44 target, 28 state layer, 16
245
+ glyph, with the hover and pressed fills on the layer. The
246
+ Widget header's three controls are these.
247
+
248
+ Wiring the Widget's ellipsis to this component without the distinction
249
+ swapped a 16px glyph with a state layer for a bare 20px one, which is
250
+ visibly wrong in the header row next to Refresh and Open. */
251
+ iconStyle = "bare",
252
+ /* A caller-supplied trigger, for a control whose trigger is neither an icon,
253
+ a button nor an avatar. Called with the props it must spread onto whatever
254
+ element it renders, so the aria wiring and the open/close handling stay
255
+ here instead of being reimplemented per component. Trend's comparison
256
+ control is the first: its trigger is a 10px label plus a 12px chevron. */
257
+ renderTrigger,
258
+ align = "end",
259
+ className = "",
260
+ style,
261
+ }) {
262
+ const noMotion = useReducedMotion();
263
+ const controlled = openProp !== undefined;
264
+ const [openState, setOpenState] = useState(false);
265
+ const open = controlled ? openProp : openState;
266
+ const setOpen = (v) => { if (!controlled) setOpenState(v); onOpenChange?.(v); };
267
+
268
+ const wrap = useRef(null);
269
+ const panel = useRef(null);
270
+ const panelId = `pwdd${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
271
+ const [hov, setHov] = useState(false);
272
+
273
+ /* ARROW KEYS, because `role="menu"` promises them.
274
+ A `menu` whose children are `menuitem` tells a screen reader user that Up
275
+ and Down move between the items. Until 2026-10-13 they did not: the panel
276
+ handled Escape and nothing else, so the roles were a promise the component
277
+ did not keep, and the spec that said so was marked REVIEWED. Tab worked,
278
+ which is why it never looked broken to a mouse user.
279
+
280
+ Focus is moved rather than tracked in state: the items are real buttons, so
281
+ the browser's own focus is the single source of truth and there is no
282
+ `aria-activedescendant` to keep in sync. */
283
+ /* FOCUS RETURN. Once the arrow keys worked, focus lived on a menu item rather
284
+ than on the trigger, so unmounting the panel dropped focus to <body> and a
285
+ keyboard user was thrown to the top of the page. Verified in headless
286
+ Chrome on 2026-10-13: before this, Escape reported `focus: BODY`.
287
+ WCAG 2.4.3 and the APG menu-button pattern both want focus back on the
288
+ trigger. rAF rather than a bare call because the panel is still mounted at
289
+ the moment the handler runs. */
290
+ const wantFocusBack = useRef(false);
291
+ const enterFrom = useRef(null);
292
+
293
+ const closeAndReturnFocus = () => {
294
+ wantFocusBack.current =
295
+ !!panel.current?.contains(document.activeElement) ||
296
+ document.activeElement === document.body;
297
+ setOpen(false);
298
+ };
299
+
300
+ /* Restore in an EFFECT, not in the handler. The first attempt called
301
+ `trigger.focus()` from a requestAnimationFrame inside the close handler and
302
+ focus still landed on <body>: rAF runs before React has committed the
303
+ closed state, so the trigger node focus() touched was then reconciled and
304
+ the focus went with it. An effect keyed on `open` runs after the commit,
305
+ when the trigger node is the final one. */
306
+ useEffect(() => {
307
+ if (open || !wantFocusBack.current) return;
308
+ wantFocusBack.current = false;
309
+ wrap.current?.querySelector('[aria-haspopup="menu"]')?.focus();
310
+ }, [open]);
311
+
312
+ /* Opening by key lands on an item; opening by click leaves focus on the
313
+ trigger, because a mouse user has not asked to be moved. */
314
+ useEffect(() => {
315
+ if (!open || !enterFrom.current) return;
316
+ const at = enterFrom.current;
317
+ enterFrom.current = null;
318
+ moveFocus(0, at);
319
+ }, [open]);
320
+
321
+ const items = () =>
322
+ panel.current
323
+ ? [...panel.current.querySelectorAll('[role="menuitem"]:not([disabled])')]
324
+ : [];
325
+
326
+ const moveFocus = (delta, absolute) => {
327
+ const list = items();
328
+ if (!list.length) return;
329
+ const here = list.indexOf(document.activeElement);
330
+ let next;
331
+ if (absolute === "first") next = 0;
332
+ else if (absolute === "last") next = list.length - 1;
333
+ else if (here === -1) next = delta > 0 ? 0 : list.length - 1;
334
+ else next = (here + delta + list.length) % list.length; // wraps, as menus do
335
+ list[next].focus();
336
+ };
337
+
338
+ useEffect(() => {
339
+ if (!open) return;
340
+ const onKey = (e) => {
341
+ if (e.key === "Escape") { closeAndReturnFocus(); return; }
342
+ if (!panel.current || !panel.current.contains(document.activeElement)) {
343
+ /* Down from the trigger itself enters the menu at the first item, which
344
+ is the behaviour the APG menu-button pattern describes. */
345
+ if (e.key === "ArrowDown" && wrap.current?.contains(document.activeElement)) {
346
+ e.preventDefault(); moveFocus(1, "first");
347
+ }
348
+ return;
349
+ }
350
+ if (e.key === "ArrowDown") { e.preventDefault(); moveFocus(1); }
351
+ else if (e.key === "ArrowUp") { e.preventDefault(); moveFocus(-1); }
352
+ else if (e.key === "Home") { e.preventDefault(); moveFocus(0, "first"); }
353
+ else if (e.key === "End") { e.preventDefault(); moveFocus(0, "last"); }
354
+ };
355
+ const onDown = (e) => { if (wrap.current && !wrap.current.contains(e.target)) setOpen(false); };
356
+ document.addEventListener("keydown", onKey);
357
+ document.addEventListener("mousedown", onDown);
358
+ return () => {
359
+ document.removeEventListener("keydown", onKey);
360
+ document.removeEventListener("mousedown", onDown);
361
+ };
362
+ }, [open]);
363
+
364
+ const shared = {
365
+ "aria-haspopup": "menu",
366
+ "aria-expanded": open,
367
+ "aria-controls": open ? panelId : undefined,
368
+ onClick: () => setOpen(!open),
369
+ /* Down and Up OPEN the menu from a closed trigger, which is what the APG
370
+ menu-button pattern describes and what a keyboard user tries first.
371
+ Enter and Space already worked, being a native button. This lives on the
372
+ trigger rather than in the document listener because that listener is
373
+ only registered while the panel is open. `enterFrom` tells the open
374
+ effect which end to land on. */
375
+ onKeyDown: (e) => {
376
+ if (open) return;
377
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
378
+ e.preventDefault();
379
+ enterFrom.current = e.key === "ArrowDown" ? "first" : "last";
380
+ setOpen(true);
381
+ }
382
+ },
383
+ };
384
+
385
+ let trigger;
386
+ if (renderTrigger) {
387
+ trigger = renderTrigger({ ...shared, "aria-label": label, title: label });
388
+ } else if (type === "button") {
389
+ trigger = (
390
+ <button type="button" {...shared} aria-label={label}
391
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
392
+ style={{
393
+ display: "inline-flex", alignItems: "center", gap: L.gap,
394
+ padding: `${L.buttonPadV} ${L.buttonPadH}`, cursor: "pointer",
395
+ background: hov ? T.itemHover : T.buttonFill,
396
+ border: `${L.border} solid ${T.buttonBorder}`, borderRadius: L.radius,
397
+ color: T.trigger, fontFamily: "inherit",
398
+ fontSize: Y("font-size-s"), lineHeight: Y("line-height-s-single"),
399
+ letterSpacing: Y("letter-spacing-spacious"), whiteSpace: "nowrap",
400
+ transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
401
+ }}>
402
+ {text}
403
+ <Icon name={open ? "expand_less" : "expand_more"} size={16} />
404
+ </button>
405
+ );
406
+ } else if (type === "avatar") {
407
+ trigger = (
408
+ <button type="button" {...shared} aria-label={label}
409
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
410
+ style={{
411
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
412
+ width: L.avatarBox, height: L.avatarBox, borderRadius: L.radius,
413
+ background: T.avatarFill, color: T.avatarText, border: "none",
414
+ cursor: "pointer", fontFamily: "inherit", flexShrink: 0,
415
+ fontSize: Y("font-size-xxs"), fontWeight: Y("weight-semibold"),
416
+ letterSpacing: Y("letter-spacing-spacious"),
417
+ filter: hov ? "brightness(0.97)" : "none",
418
+ transition: noMotion ? "none" : `filter ${M("duration-2")} ${M("easing-standard")}`,
419
+ }}>
420
+ {initials}
421
+ </button>
422
+ );
423
+ } else {
424
+ /* Figma's Icon type DRAWS a bare 20px glyph: no box, no fill, no border.
425
+ So this is not an ActionIcon, which carries a visible state layer. But
426
+ the drawing and the target are different things: the glyph stays 20 and
427
+ the button is `targetSize`, which is the 44x44 TouchTarget every KPI
428
+ tile variant carries around it. */
429
+ trigger = iconStyle === "action" ? (
430
+ /* The real ActionIcon, so the state layer, its fills and the glyph size
431
+ come from that component rather than being reproduced here. */
432
+ <ActionIcon name={icon} size="Base" label={label}
433
+ onClick={shared.onClick}
434
+ aria-haspopup={shared["aria-haspopup"]}
435
+ aria-expanded={shared["aria-expanded"]}
436
+ aria-controls={shared["aria-controls"]} />
437
+ ) : (
438
+ <button type="button" {...shared} aria-label={label} title={label}
439
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
440
+ style={{
441
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
442
+ width: targetSize, height: targetSize, padding: 0, flexShrink: 0,
443
+ background: "transparent", border: "none", cursor: "pointer",
444
+ color: hov || open ? T.triggerHover : T.trigger,
445
+ transition: noMotion ? "none" : `color ${M("duration-2")} ${M("easing-standard")}`,
446
+ }}>
447
+ <Icon name={icon} size={L.iconGlyph} />
448
+ </button>
449
+ );
450
+ }
451
+
452
+ return (
453
+ <span ref={wrap} className={`pw-dropdown ${className}`} data-type={type}
454
+ data-open={open || undefined}
455
+ style={{ position: "relative", display: "inline-flex", ...style }}>
456
+ {trigger}
457
+ {open && (
458
+ <div id={panelId} ref={panel} role="menu" aria-label={label || text}
459
+ style={{
460
+ position: "absolute", top: `calc(100% + ${L.offset}px)`, zIndex: 30,
461
+ [align === "end" ? "right" : "left"]: 0,
462
+ minWidth: L.panelMinW, boxSizing: "border-box",
463
+ // PopoverMenu pad 8, with its inner Menu slot at pad 6 and gap 8.
464
+ padding: L.panelPad,
465
+ background: T.panel, border: `${L.border} solid ${T.panelBorder}`,
466
+ borderRadius: L.radius,
467
+ /* Figma's shadow is 0 4 16 -2 at a0.08. --elevation-sheet is
468
+ 0 4 32 -8: the same offset, a softer blur, and the nearest token
469
+ that exists. Recorded in the spec rather than inventing one. */
470
+ boxShadow: "var(--elevation-sheet)",
471
+ }}>
472
+ <div style={{ display: "flex", flexDirection: "column",
473
+ gap: L.slotGap, padding: L.slotPad }}>
474
+ <DropdownClose.Provider value={closeAndReturnFocus}>{children}</DropdownClose.Provider>
475
+ </div>
476
+ </div>
477
+ )}
478
+ </span>
479
+ );
480
+ }
481
+
482
+ export default Dropdown;
@@ -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 />);