@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.
|
|
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",
|