@helloimjolopez-pathway/pathway-components 0.10.0 → 0.10.2
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/badge/badge.jsx +5 -5
- package/components/bottom-sheet/bottom-sheet.jsx +14 -18
- package/components/popover-menu/popover-menu.jsx +747 -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
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PopoverMenu, Pathway Design System
|
|
3
|
+
*
|
|
4
|
+
* Importable React component module. Source of truth for the Menu
|
|
5
|
+
* implementation (Figma calls it PopoverMenu); the standalone demo
|
|
6
|
+
* (popover-menu.html), the Storybook stories and every component that opens a
|
|
7
|
+
* menu consume this. It replaces components/dropdown/ (Dropdown, DropdownItem,
|
|
8
|
+
* DropdownDivider), which drew the 2026-10-08 PopoverMenu look by hand.
|
|
9
|
+
*
|
|
10
|
+
* The floating list Pathway shows when actions or choices appear on demand:
|
|
11
|
+
* a row's More menu, a widget's actions, a picker opened from a pill, the
|
|
12
|
+
* panel a Select or an Input opens. Two modes on one anatomy:
|
|
13
|
+
* actions React Aria Menu: menu, menuitem / menuitemradio / menuitemcheckbox
|
|
14
|
+
* options React Aria ListBox: listbox, option (the Select and Input panel)
|
|
15
|
+
* Not page navigation (SideNav, Tabs), not a form (Dialog, Sheet), not a few
|
|
16
|
+
* choices that should stay visible (Radio, Toggle Button).
|
|
17
|
+
*
|
|
18
|
+
* Built on React Aria Components, the same primitives as the approved Menu
|
|
19
|
+
* demo (pathway-family-demo/menu), so arrows, Home and End, typeahead,
|
|
20
|
+
* submenus, Esc, focus return and the roles all come from one tested source.
|
|
21
|
+
*
|
|
22
|
+
* Figma properties to props:
|
|
23
|
+
* PopoverMenu Show Header, Header `header`
|
|
24
|
+
* Show Footer, Footer `footer`
|
|
25
|
+
* Items slot children (items, sections, separators)
|
|
26
|
+
* PopoverMenu.Item State runtime (hover, focus, pressed); Disabled is `disabled`
|
|
27
|
+
* Selected from the menu's selection (`selectedKeys`), or `selected` on a static list
|
|
28
|
+
* Tone (Default, Destructive) `tone`
|
|
29
|
+
* Label `label`
|
|
30
|
+
* Show Description, Description `description`
|
|
31
|
+
* Show Inline Text, Inline Text `inlineText`
|
|
32
|
+
* Show Reason, Reason `reason` (makes the item unavailable)
|
|
33
|
+
* Show Indicator + Leading Type (Check / Radio / Checkbox) `indicator`
|
|
34
|
+
* Show Stripe `indicator="stripe"`
|
|
35
|
+
* Show Visual + Leading Type (Icon, Avatar, Dot, Swatch, Spinner) `leading`, `loading`
|
|
36
|
+
* Show Trailing + Trailing Type `trailing`
|
|
37
|
+
* Show Badges `badges`
|
|
38
|
+
* PopoverMenu.SectionHeader Title, Position `PopoverMenuSection title`; Position is worked out from where the section sits
|
|
39
|
+
* PopoverMenu.Separator `PopoverMenuSeparator`
|
|
40
|
+
* PopoverMenu.Status Type `PopoverMenuStatus type`
|
|
41
|
+
*
|
|
42
|
+
* Spec: components/popover-menu/popover-menu-spec.md
|
|
43
|
+
* Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40005568-4207 (PopoverMenu)
|
|
44
|
+
* https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40005568-4191 (PopoverMenu.Item)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import React, { createContext, useContext, useEffect, useId, useRef, useState } from "react";
|
|
48
|
+
import {
|
|
49
|
+
Menu as RACMenu, MenuItem as RACMenuItem, MenuSection, SubmenuTrigger,
|
|
50
|
+
ListBox, ListBoxItem, ListBoxSection, Header, Separator, Popover, Keyboard,
|
|
51
|
+
MenuContext, PopoverContext, OverlayTriggerStateContext, RootMenuTriggerStateContext,
|
|
52
|
+
Provider,
|
|
53
|
+
} from "react-aria-components";
|
|
54
|
+
import { useMenuTriggerState } from "react-stately";
|
|
55
|
+
import { Icon } from "../icon/icon.jsx";
|
|
56
|
+
import { ActionIcon } from "../icon/action-icon.jsx";
|
|
57
|
+
import { Checkbox } from "../checkbox/checkbox.jsx";
|
|
58
|
+
import { RadioMark } from "../radio/radio.jsx";
|
|
59
|
+
import { Spinner } from "../spinner/spinner.jsx";
|
|
60
|
+
import { Badge } from "../badge/badge.jsx";
|
|
61
|
+
import { Avatar, initialsFrom } from "../avatar/avatar.jsx";
|
|
62
|
+
import { Button } from "../button/button.jsx";
|
|
63
|
+
|
|
64
|
+
// ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
|
|
65
|
+
// SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
|
|
66
|
+
// CL = contextual-layout-units (layout-contextual.css), ST = semantic-type
|
|
67
|
+
// (type.css), M = motion (motion.css). Modeless names only.
|
|
68
|
+
const SC = (p) => `var(--semantic-color-${p})`;
|
|
69
|
+
const SL = (p) => `var(--semantic-layout-units-${p})`;
|
|
70
|
+
const CL = (p) => `var(--contextual-layout-units-${p})`;
|
|
71
|
+
const ST = (p) => `var(--semantic-type-${p})`;
|
|
72
|
+
const M = (p) => `var(--motion-${p})`;
|
|
73
|
+
|
|
74
|
+
export const INDICATORS = ["none", "check", "check-start", "radio", "checkbox", "stripe"];
|
|
75
|
+
export const LEADING_TYPES = ["icon", "avatar", "dot", "swatch"];
|
|
76
|
+
export const TRAILING_TYPES = ["shortcut", "text", "count", "chevron", "icon"];
|
|
77
|
+
export const TONES = ["Default", "Destructive"];
|
|
78
|
+
export const DOT_TONES = ["positive", "attention", "negative", "neutral"];
|
|
79
|
+
export const STATUS_TYPES = ["Loading", "Empty", "No Matches", "Error"];
|
|
80
|
+
export const TRIGGER_TYPES = ["icon", "button", "avatar"];
|
|
81
|
+
|
|
82
|
+
// ─── COLOUR TOKENS ────────────────────────────────────────────────────────────
|
|
83
|
+
// Per state, exactly as Figma binds them (spec §5).
|
|
84
|
+
export const T = {
|
|
85
|
+
panel: SC("fill-surface-elevated"),
|
|
86
|
+
panelBorder: SC("stroke-static-neutral-faint"),
|
|
87
|
+
slotDivider: SC("stroke-static-neutral-base"),
|
|
88
|
+
separator: SC("stroke-static-neutral-base"),
|
|
89
|
+
fillHover: SC("fill-static-neutral-subtle"),
|
|
90
|
+
fillPressed: SC("fill-static-neutral-base"),
|
|
91
|
+
fillSelected: SC("fill-action-selection-selected"),
|
|
92
|
+
fillDangerHover: SC("fill-action-status-negative-subtle-hover"),
|
|
93
|
+
fillDangerPressed: SC("fill-action-status-negative-subtle-pressed"),
|
|
94
|
+
stripe: SC("fill-action-primary-strong-pressed"),
|
|
95
|
+
label: SC("foreground-static-neutral-strong"),
|
|
96
|
+
labelHover: SC("foreground-static-neutral-bold"),
|
|
97
|
+
labelDanger: SC("foreground-action-status-negative-on-subtle-rest"),
|
|
98
|
+
labelDangerHover: SC("foreground-action-status-negative-on-subtle-hover"),
|
|
99
|
+
labelDangerPressed: SC("foreground-action-status-negative-on-subtle-pressed"),
|
|
100
|
+
disabled: SC("foreground-action-disabled"),
|
|
101
|
+
secondary: SC("foreground-static-neutral-subtle"),
|
|
102
|
+
reason: SC("foreground-static-neutral-base"),
|
|
103
|
+
check: SC("foreground-action-primary-on-subtle-rest"),
|
|
104
|
+
countFill: SC("fill-static-neutral-base"),
|
|
105
|
+
countText: SC("foreground-static-neutral-bold"),
|
|
106
|
+
countFillDisabled: SC("fill-action-disabled"),
|
|
107
|
+
swatchEdge: SC("stroke-static-neutral-base"),
|
|
108
|
+
dot: {
|
|
109
|
+
positive: SC("fill-static-positive-strong"),
|
|
110
|
+
attention: SC("fill-static-attention-strong"),
|
|
111
|
+
negative: SC("fill-static-negative-strong"),
|
|
112
|
+
neutral: SC("fill-static-neutral-strong"),
|
|
113
|
+
},
|
|
114
|
+
statusTitle: SC("foreground-static-neutral-bold"),
|
|
115
|
+
statusBody: SC("foreground-static-neutral-base"),
|
|
116
|
+
focusRing: SC("stroke-focusring-base"),
|
|
117
|
+
// The Icon trigger is a bare glyph in Figma; its colour follows the host.
|
|
118
|
+
trigger: SC("foreground-action-secondary-rest"),
|
|
119
|
+
triggerHover: SC("foreground-action-secondary-hover"),
|
|
120
|
+
buttonFill: SC("fill-surface-elevated"),
|
|
121
|
+
buttonFillHover: SC("fill-action-selection-hover"),
|
|
122
|
+
buttonBorder: SC("stroke-action-secondary-rest"),
|
|
123
|
+
avatarFill: SC("fill-action-primary-subtle-rest"),
|
|
124
|
+
avatarText: SC("foreground-action-primary-on-subtle-rest"),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ─── LAYOUT ───────────────────────────────────────────────────────────────────
|
|
128
|
+
// Widths, heights and glyph sizes are measurements, not tokens (CLAUDE.md §5).
|
|
129
|
+
export const L = {
|
|
130
|
+
panelRadius: SL("cornerradius-large"),
|
|
131
|
+
panelBorder: SL("borderwidth-xthin"),
|
|
132
|
+
shadow: "var(--elevation-lift)",
|
|
133
|
+
listPadV: SL("padding-xtight"),
|
|
134
|
+
listPadH: SL("padding-xxtight"),
|
|
135
|
+
itemGap: SL("gap-xxxtight"),
|
|
136
|
+
slotPad: SL("padding-xtight"),
|
|
137
|
+
footerPad: SL("padding-tight"),
|
|
138
|
+
footerGap: SL("gap-tight"),
|
|
139
|
+
itemMinH: SL("accessibility-touch-target-aa-height"), // 44, Touch Target/AA
|
|
140
|
+
itemPad: SL("padding-tight"),
|
|
141
|
+
itemContentGap: SL("gap-medium"),
|
|
142
|
+
leadGroupGap: SL("gap-tight"), // indicator to leading visual
|
|
143
|
+
textGap: SL("gap-xxxtight"), // label to description
|
|
144
|
+
inlineGap: SL("gap-tight"), // label to inline text
|
|
145
|
+
badgeGap: SL("gap-xxtight"), // label to badges (G13)
|
|
146
|
+
reasonGap: SL("gap-xxtight"),
|
|
147
|
+
trailGap: SL("gap-xxtight"),
|
|
148
|
+
itemRadius: SL("cornerradius-base"),
|
|
149
|
+
fullRadius: SL("cornerradius-full"),
|
|
150
|
+
swatchRadius: SL("cornerradius-small"),
|
|
151
|
+
headerPadTop: { First: SL("padding-xxtight"), "After Separator": SL("padding-xtight"), Default: SL("padding-base") },
|
|
152
|
+
headerPadL: SL("padding-tight"),
|
|
153
|
+
headerPadR: SL("padding-xtight"),
|
|
154
|
+
headerPadB: SL("padding-xxtight"),
|
|
155
|
+
separatorMargin: SL("padding-xtight"),
|
|
156
|
+
stripeW: SL("borderwidth-xthick"),
|
|
157
|
+
stripeInset: SL("padding-xtight"),
|
|
158
|
+
countPadH: SL("padding-xtight"),
|
|
159
|
+
statusPadV: SL("padding-base"),
|
|
160
|
+
statusPadH: SL("padding-tight"),
|
|
161
|
+
statusGap: SL("gap-xxtight"),
|
|
162
|
+
offset: 8, // Gap/Tight, a number because React Aria takes px
|
|
163
|
+
width: 280,
|
|
164
|
+
minWidth: 220,
|
|
165
|
+
maxWidth: 360,
|
|
166
|
+
maxHeight: 440,
|
|
167
|
+
searchHeight: 360,
|
|
168
|
+
leadBox: 20,
|
|
169
|
+
glyph: 18,
|
|
170
|
+
dot: 8,
|
|
171
|
+
swatch: 16,
|
|
172
|
+
spinner: 16,
|
|
173
|
+
countMin: 24,
|
|
174
|
+
focusWidth: CL("focusring-width"),
|
|
175
|
+
// Triggers carried over from Dropdown (Widget, KPI Tile, Toggle Button).
|
|
176
|
+
triggerTarget: SL("accessibility-touch-target-aa-width"),
|
|
177
|
+
triggerTargetH: SL("accessibility-touch-target-aa-height"),
|
|
178
|
+
triggerGlyph: 20,
|
|
179
|
+
avatarBox: 28,
|
|
180
|
+
buttonPadV: SL("padding-xtight"),
|
|
181
|
+
buttonPadH: SL("padding-tight"),
|
|
182
|
+
buttonBorder: SL("borderwidth-base"),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// ─── TYPE ─────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Label/Menu/Base/Medium, Text/Supporting/Base/Regular and /Semibold. Letter
|
|
187
|
+
// spacing is normal for the label and supporting text (G9).
|
|
188
|
+
export const TYPE = {
|
|
189
|
+
label: { fontSize: ST("font-size-s"), lineHeight: ST("line-height-s-single"), fontWeight: ST("weight-medium") },
|
|
190
|
+
support: { fontSize: ST("font-size-xs"), lineHeight: ST("line-height-xs-single"), fontWeight: ST("weight-regular") },
|
|
191
|
+
header: { fontSize: ST("font-size-xs"), lineHeight: ST("line-height-xs-single"), fontWeight: ST("weight-semibold"), letterSpacing: ST("letter-spacing-wide") },
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const ITEM = ".pw-menu-item";
|
|
195
|
+
const live = (s) => `${ITEM}:not([data-disabled]):is(${s})`;
|
|
196
|
+
const HOVER = "[data-hovered],[data-focused],[data-open],[data-force=Hover],[data-force=Focus]";
|
|
197
|
+
const PRESS = "[data-pressed],[data-force=Pressed]";
|
|
198
|
+
|
|
199
|
+
/* --trigger-width and --visual-viewport-height are set inline by React Aria's
|
|
200
|
+
Popover; the defaults here only apply before it measures. */
|
|
201
|
+
const CSS = `
|
|
202
|
+
.pw-menu-panel{--trigger-width:0px;--visual-viewport-height:100vh;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden;outline:none;
|
|
203
|
+
width:clamp(${L.minWidth}px,max(${L.width}px,var(--trigger-width,0px)),${L.maxWidth}px);
|
|
204
|
+
max-width:min(${L.maxWidth}px,calc(100vw - 2 * ${SL("padding-tight")}));
|
|
205
|
+
max-height:min(${L.maxHeight}px,var(--visual-viewport-height,100vh));
|
|
206
|
+
background:${T.panel};border:${L.panelBorder} solid ${T.panelBorder};border-radius:${L.panelRadius};box-shadow:${L.shadow};
|
|
207
|
+
font-family:${ST("family-brand")},sans-serif}
|
|
208
|
+
.pw-menu-panel[data-searchable]{height:${L.searchHeight}px}
|
|
209
|
+
.pw-menu-panel[data-entering]{animation:pw-menu-in ${M("duration-4")} ${M("easing-decelerate")} both}
|
|
210
|
+
.pw-menu-panel[data-placement=top][data-entering]{animation-name:pw-menu-in-up}
|
|
211
|
+
.pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
|
|
212
|
+
@keyframes pw-menu-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
|
|
213
|
+
@keyframes pw-menu-in-up{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
214
|
+
@keyframes pw-menu-out{from{opacity:1}to{opacity:0}}
|
|
215
|
+
|
|
216
|
+
.pw-menu{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;font-family:${ST("family-brand")},sans-serif}
|
|
217
|
+
.pw-menu--static{box-sizing:border-box;width:${L.width}px;max-width:100%;background:${T.panel};
|
|
218
|
+
border:${L.panelBorder} solid ${T.panelBorder};border-radius:${L.panelRadius};box-shadow:${L.shadow};overflow:hidden}
|
|
219
|
+
.pw-menu--bare{width:100%}
|
|
220
|
+
.pw-menu__header{flex:none;padding:${L.slotPad};border-bottom:${L.panelBorder} solid ${T.slotDivider}}
|
|
221
|
+
.pw-menu__footer{flex:none;display:flex;justify-content:flex-end;gap:${L.footerGap};
|
|
222
|
+
padding:${L.slotPad} ${L.footerPad} ${L.footerPad};border-top:${L.panelBorder} solid ${T.slotDivider}}
|
|
223
|
+
.pw-menu__scroll{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
|
|
224
|
+
.pw-menu__list{display:flex;flex-direction:column;gap:${L.itemGap};padding:${L.listPadV} ${L.listPadH};outline:none}
|
|
225
|
+
.pw-menu--bare .pw-menu__list{padding:0}
|
|
226
|
+
.pw-menu__list[data-empty]:focus-visible{outline:${L.focusWidth} solid ${T.focusRing};outline-offset:calc(-1 * ${L.focusWidth});border-radius:${L.panelRadius}}
|
|
227
|
+
|
|
228
|
+
${ITEM}{position:relative;display:flex;align-items:flex-start;gap:${L.itemContentGap};box-sizing:border-box;
|
|
229
|
+
min-height:${L.itemMinH};padding:${L.itemPad};border-radius:${L.itemRadius};
|
|
230
|
+
color:${T.label};font-size:${TYPE.label.fontSize};line-height:${TYPE.label.lineHeight};font-weight:${TYPE.label.fontWeight};letter-spacing:normal;
|
|
231
|
+
cursor:pointer;outline:none;text-decoration:none;-webkit-tap-highlight-color:transparent;
|
|
232
|
+
transition:background-color ${M("duration-2")} ${M("easing-standard")},color ${M("duration-2")} ${M("easing-standard")}}
|
|
233
|
+
${live(HOVER)}{background:${T.fillHover};color:${T.labelHover}}
|
|
234
|
+
${live(PRESS)}{background:${T.fillPressed};color:${T.label}}
|
|
235
|
+
${ITEM}:not([data-disabled]):is([data-selected],[data-current]){background:${T.fillSelected};color:${T.label}}
|
|
236
|
+
${ITEM}:is([data-focus-visible],[data-force=Focus]){outline:${L.focusWidth} solid ${T.focusRing};outline-offset:calc(-1 * ${L.focusWidth})}
|
|
237
|
+
${ITEM}[role=option][data-focused][data-hovered]:not([data-focus-visible]){outline:none}
|
|
238
|
+
${ITEM}[data-tone=Destructive]{color:${T.labelDanger}}
|
|
239
|
+
${ITEM}[data-tone=Destructive]:not([data-disabled]):is(${HOVER}){background:${T.fillDangerHover};color:${T.labelDangerHover}}
|
|
240
|
+
${ITEM}[data-tone=Destructive]:not([data-disabled]):is(${PRESS}){background:${T.fillDangerPressed};color:${T.labelDangerPressed}}
|
|
241
|
+
${ITEM}[data-disabled]{color:${T.disabled};background:transparent;cursor:default}
|
|
242
|
+
|
|
243
|
+
.pw-menu-item__lead-group{flex:none;display:inline-flex;align-items:center;gap:${L.leadGroupGap};min-height:${TYPE.label.lineHeight}}
|
|
244
|
+
.pw-menu-item__lead{flex:none;width:${L.leadBox}px;height:${L.leadBox}px;display:inline-flex;align-items:center;justify-content:center}
|
|
245
|
+
.pw-menu-item__lead .material-symbols-rounded,.pw-menu-item__trail .material-symbols-rounded{font-size:${L.glyph}px}
|
|
246
|
+
.pw-menu-item__check{color:${T.check}}
|
|
247
|
+
${ITEM}[data-disabled] .pw-menu-item__check{color:${T.disabled}}
|
|
248
|
+
.pw-menu-item__lead--hidden{visibility:hidden}
|
|
249
|
+
.pw-menu-item__control{overflow:hidden}
|
|
250
|
+
.pw-menu-item__control > *{flex:none;pointer-events:none}
|
|
251
|
+
.pw-menu-item__lead--avatar{width:auto;height:auto}
|
|
252
|
+
.pw-menu-item__dot{width:${L.dot}px;height:${L.dot}px;border-radius:${L.fullRadius}}
|
|
253
|
+
.pw-menu-item__swatch{box-sizing:border-box;width:${L.swatch}px;height:${L.swatch}px;border-radius:${L.swatchRadius};border:${L.panelBorder} solid ${T.swatchEdge}}
|
|
254
|
+
|
|
255
|
+
.pw-menu-item__text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:${L.textGap}}
|
|
256
|
+
.pw-menu-item__text[data-inline]{flex-direction:row;align-items:baseline;gap:${L.inlineGap}}
|
|
257
|
+
.pw-menu-item__label-row{display:flex;align-items:center;gap:${L.badgeGap};min-width:0}
|
|
258
|
+
.pw-menu-item__label{display:block;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
259
|
+
.pw-menu-item__label-row > :not(.pw-menu-item__label){flex:none}
|
|
260
|
+
.pw-menu-item__desc{font-size:${TYPE.support.fontSize};line-height:${TYPE.support.lineHeight};font-weight:${TYPE.support.fontWeight};
|
|
261
|
+
color:${T.secondary};overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
262
|
+
.pw-menu-item__text[data-inline] .pw-menu-item__desc{flex:0 1 auto;display:block;white-space:nowrap;text-overflow:ellipsis}
|
|
263
|
+
.pw-menu-item__reason{display:flex;align-items:center;gap:${L.reasonGap};color:${T.reason};
|
|
264
|
+
font-size:${TYPE.support.fontSize};line-height:${TYPE.support.lineHeight};font-weight:${TYPE.support.fontWeight}}
|
|
265
|
+
.pw-menu-item__reason .material-symbols-rounded{font-size:${L.spinner}px}
|
|
266
|
+
${ITEM}[data-disabled] .pw-menu-item__desc{color:${T.disabled}}
|
|
267
|
+
|
|
268
|
+
.pw-menu-item__trail{flex:none;display:inline-flex;align-items:center;gap:${L.trailGap};margin-left:auto;min-height:${TYPE.label.lineHeight};
|
|
269
|
+
color:${T.secondary};font-size:${TYPE.support.fontSize};line-height:${TYPE.support.lineHeight};font-weight:${TYPE.support.fontWeight};font-variant-numeric:tabular-nums}
|
|
270
|
+
.pw-menu-item__kbd{font-family:inherit;font-size:inherit}
|
|
271
|
+
.pw-menu-item__count{box-sizing:border-box;min-width:${L.countMin}px;text-align:center;padding:0 ${L.countPadH};border-radius:${L.fullRadius};
|
|
272
|
+
background:${T.countFill};color:${T.countText}}
|
|
273
|
+
${ITEM}[data-selected] .pw-menu-item__count{background:${T.panel}}
|
|
274
|
+
${ITEM}[data-disabled] .pw-menu-item__trail{color:${T.disabled}}
|
|
275
|
+
${ITEM}[data-disabled] .pw-menu-item__count{background:${T.countFillDisabled};color:${T.disabled}}
|
|
276
|
+
|
|
277
|
+
.pw-menu-item__stripe{position:absolute;left:0;top:${L.stripeInset};bottom:${L.stripeInset};width:${L.stripeW};
|
|
278
|
+
border-radius:0 ${L.itemRadius} ${L.itemRadius} 0;background:transparent}
|
|
279
|
+
${ITEM}:is([data-selected],[data-current]) .pw-menu-item__stripe{background:${T.stripe}}
|
|
280
|
+
|
|
281
|
+
.pw-menu-section{display:flex;flex-direction:column;gap:${L.itemGap}}
|
|
282
|
+
.pw-menu-section__header{padding:${L.headerPadTop.Default} ${L.headerPadR} ${L.headerPadB} ${L.headerPadL};color:${T.secondary};
|
|
283
|
+
font-size:${TYPE.header.fontSize};line-height:${TYPE.header.lineHeight};font-weight:${TYPE.header.fontWeight};letter-spacing:${TYPE.header.letterSpacing}}
|
|
284
|
+
.pw-menu__list > .pw-menu-section:first-child > .pw-menu-section__header,.pw-menu-section__header[data-position=First]{padding-top:${L.headerPadTop.First}}
|
|
285
|
+
.pw-menu-separator + .pw-menu-section > .pw-menu-section__header,.pw-menu-section__header[data-position="After Separator"]{padding-top:${L.headerPadTop["After Separator"]}}
|
|
286
|
+
.pw-menu-separator{height:0;border:0;border-top:${L.panelBorder} solid ${T.separator};margin:${L.separatorMargin}}
|
|
287
|
+
|
|
288
|
+
.pw-menu-status{display:flex;flex-direction:column;align-items:flex-start;gap:${L.statusGap};padding:${L.statusPadV} ${L.statusPadH};
|
|
289
|
+
color:${T.statusBody};font-size:${TYPE.label.fontSize};line-height:${TYPE.label.lineHeight};font-weight:${ST("weight-regular")}}
|
|
290
|
+
.pw-menu-status__row{display:flex;align-items:center;gap:${L.leadGroupGap}}
|
|
291
|
+
.pw-menu-status__title{color:${T.statusTitle};font-weight:${TYPE.label.fontWeight}}
|
|
292
|
+
.pw-menu-status__body{font-size:${TYPE.support.fontSize};line-height:${TYPE.support.lineHeight}}
|
|
293
|
+
|
|
294
|
+
.pw-menu-trigger{position:relative;display:inline-flex}
|
|
295
|
+
.pw-menu-trigger__icon,.pw-menu-trigger__button,.pw-menu-trigger__avatar{font-family:inherit;cursor:pointer;outline:none;-webkit-tap-highlight-color:transparent;
|
|
296
|
+
transition:color ${M("duration-2")} ${M("easing-standard")},background-color ${M("duration-2")} ${M("easing-standard")}}
|
|
297
|
+
.pw-menu-trigger__icon{display:inline-flex;align-items:center;justify-content:center;flex:none;padding:0;border:0;background:transparent;color:${T.trigger}}
|
|
298
|
+
.pw-menu-trigger__icon:is(:hover,[aria-expanded=true]){color:${T.triggerHover}}
|
|
299
|
+
.pw-menu-trigger__button{display:inline-flex;align-items:center;gap:${L.badgeGap};white-space:nowrap;box-sizing:border-box;min-height:${L.triggerTargetH};padding:${L.buttonPadV} ${L.buttonPadH};
|
|
300
|
+
background:${T.buttonFill};border:${L.buttonBorder} solid ${T.buttonBorder};border-radius:${L.itemRadius};color:${T.trigger};
|
|
301
|
+
font-size:${TYPE.label.fontSize};line-height:${TYPE.label.lineHeight}}
|
|
302
|
+
.pw-menu-trigger__button:hover{background:${T.buttonFillHover}}
|
|
303
|
+
.pw-menu-trigger__avatar{display:inline-flex;align-items:center;justify-content:center;flex:none;width:${L.avatarBox}px;height:${L.avatarBox}px;
|
|
304
|
+
border:0;border-radius:${L.itemRadius};background:${T.avatarFill};color:${T.avatarText};font-size:${ST("font-size-xxs")};font-weight:${ST("weight-semibold")}}
|
|
305
|
+
.pw-menu-trigger :is(.pw-menu-trigger__icon,.pw-menu-trigger__button,.pw-menu-trigger__avatar):focus-visible{outline:${L.focusWidth} solid ${T.focusRing}}
|
|
306
|
+
|
|
307
|
+
@media (prefers-reduced-motion: reduce){
|
|
308
|
+
.pw-menu-panel[data-entering],.pw-menu-panel[data-placement=top][data-entering]{animation:pw-menu-fade ${M("duration-2")} ${M("easing-standard")} both}
|
|
309
|
+
.pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
|
|
310
|
+
@keyframes pw-menu-fade{from{opacity:0}to{opacity:1}}
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
let injected = false;
|
|
315
|
+
function useStyles() {
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (injected || typeof document === "undefined" || document.getElementById("pw-popover-menu-css")) { injected = true; return; }
|
|
318
|
+
const el = document.createElement("style");
|
|
319
|
+
el.id = "pw-popover-menu-css";
|
|
320
|
+
el.textContent = CSS;
|
|
321
|
+
document.head.appendChild(el);
|
|
322
|
+
injected = true;
|
|
323
|
+
}, []);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* Picks are seen before the panel goes: the row shows its new state for
|
|
327
|
+
Duration/3, then the panel fades (approved demo). */
|
|
328
|
+
const pickDelay = () => (typeof document !== "undefined"
|
|
329
|
+
&& parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-3"))) || 200;
|
|
330
|
+
|
|
331
|
+
// ─── CONTEXTS ─────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/* Which collection the items sit in, so one PopoverMenuItem renders a React
|
|
334
|
+
Aria MenuItem in actions mode and a ListBoxItem in options mode. */
|
|
335
|
+
const ModeContext = createContext("actions");
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Lets a host that is not a PopoverMenu close itself when an item is chosen,
|
|
339
|
+
* the way a PopoverMenu does. BottomSheet provides it around its Body, so a
|
|
340
|
+
* sheet of PopoverMenuItems closes on a pick. An item with
|
|
341
|
+
* `closeOnAction={false}` keeps it open (a row that swaps the sheet's Body).
|
|
342
|
+
*/
|
|
343
|
+
export const PopoverMenuClose = createContext(null);
|
|
344
|
+
|
|
345
|
+
// ─── ITEM PARTS ───────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
function Leading({ leading, loading, disabled }) {
|
|
348
|
+
if (loading) return <span className="pw-menu-item__lead"><Spinner size={L.spinner} label="Loading" /></span>;
|
|
349
|
+
if (!leading) return null;
|
|
350
|
+
const l = typeof leading === "string" ? { type: "icon", name: leading } : leading;
|
|
351
|
+
if (l.type === "icon") return <span className="pw-menu-item__lead"><Icon name={l.name} size={L.glyph} /></span>;
|
|
352
|
+
if (l.type === "dot") {
|
|
353
|
+
return <span className="pw-menu-item__lead"><span className="pw-menu-item__dot" style={{ background: disabled ? T.disabled : (T.dot[l.tone] || T.dot.neutral) }} /></span>;
|
|
354
|
+
}
|
|
355
|
+
/* The real Avatar: 40 (M) for people rows, XS (24) for one-line rows. It
|
|
356
|
+
stays aria-hidden: the label beside it already names the person. */
|
|
357
|
+
if (l.type === "avatar") {
|
|
358
|
+
return (
|
|
359
|
+
<span className="pw-menu-item__lead pw-menu-item__lead--avatar" aria-hidden="true">
|
|
360
|
+
<Avatar size={l.size || "M"} shape="Circle" src={l.src} text={l.initials || initialsFrom(l.name || "")} />
|
|
361
|
+
</span>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (l.type === "swatch") return <span className="pw-menu-item__lead"><span className="pw-menu-item__swatch" style={{ background: l.color }} /></span>;
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const cap = (n, max = 99) => (n > max ? `${max}+` : String(n));
|
|
369
|
+
|
|
370
|
+
function Trailing({ trailing, hasSubmenu }) {
|
|
371
|
+
if (hasSubmenu) return <span className="pw-menu-item__trail"><Icon name="chevron_right" size={L.glyph} /></span>;
|
|
372
|
+
if (!trailing) return null;
|
|
373
|
+
const t = trailing;
|
|
374
|
+
if (t.type === "shortcut") return <span className="pw-menu-item__trail"><Keyboard className="pw-menu-item__kbd">{t.keys}</Keyboard></span>;
|
|
375
|
+
if (t.type === "text") return <span className="pw-menu-item__trail">{t.text}</span>;
|
|
376
|
+
if (t.type === "count") return <span className="pw-menu-item__trail"><span className="pw-menu-item__count">{cap(t.count)}</span></span>;
|
|
377
|
+
if (t.type === "chevron") return <span className="pw-menu-item__trail"><Icon name="chevron_right" size={L.glyph} /></span>;
|
|
378
|
+
if (t.type === "icon") return <span className="pw-menu-item__trail"><Icon name={t.name} size={L.glyph} /></span>;
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* The one item anatomy: [stripe] [indicator + visual] [label, description or
|
|
383
|
+
reason] [trailing]. The indicator is the real Checkbox or Radio, visual
|
|
384
|
+
only: the item is the control and owns the role, the state and the target. */
|
|
385
|
+
function ItemContent({ p, s }) {
|
|
386
|
+
const ind = p.indicator || "none";
|
|
387
|
+
const sel = !!s.isSelected;
|
|
388
|
+
const disabled = !!s.isDisabled;
|
|
389
|
+
const inline = !!p.inlineText;
|
|
390
|
+
return (
|
|
391
|
+
<>
|
|
392
|
+
{ind === "stripe" && <span className="pw-menu-item__stripe" aria-hidden="true" />}
|
|
393
|
+
{(ind === "check-start" || ind === "radio" || ind === "checkbox" || p.leading || p.loading) && (
|
|
394
|
+
<span className="pw-menu-item__lead-group">
|
|
395
|
+
{ind === "check-start" && (
|
|
396
|
+
<span className={`pw-menu-item__lead${sel ? "" : " pw-menu-item__lead--hidden"}`}>
|
|
397
|
+
<Icon name="check" size={L.glyph} className="pw-menu-item__check" />
|
|
398
|
+
</span>
|
|
399
|
+
)}
|
|
400
|
+
{ind === "radio" && (
|
|
401
|
+
<span className="pw-menu-item__lead pw-menu-item__control" inert="" aria-hidden="true">
|
|
402
|
+
<RadioMark selected={sel} disabled={disabled} />
|
|
403
|
+
</span>
|
|
404
|
+
)}
|
|
405
|
+
{ind === "checkbox" && (
|
|
406
|
+
<span className="pw-menu-item__lead pw-menu-item__control" inert="" aria-hidden="true">
|
|
407
|
+
<Checkbox checked={sel} indeterminate={!!p.mixed} disabled={disabled} size="s" />
|
|
408
|
+
</span>
|
|
409
|
+
)}
|
|
410
|
+
<Leading leading={p.leading} loading={p.loading} disabled={disabled} />
|
|
411
|
+
</span>
|
|
412
|
+
)}
|
|
413
|
+
<span className="pw-menu-item__text" data-inline={inline || undefined}>
|
|
414
|
+
{p.badges && p.badges.length ? (
|
|
415
|
+
<span className="pw-menu-item__label-row">
|
|
416
|
+
<span className="pw-menu-item__label">{p.label}</span>
|
|
417
|
+
{p.badges.map((b) => <Badge key={b.label} label={b.label} status={b.status || "Neutral"} size="Medium" badgeStyle="Outline & Fill" />)}
|
|
418
|
+
</span>
|
|
419
|
+
) : <span className="pw-menu-item__label">{p.label}</span>}
|
|
420
|
+
{inline ? <span className="pw-menu-item__desc">{p.inlineText}</span>
|
|
421
|
+
: p.description ? <span className="pw-menu-item__desc">{p.description}</span> : null}
|
|
422
|
+
{p.reason && (
|
|
423
|
+
<span className="pw-menu-item__reason"><Icon name="info" size={L.spinner} />{p.reason}</span>
|
|
424
|
+
)}
|
|
425
|
+
</span>
|
|
426
|
+
<Trailing trailing={p.trailing} hasSubmenu={s.hasSubmenu} />
|
|
427
|
+
{ind === "check" && sel && <span className="pw-menu-item__trail"><Icon name="check" size={L.glyph} className="pw-menu-item__check" /></span>}
|
|
428
|
+
</>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── ITEM ─────────────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* PopoverMenuItem: one action or option. Must sit inside a PopoverMenu or a
|
|
436
|
+
* PopoverMenuList.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} id Key for selection and onAction. Defaults to the label
|
|
439
|
+
* @param {string} label Figma Label. Truncates to one line
|
|
440
|
+
* @param {string} description Figma Description. Stacked under the label, wraps to two lines
|
|
441
|
+
* @param {string} inlineText Figma Inline Text. On the label line, after it
|
|
442
|
+
* @param {string} reason Figma Reason. Why the item is unavailable; makes it disabled
|
|
443
|
+
* @param {"none"|"check"|"check-start"|"radio"|"checkbox"|"stripe"} indicator How Selected shows
|
|
444
|
+
* @param {string|{type:"icon",name}|{type:"avatar",src,name,size}|{type:"dot",tone}|{type:"swatch",color}} leading Figma Leading (visual)
|
|
445
|
+
* @param {{type:"shortcut",keys}|{type:"text",text}|{type:"count",count}|{type:"chevron"}|{type:"icon",name}} trailing Figma Trailing
|
|
446
|
+
* @param {Array<{label,status}>} badges Figma Show Badges. Real Badges after the label
|
|
447
|
+
* @param {"Default"|"Destructive"} tone Figma Tone. Destructive goes last, after a separator
|
|
448
|
+
* @param {boolean} disabled Figma State=Disabled. Stays reachable by keyboard
|
|
449
|
+
* @param {boolean} loading A spinner replaces the leading visual while the action runs
|
|
450
|
+
* @param {boolean} mixed Checkbox indicator only: the partly-picked state
|
|
451
|
+
* @param {boolean} current Navigation popovers: the current page (stripe and selected fill)
|
|
452
|
+
* @param {boolean} selected Static lists only (StateMatrix); a live menu takes selection from `selectedKeys`
|
|
453
|
+
* @param {boolean} closeOnAction false keeps the menu open after this item runs
|
|
454
|
+
* @param {Function} onAction () => void
|
|
455
|
+
* @param {string} href Makes the item a link
|
|
456
|
+
* @param {"Hover"|"Focus"|"Pressed"} forceState Documentation only: hold a state for state matrices
|
|
457
|
+
*/
|
|
458
|
+
export function PopoverMenuItem({
|
|
459
|
+
id, label, description, inlineText, reason, indicator = "none", leading, trailing, badges,
|
|
460
|
+
tone = "Default", disabled = false, loading = false, mixed = false, current = false, selected = false,
|
|
461
|
+
closeOnAction, onAction, href, textValue, forceState,
|
|
462
|
+
}) {
|
|
463
|
+
useStyles();
|
|
464
|
+
const mode = useContext(ModeContext);
|
|
465
|
+
const close = useContext(PopoverMenuClose);
|
|
466
|
+
const p = { label, description, inlineText, reason, indicator, leading, trailing, badges, loading, mixed, selected };
|
|
467
|
+
const key = id ?? label;
|
|
468
|
+
const common = {
|
|
469
|
+
id: key,
|
|
470
|
+
textValue: textValue || label,
|
|
471
|
+
isDisabled: !!(disabled || reason),
|
|
472
|
+
className: "pw-menu-item",
|
|
473
|
+
"data-tone": tone,
|
|
474
|
+
"data-current": current || p.selected || undefined,
|
|
475
|
+
"data-force": forceState,
|
|
476
|
+
"aria-current": current ? "page" : undefined,
|
|
477
|
+
};
|
|
478
|
+
const body = (s) => <ItemContent p={p} s={{ ...s, isSelected: s.isSelected || !!p.selected }} />;
|
|
479
|
+
if (mode === "options") {
|
|
480
|
+
return <ListBoxItem {...common} href={href}>{body}</ListBoxItem>;
|
|
481
|
+
}
|
|
482
|
+
return (
|
|
483
|
+
<RACMenuItem {...common} href={href}
|
|
484
|
+
target={href && /^https?:/.test(href) ? "_blank" : undefined}
|
|
485
|
+
shouldCloseOnSelect={closeOnAction}
|
|
486
|
+
onAction={() => { onAction?.(); if (closeOnAction !== false) close?.(); }}>
|
|
487
|
+
{body}
|
|
488
|
+
</RACMenuItem>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* PopoverMenuSubmenu: one level of submenu (two at most). Right Arrow, a tap
|
|
494
|
+
* or a hover opens it; Left Arrow and Esc close it.
|
|
495
|
+
*
|
|
496
|
+
* @param {ReactElement} item The PopoverMenuItem that opens it; it takes the chevron
|
|
497
|
+
* @param {string} label The submenu's accessible name
|
|
498
|
+
* @param {node} children Its items
|
|
499
|
+
*/
|
|
500
|
+
export function PopoverMenuSubmenu({ item, label, children, ...rest }) {
|
|
501
|
+
useStyles();
|
|
502
|
+
return (
|
|
503
|
+
<SubmenuTrigger {...rest}>
|
|
504
|
+
{item}
|
|
505
|
+
<Popover className="pw-menu-panel" placement="end top" offset={-4}>
|
|
506
|
+
<PopoverMenuList label={label}>{children}</PopoverMenuList>
|
|
507
|
+
</Popover>
|
|
508
|
+
</SubmenuTrigger>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Figma PopoverMenu.Separator: a rule between groups. Destructive actions go after one. */
|
|
513
|
+
export function PopoverMenuSeparator() {
|
|
514
|
+
useStyles();
|
|
515
|
+
return <Separator className="pw-menu-separator" />;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* PopoverMenuSection: a group of items under an optional PopoverMenu.SectionHeader.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} title Figma Title. Sentence case
|
|
522
|
+
* @param {"Default"|"First"|"After Separator"} position Figma Position. Worked out from the section's place when omitted
|
|
523
|
+
* @param {"none"|"single"|"multiple"} selectionMode A settings group inside an actions menu (Sort by, Show)
|
|
524
|
+
*/
|
|
525
|
+
export function PopoverMenuSection({ title, position, children, ...rest }) {
|
|
526
|
+
useStyles();
|
|
527
|
+
const mode = useContext(ModeContext);
|
|
528
|
+
const S = mode === "options" ? ListBoxSection : MenuSection;
|
|
529
|
+
return (
|
|
530
|
+
<S className="pw-menu-section" aria-label={title ? undefined : rest["aria-label"]} {...rest}>
|
|
531
|
+
{title && <Header className="pw-menu-section__header" data-position={position}>{title}</Header>}
|
|
532
|
+
{children}
|
|
533
|
+
</S>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── STATUS ───────────────────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* PopoverMenuStatus: Figma PopoverMenu.Status. Pass it as `emptyState`, or
|
|
541
|
+
* render it in place of the items while they load.
|
|
542
|
+
*
|
|
543
|
+
* @param {"Loading"|"Empty"|"No Matches"|"Error"} type
|
|
544
|
+
* @param {string} title Empty and Error: the first line
|
|
545
|
+
* @param {string} message Loading and No Matches: the one line; Empty and Error: the second line
|
|
546
|
+
* @param {Function} onRetry Error: shows Try again
|
|
547
|
+
*/
|
|
548
|
+
export function PopoverMenuStatus({ type = "Empty", title, message, onRetry, retryLabel = "Try again" }) {
|
|
549
|
+
useStyles();
|
|
550
|
+
const busy = type === "Loading";
|
|
551
|
+
return (
|
|
552
|
+
<div className="pw-menu-status" role={type === "Error" ? "alert" : "status"} aria-busy={busy || undefined} data-type={type}>
|
|
553
|
+
{busy ? (
|
|
554
|
+
<span className="pw-menu-status__row"><Spinner size={L.spinner} label="" /><span>{message || "Loading"}</span></span>
|
|
555
|
+
) : (
|
|
556
|
+
<>
|
|
557
|
+
{title && <span className="pw-menu-status__title">{title}</span>}
|
|
558
|
+
{message && <span className={title ? "pw-menu-status__body" : undefined}>{message}</span>}
|
|
559
|
+
{type === "Error" && onRetry && <Button size="S" buttonStyle="Outlined" type="Secondary" text={retryLabel} onClick={onRetry} />}
|
|
560
|
+
</>
|
|
561
|
+
)}
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ─── LIST ─────────────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* PopoverMenuList: the list with its Header and Footer slots, without a
|
|
570
|
+
* trigger. PopoverMenu renders one inside its floating panel; a BottomSheet,
|
|
571
|
+
* a Select or an Input renders one in its own surface, and the docs render one
|
|
572
|
+
* open on the page.
|
|
573
|
+
*
|
|
574
|
+
* @param {"actions"|"options"} mode actions: role menu; options: role listbox
|
|
575
|
+
* @param {string} label Accessible name. Always required
|
|
576
|
+
* @param {"none"|"single"|"multiple"} selectionMode
|
|
577
|
+
* @param {Iterable} selectedKeys / defaultSelectedKeys
|
|
578
|
+
* @param {Function} onSelectionChange (keys: Set) => void
|
|
579
|
+
* @param {Function} onAction (key) => void, actions mode
|
|
580
|
+
* @param {ReactNode} header Figma Header slot. The search row is the Input, Look = Embedded
|
|
581
|
+
* @param {ReactNode} footer Figma Footer slot. Buttons, end-aligned
|
|
582
|
+
* @param {ReactNode} emptyState Shown when there are no items (a PopoverMenuStatus)
|
|
583
|
+
* @param {boolean} framed Draw the panel surface (docs and static examples)
|
|
584
|
+
*/
|
|
585
|
+
export function PopoverMenuList({
|
|
586
|
+
mode = "actions",
|
|
587
|
+
label,
|
|
588
|
+
header,
|
|
589
|
+
footer,
|
|
590
|
+
emptyState,
|
|
591
|
+
maxHeight,
|
|
592
|
+
framed = false,
|
|
593
|
+
bare = false,
|
|
594
|
+
className = "",
|
|
595
|
+
children,
|
|
596
|
+
...rest
|
|
597
|
+
}) {
|
|
598
|
+
useStyles();
|
|
599
|
+
const List = mode === "options" ? ListBox : RACMenu;
|
|
600
|
+
/* Esc closes the panel and returns focus. React Aria's default would clear
|
|
601
|
+
every pick in a multiple selection and leave the panel open. */
|
|
602
|
+
const listProps = { ...rest, escapeKeyBehavior: rest.escapeKeyBehavior ?? "none" };
|
|
603
|
+
return (
|
|
604
|
+
<ModeContext.Provider value={mode}>
|
|
605
|
+
<div className={`pw-menu${framed ? " pw-menu--static" : ""}${bare ? " pw-menu--bare" : ""}${className ? ` ${className}` : ""}`}>
|
|
606
|
+
{header && <div className="pw-menu__header">{header}</div>}
|
|
607
|
+
<div className="pw-menu__scroll" style={maxHeight ? { maxHeight } : undefined}>
|
|
608
|
+
<List aria-label={label} className="pw-menu__list"
|
|
609
|
+
renderEmptyState={emptyState ? () => emptyState : undefined} {...listProps}>
|
|
610
|
+
{children}
|
|
611
|
+
</List>
|
|
612
|
+
</div>
|
|
613
|
+
{footer && <div className="pw-menu__footer">{footer}</div>}
|
|
614
|
+
</div>
|
|
615
|
+
</ModeContext.Provider>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── POPOVER MENU (trigger + panel) ───────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* PopoverMenu: a trigger and the floating menu it opens.
|
|
623
|
+
*
|
|
624
|
+
* @param {"icon"|"button"|"avatar"} type The trigger drawn when `renderTrigger` is not given
|
|
625
|
+
* @param {string} label The menu's and the trigger's accessible name. Required
|
|
626
|
+
* @param {string} text The `button` trigger's visible label
|
|
627
|
+
* @param {string} initials The `avatar` trigger's initials
|
|
628
|
+
* @param {string} icon The `icon` trigger's ligature (more_vert)
|
|
629
|
+
* @param {"bare"|"action"} iconStyle bare: a 20 glyph in a 44 target (KPI Tile corner); action: the real ActionIcon (Widget header)
|
|
630
|
+
* @param {Function} renderTrigger (props) => element; spread props onto one button (aria and handlers are wired here)
|
|
631
|
+
* @param {"start"|"end"} align Which trigger edge the panel lines up with. Flips when there is no room
|
|
632
|
+
* @param {boolean} open / onOpenChange Controlled open state
|
|
633
|
+
* @param {"actions"|"options"} mode
|
|
634
|
+
* @param {"none"|"single"|"multiple"} selectionMode, selectedKeys, onSelectionChange, onAction
|
|
635
|
+
* @param {ReactNode} header, footer, emptyState See PopoverMenuList
|
|
636
|
+
* @param {boolean} searchable The panel opens at the fixed 360 height and keeps it while the list filters
|
|
637
|
+
*/
|
|
638
|
+
export function PopoverMenu({
|
|
639
|
+
type = "icon",
|
|
640
|
+
label,
|
|
641
|
+
text,
|
|
642
|
+
initials,
|
|
643
|
+
icon = "more_vert",
|
|
644
|
+
iconStyle = "bare",
|
|
645
|
+
renderTrigger,
|
|
646
|
+
align = "end",
|
|
647
|
+
open: openProp,
|
|
648
|
+
defaultOpen,
|
|
649
|
+
onOpenChange,
|
|
650
|
+
mode = "actions",
|
|
651
|
+
selectionMode,
|
|
652
|
+
selectedKeys,
|
|
653
|
+
defaultSelectedKeys,
|
|
654
|
+
onSelectionChange,
|
|
655
|
+
onAction,
|
|
656
|
+
header,
|
|
657
|
+
footer,
|
|
658
|
+
emptyState,
|
|
659
|
+
searchable = false,
|
|
660
|
+
maxHeight,
|
|
661
|
+
className = "",
|
|
662
|
+
style,
|
|
663
|
+
children,
|
|
664
|
+
}) {
|
|
665
|
+
useStyles();
|
|
666
|
+
const state = useMenuTriggerState({ isOpen: openProp, defaultOpen, onOpenChange });
|
|
667
|
+
const wrap = useRef(null);
|
|
668
|
+
const triggerId = `pwmenu${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
669
|
+
const [hov, setHov] = useState(false);
|
|
670
|
+
|
|
671
|
+
/* Single picks and plain actions close after the pick delay; picking many
|
|
672
|
+
keeps the menu open so more can be changed. */
|
|
673
|
+
const closeSoon = () => setTimeout(() => state.close(), pickDelay());
|
|
674
|
+
const handleAction = (key) => { onAction?.(key); if (selectionMode !== "multiple") closeSoon(); };
|
|
675
|
+
const handleSelection = (keys) => { onSelectionChange?.(keys); if (selectionMode !== "multiple" && mode === "options") closeSoon(); };
|
|
676
|
+
|
|
677
|
+
const triggerProps = {
|
|
678
|
+
id: triggerId,
|
|
679
|
+
"aria-haspopup": mode === "options" ? "listbox" : "menu",
|
|
680
|
+
"aria-expanded": state.isOpen,
|
|
681
|
+
onClick: () => state.toggle(null),
|
|
682
|
+
/* Arrow Down and Up open from a closed trigger and land on the first or
|
|
683
|
+
last item (APG menu button). Enter and Space are the native click. */
|
|
684
|
+
onKeyDown: (e) => {
|
|
685
|
+
if (state.isOpen) return;
|
|
686
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
687
|
+
e.preventDefault();
|
|
688
|
+
state.open(e.key === "ArrowDown" ? "first" : "last");
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
let trigger;
|
|
694
|
+
if (renderTrigger) {
|
|
695
|
+
trigger = renderTrigger({ ...triggerProps, "aria-label": label, title: label });
|
|
696
|
+
} else if (type === "button") {
|
|
697
|
+
trigger = (
|
|
698
|
+
<button type="button" className="pw-menu-trigger__button" {...triggerProps} aria-label={text ? undefined : label}>
|
|
699
|
+
{text}
|
|
700
|
+
<Icon name={state.isOpen ? "expand_less" : "expand_more"} size={16} />
|
|
701
|
+
</button>
|
|
702
|
+
);
|
|
703
|
+
} else if (type === "avatar") {
|
|
704
|
+
trigger = <button type="button" className="pw-menu-trigger__avatar" {...triggerProps} aria-label={label}>{initials}</button>;
|
|
705
|
+
} else if (iconStyle === "action") {
|
|
706
|
+
trigger = <ActionIcon name={icon} size="Base" label={label} {...triggerProps} />;
|
|
707
|
+
} else {
|
|
708
|
+
trigger = (
|
|
709
|
+
<button type="button" className="pw-menu-trigger__icon" {...triggerProps} aria-label={label} title={label}
|
|
710
|
+
style={{ width: L.triggerTarget, height: L.triggerTarget }}>
|
|
711
|
+
<Icon name={icon} size={L.triggerGlyph} />
|
|
712
|
+
</button>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const list = (
|
|
717
|
+
<PopoverMenuList mode={mode} label={label}
|
|
718
|
+
selectionMode={selectionMode} selectedKeys={selectedKeys} defaultSelectedKeys={defaultSelectedKeys}
|
|
719
|
+
onSelectionChange={handleSelection} onAction={mode === "options" ? undefined : handleAction}
|
|
720
|
+
/* A ListBox takes no focus from MenuContext, so options mode focuses
|
|
721
|
+
itself: `true` lands on the picked option, else the first. */
|
|
722
|
+
autoFocus={mode === "options" ? (state.focusStrategy === "last" ? "last" : true) : undefined}
|
|
723
|
+
shouldFocusWrap
|
|
724
|
+
header={header} footer={footer} emptyState={emptyState} maxHeight={maxHeight}>
|
|
725
|
+
{children}
|
|
726
|
+
</PopoverMenuList>
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
return (
|
|
730
|
+
<span ref={wrap} className={`pw-menu-trigger ${className}`} data-open={state.isOpen || undefined}
|
|
731
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)} data-hovered={hov || undefined} style={style}>
|
|
732
|
+
{trigger}
|
|
733
|
+
<Provider values={[
|
|
734
|
+
[MenuContext, { "aria-labelledby": label ? undefined : triggerId, autoFocus: state.focusStrategy || true, onClose: state.close, shouldCloseOnSelect: false }],
|
|
735
|
+
[OverlayTriggerStateContext, state],
|
|
736
|
+
[RootMenuTriggerStateContext, state],
|
|
737
|
+
[PopoverContext, { trigger: "MenuTrigger", triggerRef: wrap, placement: `bottom ${align}` }],
|
|
738
|
+
]}>
|
|
739
|
+
<Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}>
|
|
740
|
+
{list}
|
|
741
|
+
</Popover>
|
|
742
|
+
</Provider>
|
|
743
|
+
</span>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export default PopoverMenu;
|