@helloimjolopez-pathway/pathway-components 0.4.0 → 0.6.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;