@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.
@@ -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;