@helloimjolopez-pathway/pathway-components 0.4.0 → 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,145 @@
1
+ /**
2
+ * BottomSheet's standalone demo. Imports the real module, and the rows are the
3
+ * real DropdownItem and DropdownDivider, so nothing here can fall out of step
4
+ * with what ships: see components/_demo/harness.jsx for why demos are generated.
5
+ */
6
+ import React, { useRef, useState } from "react";
7
+ import { DemoChrome, mount } from "../_demo/harness.jsx";
8
+ import { BottomSheet } from "./bottom-sheet.jsx";
9
+ import { DropdownItem, DropdownDivider, DropdownClose } from "../dropdown/dropdown.jsx";
10
+ import { Button } from "../button/button.jsx";
11
+ import { ActionIcon } from "../icon/action-icon.jsx";
12
+
13
+ const U = (n) => `var(--semantic-layout-units-${n})`;
14
+ const C = (n) => `var(--semantic-color-${n})`;
15
+ const Y = (n) => `var(--semantic-type-${n})`;
16
+
17
+ const APPS = ["Financials Home", "Accounts Payable", "Accounts Receivable", "General Ledger", "Fixed Assets", "Purchasing Management"];
18
+ const ROWS = [
19
+ { id: 1, payee: "Grace Office Supply", amount: "$412.50" },
20
+ { id: 2, payee: "Northside Utilities", amount: "$1,208.00" },
21
+ { id: 3, payee: "Covenant Insurance", amount: "$3,940.12" },
22
+ ];
23
+
24
+ const caption = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
25
+
26
+ /* The real screen: Accounts Payable on a phone. The app switcher, a row's More
27
+ menu, and Filters each open a sheet. */
28
+ function InContext() {
29
+ const [app, setApp] = useState("Accounts Payable");
30
+ const [sheet, setSheet] = useState(null); // "apps" | { row } | "filters"
31
+ const [moving, setMoving] = useState(false); // the row menu swapped to "Move to fund"
32
+ const [log, setLog] = useState("");
33
+ const close = () => { setSheet(null); setMoving(false); };
34
+
35
+ const row = sheet && typeof sheet === "object" ? sheet.row : null;
36
+
37
+ return (
38
+ <section style={{ background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
39
+ borderRadius: U("cornerradius-large"), overflow: "hidden", maxWidth: 420, width: "100%", margin: "0 auto" }}>
40
+ <div style={{ padding: U("padding-base"), display: "flex", flexDirection: "column", gap: U("gap-base") }}>
41
+ <button type="button" aria-haspopup="dialog" onClick={() => setSheet("apps")}
42
+ style={{ font: "inherit", display: "flex", alignItems: "center", gap: U("gap-tight"), minHeight: 44, cursor: "pointer",
43
+ padding: `0 ${U("padding-tight")}`, borderRadius: U("cornerradius-base"), textAlign: "left",
44
+ border: `1px solid ${C("stroke-action-primary-strong-pressed")}`, background: C("fill-action-primary-subtle-pressed"),
45
+ color: C("foreground-action-primary-on-subtle-pressed"), fontSize: Y("font-size-s"), fontWeight: Y("weight-medium") }}>
46
+ <span className="material-symbols-rounded" aria-hidden="true" style={{ fontSize: 24 }}>apps</span>
47
+ <span style={{ flex: 1 }}>{app}</span>
48
+ <span className="material-symbols-rounded" aria-hidden="true" style={{ fontSize: 14 }}>chevron_right</span>
49
+ </button>
50
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
51
+ <h2 style={{ margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-strong") }}>Bills to pay</h2>
52
+ <Button buttonStyle="Outlined" type="Secondary" size="S" text="Filters" showLeadingIcon leadingIcon="filter_list"
53
+ aria-haspopup="dialog" onClick={() => setSheet("filters")} />
54
+ </div>
55
+ <div>
56
+ {ROWS.map((r) => (
57
+ <div key={r.id} style={{ display: "flex", alignItems: "center", gap: U("gap-tight"), padding: `${U("padding-xtight")} 0`,
58
+ borderBottom: `1px solid ${C("stroke-static-neutral-faint")}`, fontSize: Y("font-size-s"), color: C("foreground-static-neutral-base") }}>
59
+ <span style={{ flex: 1, minWidth: 0 }}>{r.payee}</span>
60
+ <span>{r.amount}</span>
61
+ <ActionIcon name="more_vert" size="Base" label={`More for ${r.payee}`} aria-haspopup="dialog"
62
+ onClick={() => setSheet({ row: r })} />
63
+ </div>
64
+ ))}
65
+ </div>
66
+ <p role="status" style={{ margin: 0, ...caption }}>{log || "Open the app switcher, a row's More menu, or Filters."}</p>
67
+ </div>
68
+
69
+ <BottomSheet open={sheet === "apps"} onOpenChange={(o) => !o && close()} title="Financial Applications" menu>
70
+ {APPS.map((a, i) => (
71
+ <React.Fragment key={a}>
72
+ <DropdownItem label={a} checked={a === app} onSelect={() => { setApp(a); setLog(`Switched to ${a}.`); }} />
73
+ {i === 0 && <DropdownDivider />}
74
+ </React.Fragment>
75
+ ))}
76
+ </BottomSheet>
77
+
78
+ <BottomSheet open={!!row} onOpenChange={(o) => !o && close()} menu
79
+ title={moving ? "Move to fund" : row?.payee}
80
+ showBack={moving} onBack={() => setMoving(false)}>
81
+ {moving ? (
82
+ ["General Fund", "Building Fund", "Missions Fund"].map((f) => (
83
+ <DropdownItem key={f} label={f} onSelect={() => setLog(`${row?.payee} moved to ${f}.`)} />
84
+ ))
85
+ ) : (
86
+ <>
87
+ <DropdownItem label="Edit" icon="edit" onSelect={() => setLog(`Edit ${row?.payee}.`)} />
88
+ <DropdownItem label="Duplicate" icon="content_copy" onSelect={() => setLog(`Duplicated ${row?.payee}.`)} />
89
+ {/* A follow-up choice swaps the Body in place; it never opens a second sheet. */}
90
+ <MoveItem onMove={() => setMoving(true)} />
91
+ <DropdownDivider />
92
+ <DropdownItem label="Delete" icon="delete" danger onSelect={() => setLog(`Delete ${row?.payee} would ask for confirmation.`)} />
93
+ </>
94
+ )}
95
+ </BottomSheet>
96
+
97
+ <Filters open={sheet === "filters"} onClose={close} onApply={(n) => setLog(`Showing ${n} results.`)} />
98
+ </section>
99
+ );
100
+ }
101
+
102
+ /* "Move to fund…" swaps the Body instead of closing the sheet, so it is the
103
+ real DropdownItem with the close context cleared. */
104
+ function MoveItem({ onMove }) {
105
+ return (
106
+ <DropdownClose.Provider value={null}>
107
+ <DropdownItem label="Move to fund…" icon="drive_file_move" onSelect={onMove} />
108
+ </DropdownClose.Provider>
109
+ );
110
+ }
111
+
112
+ function Filters({ open, onClose, onApply }) {
113
+ const [unpaid, setUnpaid] = useState(true);
114
+ const [overdue, setOverdue] = useState(false);
115
+ const count = 24 - (unpaid ? 0 : 6) - (overdue ? 10 : 0);
116
+ const reset = () => { setUnpaid(false); setOverdue(false); };
117
+ const Check = ({ label, on, set }) => (
118
+ <label style={{ display: "flex", alignItems: "center", gap: U("gap-medium"), minHeight: 44, padding: `0 ${U("padding-tight")}`,
119
+ fontSize: Y("font-size-s"), color: C("foreground-static-neutral-strong"), cursor: "pointer" }}>
120
+ <input type="checkbox" checked={on} onChange={(e) => set(e.target.checked)} /> {label}
121
+ </label>
122
+ );
123
+ return (
124
+ <BottomSheet open={open} onOpenChange={(o) => !o && onClose()} title="Filters"
125
+ footer={<>
126
+ <Button buttonStyle="Fill" type="Secondary" size="M" text="Reset" onClick={reset} />
127
+ <Button buttonStyle="Fill" type="Primary" size="M" text={`Show ${count} results`} onClick={() => { onApply(count); onClose(); }} />
128
+ </>}>
129
+ <Check label="Unpaid" on={unpaid} set={setUnpaid} />
130
+ <Check label="Overdue only" on={overdue} set={setOverdue} />
131
+ </BottomSheet>
132
+ );
133
+ }
134
+
135
+ function App() {
136
+ return (
137
+ <DemoChrome title="BottomSheet"
138
+ blurb={"The mobile way to show a menu, a switcher or a short set of options. Close, the overlay, Esc and a swipe down all close it, " +
139
+ "and focus returns to the control that opened it. Choosing an item closes it. Below 768 only: the host decides, so switch to Mobile."}>
140
+ <InContext />
141
+ </DemoChrome>
142
+ );
143
+ }
144
+
145
+ mount(<App />);
@@ -0,0 +1,433 @@
1
+ /**
2
+ * BottomSheet: the mobile way to show a menu, a switcher or a short set of
3
+ * options. A modal panel anchored to the bottom of the viewport, over a dimmed
4
+ * overlay.
5
+ *
6
+ * SOURCE OF TRUTH: Figma set `BottomSheet` 40018874:13685 on the
7
+ * `↳ ❇️ BottomSheet` page, variant Platform=Web 40018722:111, read 2026-11-10.
8
+ * Platform=iOS and Platform=Android are the operating system's own sheet
9
+ * frames; a browser has none, so the web draws Platform=Web and nothing else.
10
+ *
11
+ * BottomSheet (Platform=Web) Fill/Surface/Elevated, top corners
12
+ * CornerRadius/XLarge, bottom corners square,
13
+ * VERTICAL gap Gap/Tight, max width 640,
14
+ * effect style `BottomSheet`
15
+ * Container.Top
16
+ * Handle BottomSheet.Handle 40018716:5338: a 20 tall
17
+ * zone holding a 32x4 pill,
18
+ * Fill/Static/Neutral/Strong, CornerRadius/Full
19
+ * BottomSheet.Header 40018716:5340: min height Touch Target/AA,
20
+ * pad 8 / 2 / 4 / 2 (XTight, XXXXTight,
21
+ * XXXTight, XXXXTight), gap XXTight
22
+ * Back (Show Back) Action Icon Size=Base, `arrow_back`
23
+ * Container.Title pad left Padding/Base
24
+ * Title Heading/Local/Base/Semibold,
25
+ * Foreground/Static/Neutral/Strong
26
+ * Close Action Icon Size=Base, `close`
27
+ * Body (slot) pad 0 / XTight / XTight, gap XXTight,
28
+ * scrolls when taller than the space left
29
+ * Footer (Show Footer) BottomSheet.Footer 40018716:5354: top border
30
+ * Stroke/Static/Neutral/Faint at
31
+ * BorderWidth/Base, pad Tight / Base / Base,
32
+ * Actions slot right-aligned, gap Tight
33
+ * BottomSheet.Overlay 40018716:5380: Scrim/Subtle
34
+ *
35
+ * WHAT THIS COMPONENT IS NOT. It does not decide when a sheet is used. Figma
36
+ * says below 768 the content opens here and from 768 up the same content opens
37
+ * as a PopoverMenu; that choice belongs to the host (TopNav, SideNav, a row's
38
+ * More menu), which renders a BottomSheet or a Dropdown. It does not draw the
39
+ * rows either: the Body is a slot, and a menu in it is the real
40
+ * `DropdownItem` / `DropdownDivider` from dropdown.jsx, never a copy.
41
+ *
42
+ * WHY THERE IS NO DRAWER LIBRARY. The Figma description names Vaul Drawer on
43
+ * Radix Dialog. The component package ships with React as its only peer
44
+ * dependency, and Tooltip and Scroll-to-Enable both build their overlays
45
+ * without one, so this follows the Radix Dialog anatomy (Overlay, Content,
46
+ * Title, Close) with the behaviour written here: focus moved in and returned,
47
+ * Tab kept inside, Esc, page scroll locked, the rest of the page inert, and
48
+ * swipe down to close.
49
+ */
50
+ import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
51
+ import { createPortal } from "react-dom";
52
+ import { ActionIcon } from "../icon/action-icon.jsx";
53
+ import { DropdownClose } from "../dropdown/dropdown.jsx";
54
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
55
+
56
+ const C = (n) => `var(--semantic-color-${n})`;
57
+ const U = (n) => `var(--semantic-layout-units-${n})`;
58
+ const Y = (n) => `var(--semantic-type-${n})`;
59
+ const M = (n) => `var(--motion-${n})`;
60
+
61
+ export const T = {
62
+ surface: C("fill-surface-elevated"),
63
+ overlay: C("scrim-subtle"),
64
+ handle: C("fill-static-neutral-strong"),
65
+ title: C("foreground-static-neutral-strong"),
66
+ footerBorder: C("stroke-static-neutral-faint"),
67
+ /* Figma's effect style `BottomSheet` is a drop shadow of x -6, y 0, blur 36
68
+ on Cool Neutral/900 at 16%. It binds a PRIMITIVE colour and has no CSS
69
+ token. --elevation-sheet is the nearest token that exists, the same
70
+ choice Dropdown made for PopoverMenu's shadow. Kept by Jo, 2026-11-10. */
71
+ shadow: "var(--elevation-sheet)",
72
+ };
73
+
74
+ export const L = {
75
+ radius: U("cornerradius-xlarge"),
76
+ sheetGap: U("gap-tight"),
77
+ // Measurements, not tokens: the sheet stops at 640 and always leaves 56 of
78
+ // overlay showing above it so it reads as a layer over the page.
79
+ maxWidth: 640,
80
+ topClearance: 56,
81
+ handleZone: 20,
82
+ pillWidth: 32,
83
+ pillHeight: 4,
84
+ pillRadius: U("cornerradius-full"),
85
+ headerMinH: U("accessibility-touch-target-aa-height"),
86
+ headerPadTop: U("padding-xtight"),
87
+ headerPadX: U("padding-xxxxtight"),
88
+ headerPadBottom: U("padding-xxxtight"),
89
+ headerGap: U("gap-xxtight"),
90
+ titleInset: U("padding-base"),
91
+ bodyPad: U("padding-xtight"),
92
+ bodyGap: U("gap-xxtight"),
93
+ footerBorder: U("borderwidth-base"),
94
+ footerPadTop: U("padding-tight"),
95
+ footerPad: U("padding-base"),
96
+ footerGap: U("gap-tight"),
97
+ // Swipe: a drag shorter than this, or slower than the flick speed, springs
98
+ // back. Behaviour thresholds, not design values.
99
+ dismissDistance: 80,
100
+ flickSpeed: 0.5,
101
+ };
102
+
103
+ export const TYPE = {
104
+ // ❇️ Heading/Local/Base/Semibold
105
+ title: {
106
+ fontFamily: Y("family-brand"),
107
+ fontSize: Y("font-size-s"),
108
+ fontWeight: Y("weight-semibold"),
109
+ lineHeight: Y("line-height-s-tight"),
110
+ letterSpacing: Y("letter-spacing-wide"),
111
+ },
112
+ };
113
+
114
+ export const MOTION = {
115
+ enter: `pw-bottom-sheet-up ${M("duration-5")} ${M("easing-emphasized")} both`,
116
+ exit: `pw-bottom-sheet-down ${M("duration-4")} ${M("easing-accelerate")} both`,
117
+ fadeIn: `pw-bottom-sheet-fade-in ${M("duration-4")} ${M("easing-standard")} both`,
118
+ fadeOut: `pw-bottom-sheet-fade-out ${M("duration-4")} ${M("easing-standard")} both`,
119
+ settle: `transform ${M("duration-4")} ${M("easing-decelerate")}`,
120
+ };
121
+
122
+ const CSS =
123
+ "@keyframes pw-bottom-sheet-up{from{transform:translateY(100%)}to{transform:none}}" +
124
+ "@keyframes pw-bottom-sheet-down{from{transform:none}to{transform:translateY(100%)}}" +
125
+ "@keyframes pw-bottom-sheet-fade-in{from{opacity:0}to{opacity:1}}" +
126
+ "@keyframes pw-bottom-sheet-fade-out{from{opacity:1}to{opacity:0}}" +
127
+ ".pw-bottom-sheet__body{scrollbar-width:thin}";
128
+
129
+ function useInjectedStyles() {
130
+ useLayoutEffect(() => {
131
+ if (typeof document === "undefined" || document.getElementById("pw-bottom-sheet-css")) return;
132
+ const s = document.createElement("style");
133
+ s.id = "pw-bottom-sheet-css";
134
+ s.textContent = CSS;
135
+ document.head.appendChild(s);
136
+ }, []);
137
+ }
138
+
139
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
140
+ const MENU_ITEMS = '[role="menuitem"]:not([disabled]), [role="menuitemradio"]:not([disabled]), [role="menuitemcheckbox"]:not([disabled])';
141
+
142
+ /** BottomSheet.Handle: the drag affordance. Hidden from assistive technology:
143
+ * Close, Esc and the overlay are the accessible ways to close. */
144
+ export function BottomSheetHandle() {
145
+ return (
146
+ <div aria-hidden="true" data-part="handle"
147
+ style={{ height: L.handleZone, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
148
+ <div style={{ width: L.pillWidth, height: L.pillHeight, borderRadius: L.pillRadius, background: T.handle }} />
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /**
154
+ * BottomSheet.Header: title row. The title names the dialog. It wraps to two
155
+ * lines and then truncates. Close is always present; Back appears when the
156
+ * Body has been swapped in place.
157
+ */
158
+ export function BottomSheetHeader({ title, titleId, titleRef, showBack = false, onBack, onClose,
159
+ backLabel = "Back", closeLabel = "Close" }) {
160
+ return (
161
+ <div data-part="header" style={{
162
+ display: "flex", alignItems: "center", gap: L.headerGap, minHeight: L.headerMinH, boxSizing: "border-box",
163
+ padding: `${L.headerPadTop} ${L.headerPadX} ${L.headerPadBottom}`,
164
+ }}>
165
+ {showBack && <ActionIcon name="arrow_back" size="Base" label={backLabel} onClick={onBack} />}
166
+ <div style={{ flex: 1, minWidth: 0, paddingLeft: L.titleInset }}>
167
+ <h2 id={titleId} ref={titleRef} tabIndex={-1} style={{
168
+ margin: 0, outline: "none", color: T.title, ...TYPE.title,
169
+ overflowWrap: "anywhere", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden",
170
+ }}>{title}</h2>
171
+ </div>
172
+ <ActionIcon name="close" size="Base" label={closeLabel} onClick={onClose} />
173
+ </div>
174
+ );
175
+ }
176
+
177
+ /** BottomSheet.Footer: sticky actions, right-aligned. Pass Buttons as children. */
178
+ export function BottomSheetFooter({ children }) {
179
+ return (
180
+ <div data-part="footer" style={{
181
+ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: L.footerGap, flexShrink: 0,
182
+ borderTop: `${L.footerBorder} solid ${T.footerBorder}`,
183
+ padding: `${L.footerPadTop} ${L.footerPad} calc(${L.footerPad} + env(safe-area-inset-bottom, 0px))`,
184
+ }}>
185
+ {children}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ /** BottomSheet.Overlay: the dimmed layer behind the sheet. A tap closes. */
191
+ export function BottomSheetOverlay({ onClick, animation, opacity }) {
192
+ return (
193
+ <div data-part="overlay" aria-hidden="true" onClick={onClick}
194
+ style={{ position: "absolute", inset: 0, background: T.overlay, animation, opacity }} />
195
+ );
196
+ }
197
+
198
+ /**
199
+ * @param {boolean} open Controlled. The sheet animates out when this
200
+ * turns false, then unmounts.
201
+ * @param {function} onOpenChange Called with false when the sheet asks to
202
+ * close: Close, overlay, Esc, swipe, or a
203
+ * menu item chosen.
204
+ * @param {string} title Header Title. Also the dialog's name.
205
+ * @param {node} children The Body slot.
206
+ * @param {boolean} menu The Body is a menu: role=menu, arrow keys,
207
+ * Home and End. Use with DropdownItem rows.
208
+ * @param {boolean} showHandle Figma `Show Handle`. Default true.
209
+ * @param {node} footer BottomSheet.Footer's Actions. Figma
210
+ * `Show Footer` is true when this is given.
211
+ * @param {boolean} showBack Header `Show Back`, for a Body swapped in
212
+ * place. Never open a second sheet.
213
+ * @param {function} onBack Back pressed.
214
+ * @param {"auto"|"title"|"first"} initialFocus auto: in a menu, the checked
215
+ * item, else the first item; otherwise the
216
+ * title. Moving to the title is what a
217
+ * swapped Body needs.
218
+ * @param {object} returnFocusRef Where focus goes on close. Default: the
219
+ * element that had focus when it opened.
220
+ * @param {string} closeLabel Close's accessible name. Default "Close".
221
+ * @param {string} backLabel Back's accessible name. Default "Back".
222
+ * @param {Element} container Portal target. Default document.body.
223
+ * @param {boolean} contained Documentation frames only: draw the sheet
224
+ * inside its positioned parent instead of
225
+ * over the viewport, with no portal, no scroll
226
+ * lock, no inert page and no focus moved.
227
+ */
228
+ export function BottomSheet({
229
+ open,
230
+ onOpenChange,
231
+ title,
232
+ children,
233
+ menu = false,
234
+ showHandle = true,
235
+ footer,
236
+ showBack = false,
237
+ onBack,
238
+ initialFocus = "auto",
239
+ returnFocusRef,
240
+ closeLabel = "Close",
241
+ backLabel = "Back",
242
+ container,
243
+ contained = false,
244
+ className = "",
245
+ style,
246
+ }) {
247
+ useInjectedStyles();
248
+ const noMotion = useReducedMotion();
249
+ const titleId = `pwbs${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
250
+ const [rendered, setRendered] = useState(!!open);
251
+ const [leaving, setLeaving] = useState(false);
252
+ const [drag, setDrag] = useState(0);
253
+ const [dragging, setDragging] = useState(false);
254
+ const sheet = useRef(null);
255
+ const body = useRef(null);
256
+ const titleRef = useRef(null);
257
+ const host = useRef(null);
258
+ const opener = useRef(null);
259
+ const gesture = useRef(null);
260
+
261
+ const requestClose = () => onOpenChange?.(false);
262
+
263
+ // Mount on open; on close play the exit, then unmount.
264
+ useEffect(() => {
265
+ if (open) {
266
+ if (typeof document !== "undefined") opener.current = document.activeElement;
267
+ setLeaving(false); setDrag(0); setRendered(true);
268
+ } else if (rendered) {
269
+ setLeaving(true);
270
+ }
271
+ }, [open]);
272
+
273
+ const onAnimationEnd = (e) => {
274
+ if (e.target !== sheet.current || !leaving) return;
275
+ setRendered(false); setLeaving(false); setDrag(0);
276
+ focusBack.current = true;
277
+ };
278
+
279
+ /* Return focus once the page is no longer inert. Focusing the opener from the
280
+ animation handler itself ran while the inert effect still held, so focus
281
+ fell to <body>. This effect runs after that effect's cleanup. */
282
+ const focusBack = useRef(false);
283
+
284
+ // While open: lock page scroll and make everything outside the sheet inert,
285
+ // so assistive technology and Tab see only the dialog.
286
+ useEffect(() => {
287
+ if (!rendered || !host.current || contained) return undefined;
288
+ const root = host.current;
289
+ const overflow = document.body.style.overflow;
290
+ document.body.style.overflow = "hidden";
291
+ const parent = root.parentNode;
292
+ const inerted = [...(parent?.children || [])].filter((n) => n !== root && !n.inert && n.tagName !== "SCRIPT" && n.tagName !== "STYLE");
293
+ inerted.forEach((n) => { n.inert = true; });
294
+ return () => {
295
+ document.body.style.overflow = overflow;
296
+ inerted.forEach((n) => { n.inert = false; });
297
+ };
298
+ }, [rendered]);
299
+
300
+ useEffect(() => {
301
+ if (rendered || !focusBack.current) return;
302
+ focusBack.current = false;
303
+ const back = returnFocusRef?.current || opener.current;
304
+ if (back && back.isConnected && typeof back.focus === "function") back.focus();
305
+ }, [rendered]);
306
+
307
+ // Initial focus, after the sheet is in the DOM.
308
+ useEffect(() => {
309
+ if (!rendered || leaving || !sheet.current || contained) return;
310
+ const items = body.current ? [...body.current.querySelectorAll(MENU_ITEMS)] : [];
311
+ let target = titleRef.current;
312
+ if (initialFocus === "first") target = items[0] || sheet.current.querySelector(FOCUSABLE) || target;
313
+ else if (initialFocus === "auto" && menu && items.length) target = items.find((n) => n.getAttribute("aria-checked") === "true") || items[0];
314
+ target?.focus({ preventScroll: true });
315
+ }, [rendered, leaving]);
316
+
317
+ // A Body swapped in place moves focus to the new title.
318
+ const firstShowBack = useRef(true);
319
+ useEffect(() => {
320
+ if (firstShowBack.current) { firstShowBack.current = false; return; }
321
+ if (rendered && !contained) titleRef.current?.focus({ preventScroll: true });
322
+ }, [showBack]);
323
+
324
+ const onKeyDown = (e) => {
325
+ if (e.key === "Escape") {
326
+ // Stop here: a sheet over an open SideNav closes the sheet only.
327
+ e.stopPropagation(); e.nativeEvent?.stopImmediatePropagation?.();
328
+ requestClose(); return;
329
+ }
330
+ if (e.key === "Tab" && sheet.current) {
331
+ const els = [...sheet.current.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement);
332
+ if (!els.length) { e.preventDefault(); return; }
333
+ const first = els[0], last = els[els.length - 1];
334
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === titleRef.current)) { e.preventDefault(); last.focus(); }
335
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
336
+ return;
337
+ }
338
+ if (!menu || !body.current || !body.current.contains(document.activeElement)) return;
339
+ const list = [...body.current.querySelectorAll(MENU_ITEMS)];
340
+ if (!list.length) return;
341
+ const here = list.indexOf(document.activeElement);
342
+ let next = null;
343
+ if (e.key === "ArrowDown") next = (here + 1) % list.length;
344
+ else if (e.key === "ArrowUp") next = (here - 1 + list.length) % list.length;
345
+ else if (e.key === "Home") next = 0;
346
+ else if (e.key === "End") next = list.length - 1;
347
+ if (next !== null) { e.preventDefault(); list[next].focus(); }
348
+ };
349
+
350
+ /* Swipe down to close. It starts anywhere on the sheet, but inside the Body
351
+ only when the Body is scrolled to the top, so it never fights the Body's
352
+ own scroll. It takes over only after 8px of downward travel, so a tap on a
353
+ row stays a tap. */
354
+ const onPointerDown = (e) => {
355
+ if (e.button !== undefined && e.button !== 0) return;
356
+ const inBody = body.current?.contains(e.target);
357
+ if (inBody && body.current.scrollTop > 0) return;
358
+ gesture.current = { y: e.clientY, t: performance.now(), id: e.pointerId, active: false };
359
+ };
360
+ const onPointerMove = (e) => {
361
+ const g = gesture.current;
362
+ if (!g || g.id !== e.pointerId) return;
363
+ const dy = e.clientY - g.y;
364
+ if (!g.active) {
365
+ if (dy < -4) { gesture.current = null; return; }
366
+ if (dy < 8) return;
367
+ g.active = true;
368
+ setDragging(true);
369
+ sheet.current?.setPointerCapture?.(e.pointerId);
370
+ }
371
+ setDrag(Math.max(0, dy));
372
+ };
373
+ const onPointerUp = (e) => {
374
+ const g = gesture.current;
375
+ gesture.current = null;
376
+ if (!g || !g.active) return;
377
+ setDragging(false);
378
+ const dy = Math.max(0, e.clientY - g.y);
379
+ const speed = dy / Math.max(1, performance.now() - g.t);
380
+ if (dy > L.dismissDistance || speed > L.flickSpeed) requestClose();
381
+ else setDrag(0);
382
+ };
383
+
384
+ if (!rendered || typeof document === "undefined") return null;
385
+
386
+ const slide = noMotion ? (leaving ? MOTION.fadeOut : MOTION.fadeIn) : (leaving ? MOTION.exit : MOTION.enter);
387
+ const dragShift = drag > 0 && !leaving;
388
+ const overlayOpacity = dragShift && sheet.current ? Math.max(0, 1 - drag / sheet.current.offsetHeight) : undefined;
389
+
390
+ const tree = (
391
+ <div ref={host} className={`pw-bottom-sheet ${className}`} data-open={!leaving || undefined}
392
+ style={{ position: contained ? "absolute" : "fixed", inset: 0, zIndex: contained ? 1 : 1000, display: "flex", flexDirection: "column", justifyContent: "flex-end", alignItems: "center", ...style }}>
393
+ <BottomSheetOverlay onClick={requestClose} animation={leaving ? MOTION.fadeOut : MOTION.fadeIn} opacity={overlayOpacity} />
394
+ <div ref={sheet} role="dialog" aria-modal="true" aria-labelledby={titleId}
395
+ onKeyDown={onKeyDown} onAnimationEnd={onAnimationEnd}
396
+ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}
397
+ style={{
398
+ position: "relative", width: "100%", maxWidth: L.maxWidth, boxSizing: "border-box",
399
+ maxHeight: contained ? `calc(100% - ${L.topClearance}px)` : `calc(100dvh - ${L.topClearance}px)`,
400
+ display: "flex", flexDirection: "column", gap: L.sheetGap,
401
+ background: T.surface, boxShadow: T.shadow,
402
+ borderRadius: `${L.radius} ${L.radius} 0 0`,
403
+ animation: dragShift ? "none" : slide,
404
+ transform: dragShift ? `translateY(${drag}px)` : undefined,
405
+ transition: dragging || noMotion ? "none" : MOTION.settle,
406
+ touchAction: "pan-y",
407
+ }}>
408
+ <div data-part="top" style={{ display: "flex", flexDirection: "column", flexShrink: 0 }}>
409
+ {showHandle && <BottomSheetHandle />}
410
+ <BottomSheetHeader title={title} titleId={titleId} titleRef={titleRef}
411
+ showBack={showBack} onBack={onBack} onClose={requestClose}
412
+ closeLabel={closeLabel} backLabel={backLabel} />
413
+ </div>
414
+ <div ref={body} className="pw-bottom-sheet__body" data-part="body"
415
+ role={menu ? "menu" : undefined} aria-labelledby={menu ? titleId : undefined}
416
+ style={{
417
+ display: "flex", flexDirection: "column", gap: L.bodyGap, minHeight: 0, flex: "0 1 auto",
418
+ overflowY: "auto", overflowX: "hidden", overscrollBehavior: "contain",
419
+ padding: footer
420
+ ? `0 ${L.bodyPad} ${L.bodyPad}`
421
+ : `0 ${L.bodyPad} calc(${L.bodyPad} + env(safe-area-inset-bottom, 0px))`,
422
+ }}>
423
+ {/* Choosing a DropdownItem closes the sheet, as it closes a Dropdown. */}
424
+ <DropdownClose.Provider value={requestClose}>{children}</DropdownClose.Provider>
425
+ </div>
426
+ {footer && <BottomSheetFooter>{footer}</BottomSheetFooter>}
427
+ </div>
428
+ </div>
429
+ );
430
+ return contained ? tree : createPortal(tree, container || document.body);
431
+ }
432
+
433
+ export default BottomSheet;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,7 +19,9 @@
19
19
  "./page-template": "./components/page-template/page-template.jsx",
20
20
  "./tooltip": "./components/tooltip/tooltip.jsx",
21
21
  "./badge": "./components/badge/badge.jsx",
22
- "./scroll-to-enable": "./components/scroll-to-enable/scroll-to-enable.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"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "react": ">=17",