@helloimjolopez-pathway/pathway-components 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/bottom-sheet/bottom-sheet.jsx +14 -18
- package/components/popover-menu/popover-menu.jsx +746 -0
- package/components/spinner/spinner.jsx +205 -0
- package/components/toggle-button/toggle-button.jsx +16 -6
- package/dist/pathway-accounting-sidenav.js +195 -8
- package/dist/pathway-components.js +226 -99
- package/dist/pathway-sidenav.js +195 -8
- package/package.json +4 -3
- package/components/dropdown/dropdown.jsx +0 -482
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helloimjolopez-pathway/pathway-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Pathway Design System components for Ministry Brands products: web components (pathway-nav-shell, pathway-top-nav, pathway-sidenav, pathway-accounting-sidenav and more) and React source.",
|
|
5
5
|
"homepage": "https://potential-bassoon-voz982n.pages.github.io/storybook/",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"./badge": "./components/badge/badge.jsx",
|
|
32
32
|
"./scroll-to-enable": "./components/scroll-to-enable/scroll-to-enable.jsx",
|
|
33
33
|
"./bottom-sheet": "./components/bottom-sheet/bottom-sheet.jsx",
|
|
34
|
-
"./
|
|
34
|
+
"./popover-menu": "./components/popover-menu/popover-menu.jsx",
|
|
35
35
|
"./toggle-button": "./components/toggle-button/toggle-button.jsx",
|
|
36
36
|
"./radio": "./components/radio/radio.jsx",
|
|
37
37
|
"./avatar": "./components/avatar/avatar.jsx",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"jsdelivr": "./dist/pathway-components.js",
|
|
52
52
|
"unpkg": "./dist/pathway-components.js",
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"react-aria-components": "^1.22.1"
|
|
54
|
+
"react-aria-components": "^1.22.1",
|
|
55
|
+
"react-stately": "^3.51.0"
|
|
55
56
|
}
|
|
56
57
|
}
|
|
@@ -1,482 +0,0 @@
|
|
|
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;
|