@helloimjolopez-pathway/pathway-components 0.11.1 → 0.13.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/badge/badge.jsx +3 -1
- package/components/bottom-sheet/bottom-sheet.jsx +4 -4
- package/components/icon/action-icon.jsx +57 -45
- package/components/popover-menu/popover-menu.jsx +65 -6
- package/components/top-nav/top-nav.jsx +3 -3
- package/dist/pathway-accounting-sidenav.js +114 -69
- package/dist/pathway-components.js +151 -129
- package/dist/pathway-sidenav.js +74 -29
- package/package.json +1 -1
|
@@ -160,6 +160,7 @@ const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${
|
|
|
160
160
|
* @param {string} size Small | Medium | Large
|
|
161
161
|
* @param {string} badgeStyle Outline & Fill | Fill | Outline | Naked
|
|
162
162
|
* @param {string} leadingIcon Material Symbols ligature; omitted means no icon
|
|
163
|
+
* @param {string} selectedIcon Interactive On: the icon in place of the leading icon (Figma "Check" swap); default "check"
|
|
163
164
|
* @param {boolean} showTrailingIcon Interactive only: the drill chevron
|
|
164
165
|
* @param {boolean} selected Interactive, controlled
|
|
165
166
|
* @param {boolean} defaultSelected Interactive, uncontrolled
|
|
@@ -179,6 +180,7 @@ export function Badge({
|
|
|
179
180
|
size = "Small",
|
|
180
181
|
badgeStyle = "Outline & Fill",
|
|
181
182
|
leadingIcon,
|
|
183
|
+
selectedIcon = "check",
|
|
182
184
|
trailingIcon,
|
|
183
185
|
showTrailingIcon = false,
|
|
184
186
|
selected,
|
|
@@ -298,7 +300,7 @@ export function Badge({
|
|
|
298
300
|
const fg = disabled ? C("foreground-action-disabled") : on ? a.on.text : a.off.text[st];
|
|
299
301
|
const stroke = disabled || on || !painted.stroke ? null : a.off.stroke[st];
|
|
300
302
|
const drill = !!href || showTrailingIcon;
|
|
301
|
-
const lead = on ?
|
|
303
|
+
const lead = on ? selectedIcon : leadingIcon;
|
|
302
304
|
const Tag = href ? "a" : "button";
|
|
303
305
|
const handlers = {
|
|
304
306
|
onMouseEnter: () => !disabled && setHovered(true),
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
* BottomSheet.Header 40018716:5340: min height Touch Target/AA,
|
|
20
20
|
* pad 8 / 2 / 4 / 2 (XTight, XXXXTight,
|
|
21
21
|
* XXXTight, XXXXTight), gap XXTight
|
|
22
|
-
* Back (Show Back) Action Icon Size=
|
|
22
|
+
* Back (Show Back) Action Icon Size=Medium, `arrow_back`
|
|
23
23
|
* Container.Title pad left Padding/Base
|
|
24
24
|
* Title Heading/Local/Base/Semibold,
|
|
25
25
|
* Foreground/Static/Neutral/Strong
|
|
26
|
-
* Close Action Icon Size=
|
|
26
|
+
* Close Action Icon Size=Medium, `close`
|
|
27
27
|
* Body (slot) pad 0 / XTight / XTight, gap XXTight,
|
|
28
28
|
* scrolls when taller than the space left
|
|
29
29
|
* Footer (Show Footer) BottomSheet.Footer 40018716:5354: top border
|
|
@@ -160,14 +160,14 @@ export function BottomSheetHeader({ title, titleId, titleRef, showBack = false,
|
|
|
160
160
|
display: "flex", alignItems: "center", gap: L.headerGap, minHeight: L.headerMinH, boxSizing: "border-box",
|
|
161
161
|
padding: `${L.headerPadTop} ${L.headerPadX} ${L.headerPadBottom}`,
|
|
162
162
|
}}>
|
|
163
|
-
{showBack && <ActionIcon name="arrow_back" size="
|
|
163
|
+
{showBack && <ActionIcon name="arrow_back" size="Medium" label={backLabel} onClick={onBack} />}
|
|
164
164
|
<div style={{ flex: 1, minWidth: 0, paddingLeft: L.titleInset }}>
|
|
165
165
|
<h2 id={titleId} ref={titleRef} tabIndex={-1} style={{
|
|
166
166
|
margin: 0, outline: "none", color: T.title, ...TYPE.title,
|
|
167
167
|
overflowWrap: "anywhere", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden",
|
|
168
168
|
}}>{title}</h2>
|
|
169
169
|
</div>
|
|
170
|
-
<ActionIcon name="close" size="
|
|
170
|
+
<ActionIcon name="close" size="Medium" label={closeLabel} onClick={onClose} />
|
|
171
171
|
</div>
|
|
172
172
|
);
|
|
173
173
|
}
|
|
@@ -1,43 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ActionIcon: the icon-only control Pathway puts in a header or a toolbar.
|
|
3
3
|
*
|
|
4
|
-
* SOURCE OF TRUTH: Figma set `Action
|
|
5
|
-
* `↳ ❇️ Action Icon` page.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* one whose absence from Code Connect mattered most.
|
|
4
|
+
* SOURCE OF TRUTH: Figma set `Action.Icon` 40006794:19891, on the
|
|
5
|
+
* `↳ ❇️ Action Icon` page. Fifteen variants on two axes, State (Rest / Hover /
|
|
6
|
+
* Pressed / Disabled / Focused) x Size (Small / Medium / Large), 2026-11-11. Used 30 times
|
|
7
|
+
* inside the Widget set and twice inside KPI Tiles.
|
|
9
8
|
*
|
|
10
9
|
* MEASURED, per Size. The box is the target, `Container.Icon` is the state
|
|
11
10
|
* layer, and the glyph is the frame size an `<Icon>` takes:
|
|
12
11
|
*
|
|
13
|
-
* Size
|
|
14
|
-
* Small
|
|
15
|
-
*
|
|
16
|
-
* Large
|
|
12
|
+
* Size box, Touch Target token Container.Icon glyph
|
|
13
|
+
* Small 36x36 Accessibility/Touch Target/Desktop Only 24x24 r=8 14
|
|
14
|
+
* Medium 44x44 Accessibility/Touch Target/AA 28x28 r=8 16
|
|
15
|
+
* Large 48x48 Accessibility/Touch Target/AAA 32x32 r=8 20
|
|
17
16
|
*
|
|
18
|
-
* STATE TOKENS, read off all
|
|
17
|
+
* STATE TOKENS, read off all nine variants.
|
|
19
18
|
*
|
|
20
19
|
* The state layer's fill:
|
|
21
|
-
*
|
|
20
|
+
* Rest none. Figma's Rest variants carry no Container.Icon at all
|
|
22
21
|
* Hover `Fill/Action/Primary/Subtle/Hover`
|
|
23
22
|
* Pressed `Fill/Action/Primary/Subtle/Pressed`
|
|
24
23
|
*
|
|
25
24
|
* The glyph's colour:
|
|
26
|
-
*
|
|
25
|
+
* Rest `Foreground/Action/Secondary/Rest`
|
|
27
26
|
* Hover `Foreground/Action/Secondary/Hover`
|
|
28
27
|
* Pressed `Foreground/Action/Secondary/Pressed`
|
|
29
28
|
*
|
|
30
|
-
*
|
|
29
|
+
* The glyph on Disabled: `Foreground/Action/Disabled`, no layer fill, no hover
|
|
30
|
+
* or press. It keeps `aria-disabled` rather than the native attribute, so Tab
|
|
31
|
+
* still reaches it and its name is still read.
|
|
32
|
+
* Focused (keyboard only): Rest, plus the focus ring around the state layer:
|
|
33
|
+
* `Stroke/FocusRing/Base` at `FocusRing/Width`, hugging the layer (no gap).
|
|
34
|
+
*
|
|
35
|
+
* `State` (Rest / Hover / Pressed / Disabled / Focused) is runtime; only
|
|
36
|
+
* Disabled is a prop, `disabled`, because it is the one a consumer decides.
|
|
31
37
|
* `Icon` is an INSTANCE_SWAP, which in code is the `name` prop: any Material
|
|
32
38
|
* Symbols ligature.
|
|
33
39
|
*
|
|
34
|
-
* THE COMPONENT ITSELF BINDS NO FILL, in any of the nine variants, and the
|
|
35
|
-
* glyph's fill is unbound too so it inherits. That is why this takes its
|
|
36
|
-
* resting colour from `color` on the button and lets the glyph inherit it: the
|
|
37
|
-
* design expresses "whatever the surrounding context is" rather than a fixed
|
|
38
|
-
* icon colour, and a hard-coded one would break the Widget header, the KPI
|
|
39
|
-
* tile and a dark toolbar all differently.
|
|
40
|
-
*
|
|
41
40
|
* WHY IT LIVES HERE rather than in widget.jsx, where it used to. It was a
|
|
42
41
|
* private helper inside the Widget, so the Figma component had nothing
|
|
43
42
|
* importable to point at and the KPI Tile could not reuse it. The Widget now
|
|
@@ -52,21 +51,18 @@ injectFocusRing();
|
|
|
52
51
|
const C = (n) => `var(--semantic-color-${n})`;
|
|
53
52
|
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
54
53
|
const M = (n) => `var(--motion-${n})`;
|
|
54
|
+
const CL = (n) => `var(--contextual-layout-units-${n})`;
|
|
55
55
|
|
|
56
|
-
/*
|
|
57
|
-
`layer` is the state-layer frame the hover
|
|
58
|
-
the nested Material Symbols instance.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
rendered small inside a correctly sized target: the Widget header's 16px
|
|
63
|
-
ellipsis drew at 14. The three state-layer sizes were right, which is why it
|
|
64
|
-
survived a measurement pass that compared targets and layers and never looked
|
|
65
|
-
at the glyph. Figma's nested instance is 14 at Small, 16 at Base, 18 at Large. */
|
|
56
|
+
/* Per Size, read off every variant on 2026-11-11. `box` is the button, bound in
|
|
57
|
+
Figma to the Touch Target tokens; `layer` is the state-layer frame the hover
|
|
58
|
+
and pressed fills go on; `glyph` is the nested Material Symbols instance. The
|
|
59
|
+
numbers are kept beside the tokens for the stories and for anything that has
|
|
60
|
+
to do arithmetic with the box. Large's glyph is 20, not the 18 it was. */
|
|
61
|
+
const TT = (n) => `var(--semantic-layout-units-accessibility-touch-target-${n})`;
|
|
66
62
|
export const ACTION_ICON_SIZES = {
|
|
67
|
-
Small:
|
|
68
|
-
|
|
69
|
-
Large:
|
|
63
|
+
Small: { box: 36, boxW: TT("desktop-only-width"), boxH: TT("desktop-only-height"), layer: 24, glyph: 14 },
|
|
64
|
+
Medium: { box: 44, boxW: TT("aa-width"), boxH: TT("aa-height"), layer: 28, glyph: 16 },
|
|
65
|
+
Large: { box: 48, boxW: TT("aaa-width"), boxH: TT("aaa-height"), layer: 32, glyph: 20 },
|
|
70
66
|
};
|
|
71
67
|
|
|
72
68
|
export const T = {
|
|
@@ -81,11 +77,15 @@ export const T = {
|
|
|
81
77
|
the hover state is the only thing the layer is for. */
|
|
82
78
|
layerHover: C("fill-action-primary-subtle-hover"),
|
|
83
79
|
layerPress: C("fill-action-primary-subtle-pressed"),
|
|
80
|
+
disabled: C("foreground-action-disabled"),
|
|
81
|
+
focusOutline: `${CL("focusring-width")} solid ${C("stroke-focusring-base")}`,
|
|
84
82
|
};
|
|
85
83
|
|
|
86
84
|
/**
|
|
87
85
|
* @param {string} name Material Symbols ligature, Figma's `Icon` swap.
|
|
88
|
-
* @param {"Small"|"
|
|
86
|
+
* @param {"Small"|"Medium"|"Large"} size Figma's Size axis, same words.
|
|
87
|
+
* Small is Desktop Only (36): dense desktop surfaces
|
|
88
|
+
* only. Medium meets AA (44). Large meets AAA (48).
|
|
89
89
|
* @param {string} label Required: an icon-only control with no accessible
|
|
90
90
|
* name is unusable, so this is not optional.
|
|
91
91
|
* @param {0|1} fill FILL axis. The Widget header is FILL 0.
|
|
@@ -99,6 +99,11 @@ export const T = {
|
|
|
99
99
|
* dark Secondary glyph, which is right on a light page and
|
|
100
100
|
* wrong on the navy TopNav. SideNav.Control (40007082:7703)
|
|
101
101
|
* binds the Ghost ladder instead.
|
|
102
|
+
* @param {boolean} disabled Figma's Disabled state. aria-disabled, never the
|
|
103
|
+
* native attribute: it stays in the tab order.
|
|
104
|
+
* @param {"hover"|"pressed"|"focused"} forceState Stories and demos only: draws
|
|
105
|
+
* a state without the pointer or keyboard, so the state
|
|
106
|
+
* matrix can show every variant Figma has at once.
|
|
102
107
|
*/
|
|
103
108
|
export function ActionIcon({
|
|
104
109
|
name,
|
|
@@ -107,26 +112,29 @@ export function ActionIcon({
|
|
|
107
112
|
fill = 0,
|
|
108
113
|
color,
|
|
109
114
|
states,
|
|
115
|
+
forceState,
|
|
116
|
+
disabled = false,
|
|
110
117
|
onClick,
|
|
111
|
-
disabled,
|
|
112
118
|
className = "",
|
|
113
119
|
style,
|
|
114
120
|
...rest
|
|
115
121
|
}) {
|
|
116
122
|
const noMotion = useReducedMotion();
|
|
117
|
-
const [
|
|
118
|
-
const [
|
|
123
|
+
const [hovRaw, setHov] = useState(false);
|
|
124
|
+
const [pressRaw, setPress] = useState(false);
|
|
125
|
+
const hov = !disabled && (hovRaw || forceState === "hover");
|
|
126
|
+
const press = !disabled && (pressRaw || forceState === "pressed");
|
|
119
127
|
const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
|
|
120
128
|
const st = { ...T, ...states };
|
|
121
|
-
const fg = disabled ? T.
|
|
129
|
+
const fg = disabled ? T.disabled : press ? st.pressed : hov ? st.hover : (color || T.rest);
|
|
122
130
|
|
|
123
131
|
return (
|
|
124
132
|
<button
|
|
125
133
|
type="button"
|
|
126
134
|
aria-label={label}
|
|
127
135
|
title={label}
|
|
128
|
-
disabled={disabled}
|
|
129
|
-
onClick={onClick}
|
|
136
|
+
aria-disabled={disabled || undefined}
|
|
137
|
+
onClick={disabled ? undefined : onClick}
|
|
130
138
|
onMouseEnter={() => setHov(true)}
|
|
131
139
|
onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
132
140
|
onMouseDown={() => setPress(true)}
|
|
@@ -136,10 +144,9 @@ export function ActionIcon({
|
|
|
136
144
|
data-size={size}
|
|
137
145
|
style={{
|
|
138
146
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
139
|
-
width: s.
|
|
140
|
-
background: "transparent", border: "none", padding: 0,
|
|
141
|
-
cursor: disabled ? "
|
|
142
|
-
opacity: disabled ? 0.5 : 1,
|
|
147
|
+
width: s.boxW, height: s.boxH, flexShrink: 0, boxSizing: "border-box",
|
|
148
|
+
background: "transparent", border: "none", padding: 0, outline: "none",
|
|
149
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
143
150
|
color: fg,
|
|
144
151
|
...style,
|
|
145
152
|
}}
|
|
@@ -153,7 +160,12 @@ export function ActionIcon({
|
|
|
153
160
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
154
161
|
width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
|
|
155
162
|
background: press ? st.layerPress : hov ? st.layerHover : "transparent",
|
|
156
|
-
|
|
163
|
+
/* Focused sits AROUND THE STATE LAYER, not the target: Jo, 2026-11-11.
|
|
164
|
+
Keyboard focus draws it through FOCUS_RING_LAYER (hugging the layer,
|
|
165
|
+
no gap); forceState draws the same ring for stories and demos. */
|
|
166
|
+
outline: !disabled && forceState === "focused" ? T.focusOutline : undefined,
|
|
167
|
+
outlineOffset: !disabled && forceState === "focused" ? 0 : undefined,
|
|
168
|
+
transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}, outline ${M("duration-2")} ${M("easing-standard")}`,
|
|
157
169
|
}}>
|
|
158
170
|
<Icon name={name} size={s.glyph} fill={fill} style={{
|
|
159
171
|
transition: noMotion ? "none" : `color ${M("duration-2")} ${M("easing-standard")}`,
|
|
@@ -60,6 +60,7 @@ import { Spinner } from "../spinner/spinner.jsx";
|
|
|
60
60
|
import { Badge } from "../badge/badge.jsx";
|
|
61
61
|
import { Avatar, initialsFrom } from "../avatar/avatar.jsx";
|
|
62
62
|
import { Button } from "../button/button.jsx";
|
|
63
|
+
import { Tooltip } from "../tooltip/tooltip.jsx";
|
|
63
64
|
|
|
64
65
|
// ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
|
|
65
66
|
// SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
|
|
@@ -191,6 +192,12 @@ export const TYPE = {
|
|
|
191
192
|
header: { fontSize: ST("font-size-xs"), lineHeight: ST("line-height-xs-single"), fontWeight: ST("weight-semibold"), letterSpacing: ST("letter-spacing-wide") },
|
|
192
193
|
};
|
|
193
194
|
|
|
195
|
+
/* The BottomSheet's tokens and measurements (bottom-sheet.jsx T and L), named
|
|
196
|
+
here rather than imported because BottomSheet imports this module. */
|
|
197
|
+
const SHEET_T = { surface: SC("fill-surface-elevated"), overlay: SC("scrim-subtle"), handle: SC("fill-static-neutral-strong") };
|
|
198
|
+
const SHEET_L = { radius: SL("cornerradius-xlarge"), pillRadius: SL("cornerradius-full"),
|
|
199
|
+
maxWidth: 640, topClearance: 56, handleZone: 20, pillWidth: 32, pillHeight: 4 };
|
|
200
|
+
|
|
194
201
|
const ITEM = ".pw-menu-item";
|
|
195
202
|
const live = (s) => `${ITEM}:not([data-disabled]):is(${s})`;
|
|
196
203
|
const HOVER = "[data-hovered],[data-focused],[data-open],[data-force=Hover],[data-force=Focus]";
|
|
@@ -206,6 +213,9 @@ const CSS = `
|
|
|
206
213
|
background:${T.panel};border:${L.panelBorder} solid ${T.panelBorder};border-radius:${L.panelRadius};box-shadow:${L.shadow};
|
|
207
214
|
font-family:${ST("family-brand")},sans-serif}
|
|
208
215
|
.pw-menu-panel[data-searchable]{height:${L.searchHeight}px}
|
|
216
|
+
/* React Aria stacks the panel at 100000 inline; a truncated label's Tooltip
|
|
217
|
+
(portalled to the body at 1000) has to sit above it. */
|
|
218
|
+
body:has(.pw-menu-panel) .pw-tooltip{z-index:100001}
|
|
209
219
|
.pw-menu-panel[data-entering]{animation:pw-menu-in ${M("duration-4")} ${M("easing-decelerate")} both}
|
|
210
220
|
.pw-menu-panel[data-placement=top][data-entering]{animation-name:pw-menu-in-up}
|
|
211
221
|
.pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
|
|
@@ -304,7 +314,26 @@ ${ITEM}:is([data-selected],[data-current]) .pw-menu-item__stripe{background:${T.
|
|
|
304
314
|
border:0;border-radius:${L.itemRadius};background:${T.avatarFill};color:${T.avatarText};font-size:${ST("font-size-xxs")};font-weight:${ST("weight-semibold")}}
|
|
305
315
|
.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
316
|
|
|
317
|
+
/* smallScreen="sheet" (G4, pending the overlay family decision): below 768 the
|
|
318
|
+
panel opens as a bottom sheet from the BottomSheet tokens, the way the
|
|
319
|
+
approved demo draws it. The scrim is a spread shadow, so a press outside
|
|
320
|
+
still reaches the page and closes the menu. */
|
|
321
|
+
@media (max-width: 767px){
|
|
322
|
+
.pw-menu-panel[data-small-screen=sheet]{position:fixed !important;top:auto !important;bottom:0 !important;left:0 !important;right:0 !important;
|
|
323
|
+
width:auto;max-width:${SHEET_L.maxWidth}px;margin:0 auto;max-height:calc(var(--visual-viewport-height,100vh) - ${SHEET_L.topClearance}px);
|
|
324
|
+
padding-top:${SHEET_L.handleZone}px;padding-bottom:env(safe-area-inset-bottom,0);
|
|
325
|
+
background:${SHEET_T.surface};border-bottom:0;border-radius:${SHEET_L.radius} ${SHEET_L.radius} 0 0;box-shadow:0 0 0 100vmax ${SHEET_T.overlay}}
|
|
326
|
+
.pw-menu-panel[data-small-screen=sheet]::before{content:"";position:absolute;top:${(SHEET_L.handleZone - SHEET_L.pillHeight) / 2}px;left:50%;
|
|
327
|
+
width:${SHEET_L.pillWidth}px;height:${SHEET_L.pillHeight}px;margin-left:-${SHEET_L.pillWidth / 2}px;border-radius:${SHEET_L.pillRadius};background:${SHEET_T.handle}}
|
|
328
|
+
.pw-menu-panel[data-small-screen=sheet][data-entering],.pw-menu-panel[data-small-screen=sheet][data-placement=top][data-entering]{animation:pw-menu-sheet-in ${M("duration-5")} ${M("easing-emphasized")} both}
|
|
329
|
+
.pw-menu-panel[data-small-screen=sheet][data-exiting]{animation:pw-menu-sheet-out ${M("duration-4")} ${M("easing-accelerate")} both}
|
|
330
|
+
@keyframes pw-menu-sheet-in{from{transform:translateY(100%)}to{transform:none}}
|
|
331
|
+
@keyframes pw-menu-sheet-out{from{transform:none}to{transform:translateY(100%)}}
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
@media (prefers-reduced-motion: reduce){
|
|
335
|
+
.pw-menu-panel[data-small-screen=sheet][data-entering],.pw-menu-panel[data-small-screen=sheet][data-placement=top][data-entering]{animation:pw-menu-fade ${M("duration-2")} ${M("easing-standard")} both}
|
|
336
|
+
.pw-menu-panel[data-small-screen=sheet][data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
|
|
308
337
|
.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
338
|
.pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
|
|
310
339
|
@keyframes pw-menu-fade{from{opacity:0}to{opacity:1}}
|
|
@@ -325,8 +354,9 @@ function useStyles() {
|
|
|
325
354
|
|
|
326
355
|
/* Picks are seen before the panel goes: the row shows its new state for
|
|
327
356
|
Duration/3, then the panel fades (approved demo). */
|
|
328
|
-
const
|
|
329
|
-
&& parseFloat(getComputedStyle(document.documentElement).getPropertyValue(
|
|
357
|
+
const motionMs = (name, fallback) => (typeof document !== "undefined"
|
|
358
|
+
&& parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name))) || fallback;
|
|
359
|
+
const pickDelay = () => motionMs("--motion-duration-3", 200);
|
|
330
360
|
|
|
331
361
|
// ─── CONTEXTS ─────────────────────────────────────────────────────────────────
|
|
332
362
|
|
|
@@ -379,6 +409,32 @@ function Trailing({ trailing, hasSubmenu }) {
|
|
|
379
409
|
return null;
|
|
380
410
|
}
|
|
381
411
|
|
|
412
|
+
/* A label cut off by the panel width shows the full text in the real Tooltip:
|
|
413
|
+
on hover after Duration/4, on keyboard focus at once (approved demo). The
|
|
414
|
+
item's accessible name already carries the full label, so the tooltip is
|
|
415
|
+
visual only and leaves the item's ARIA alone. */
|
|
416
|
+
function ItemLabel({ label, s }) {
|
|
417
|
+
const ref = useRef(null);
|
|
418
|
+
const [cut, setCut] = useState(false);
|
|
419
|
+
const [hoverOpen, setHoverOpen] = useState(false);
|
|
420
|
+
const engaged = !!(s.isHovered || s.isFocusVisible);
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
const el = ref.current;
|
|
423
|
+
setCut(!!el && el.scrollWidth > el.clientWidth + 1);
|
|
424
|
+
}, [engaged, label]);
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (!s.isHovered) { setHoverOpen(false); return undefined; }
|
|
427
|
+
const t = setTimeout(() => setHoverOpen(true), motionMs("--motion-duration-4", 300));
|
|
428
|
+
return () => clearTimeout(t);
|
|
429
|
+
}, [s.isHovered]);
|
|
430
|
+
const open = cut && typeof label === "string" && (!!s.isFocusVisible || hoverOpen);
|
|
431
|
+
return (
|
|
432
|
+
<Tooltip content={typeof label === "string" ? label : undefined} open={open} manageAria={false} align="start">
|
|
433
|
+
<span ref={ref} className="pw-menu-item__label">{label}</span>
|
|
434
|
+
</Tooltip>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
382
438
|
/* The one item anatomy: [stripe] [indicator + visual] [label, description or
|
|
383
439
|
reason] [trailing]. The indicator is the real Checkbox or Radio, visual
|
|
384
440
|
only: the item is the control and owns the role, the state and the target. */
|
|
@@ -413,10 +469,10 @@ function ItemContent({ p, s }) {
|
|
|
413
469
|
<span className="pw-menu-item__text" data-inline={inline || undefined}>
|
|
414
470
|
{p.badges && p.badges.length ? (
|
|
415
471
|
<span className="pw-menu-item__label-row">
|
|
416
|
-
<
|
|
472
|
+
<ItemLabel label={p.label} s={s} />
|
|
417
473
|
{p.badges.map((b) => <Badge key={b.label} label={b.label} status={b.status || "Neutral"} size="Medium" badgeStyle="Outline & Fill" />)}
|
|
418
474
|
</span>
|
|
419
|
-
) : <
|
|
475
|
+
) : <ItemLabel label={p.label} s={s} />}
|
|
420
476
|
{inline ? <span className="pw-menu-item__desc">{p.inlineText}</span>
|
|
421
477
|
: p.description ? <span className="pw-menu-item__desc">{p.description}</span> : null}
|
|
422
478
|
{p.reason && (
|
|
@@ -634,6 +690,7 @@ export function PopoverMenuList({
|
|
|
634
690
|
* @param {"none"|"single"|"multiple"} selectionMode, selectedKeys, onSelectionChange, onAction
|
|
635
691
|
* @param {ReactNode} header, footer, emptyState See PopoverMenuList
|
|
636
692
|
* @param {boolean} searchable The panel opens at the fixed 360 height and keeps it while the list filters
|
|
693
|
+
* @param {"float"|"sheet"} smallScreen Below 768: float from the trigger (default), or open as a bottom sheet. Pending decision G4
|
|
637
694
|
*/
|
|
638
695
|
export function PopoverMenu({
|
|
639
696
|
type = "icon",
|
|
@@ -657,6 +714,7 @@ export function PopoverMenu({
|
|
|
657
714
|
footer,
|
|
658
715
|
emptyState,
|
|
659
716
|
searchable = false,
|
|
717
|
+
smallScreen = "float",
|
|
660
718
|
maxHeight,
|
|
661
719
|
className = "",
|
|
662
720
|
style,
|
|
@@ -703,7 +761,7 @@ export function PopoverMenu({
|
|
|
703
761
|
} else if (type === "avatar") {
|
|
704
762
|
trigger = <button type="button" className="pw-menu-trigger__avatar" {...triggerProps} aria-label={label}>{initials}</button>;
|
|
705
763
|
} else if (iconStyle === "action") {
|
|
706
|
-
trigger = <ActionIcon name={icon} size="
|
|
764
|
+
trigger = <ActionIcon name={icon} size="Medium" label={label} {...triggerProps} />;
|
|
707
765
|
} else {
|
|
708
766
|
trigger = (
|
|
709
767
|
<button type="button" className="pw-menu-trigger__icon" {...triggerProps} aria-label={label} title={label}
|
|
@@ -736,7 +794,8 @@ export function PopoverMenu({
|
|
|
736
794
|
[RootMenuTriggerStateContext, state],
|
|
737
795
|
[PopoverContext, { trigger: "MenuTrigger", triggerRef: wrap, placement: `bottom ${align}` }],
|
|
738
796
|
]}>
|
|
739
|
-
<Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}
|
|
797
|
+
<Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}
|
|
798
|
+
data-small-screen={smallScreen === "sheet" ? "sheet" : undefined}>
|
|
740
799
|
{list}
|
|
741
800
|
</Popover>
|
|
742
801
|
</Provider>
|
|
@@ -221,7 +221,7 @@ export const L = {
|
|
|
221
221
|
chevronGlyph: 12,
|
|
222
222
|
modChevronBox: 20,
|
|
223
223
|
orgChevronBox: 24,
|
|
224
|
-
// Action Icon Size=
|
|
224
|
+
// Action Icon Size=Medium: 16 glyph in a 28 Container.Icon. The glyph was 14,
|
|
225
225
|
// which also put it below the icon font's smallest optical size.
|
|
226
226
|
actionIconBox: 28, actionIconGlyph: 16,
|
|
227
227
|
// Figma's Action Icon is 44, not the bar's 48. It is the one control on the
|
|
@@ -336,7 +336,7 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
|
|
|
336
336
|
* TopNav.Actions, Figma 40007082:7313. A SLOT (Figma property "Slot.Actions"),
|
|
337
337
|
* not a fixed pair of bells: a product puts whatever action icons it needs in
|
|
338
338
|
* it, one bell, or a bell, a cog and a help icon. Each item renders as Figma's
|
|
339
|
-
* nested Action Icon at Size=
|
|
339
|
+
* nested Action Icon at Size=Medium.
|
|
340
340
|
*
|
|
341
341
|
* actions Array<{ icon, label, onClick }>. `icon` is a Material Symbols
|
|
342
342
|
* ligature, `label` the accessible name. Defaults to the two
|
|
@@ -346,7 +346,7 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
|
|
|
346
346
|
* ("More actions"), which is what Figma's mobile variant shows. One action on
|
|
347
347
|
* its own stays as itself.
|
|
348
348
|
*
|
|
349
|
-
* Action Icon Size=
|
|
349
|
+
* Action Icon Size=Medium, read 2026-10-30 and 2026-11-02:
|
|
350
350
|
* target accessibility-touch-target-aa (44)
|
|
351
351
|
* Container.Icon 28, cornerradius-base: rest none, hover
|
|
352
352
|
* Fill/Action/Ghost/Hover, pressed Fill/Action/Ghost/Pressed
|