@helloimjolopez-pathway/pathway-components 0.2.4 → 0.3.1
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 +331 -0
- package/components/nav-shell/nav-shell-demo.jsx +16 -4
- package/components/nav-shell/nav-shell.jsx +18 -0
- package/components/sidenav/sidenav.jsx +189 -5
- package/components/tooltip/tooltip.jsx +6 -1
- package/components/top-nav/top-nav.jsx +8 -1
- package/package.json +3 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge: a short status, category or applied value. One component, three types.
|
|
3
|
+
*
|
|
4
|
+
* Figma `Badge`, set 40011003:15892, page "↳ ❇️ Badge" (1673:3961). Spec v2:
|
|
5
|
+
* components/badge/badge-spec.md (REVIEWED 2026-11-06).
|
|
6
|
+
*
|
|
7
|
+
* type="static" a status or category the system sets. Nothing to press.
|
|
8
|
+
* 96 variants: Status (8) x Size (3) x Style (4). Unchanged
|
|
9
|
+
* from v1 except where v1 disagreed with Figma (below).
|
|
10
|
+
* type="dismissible" something a person applied or picked (an applied filter,
|
|
11
|
+
* a person, an editable tag). Only the round x is a button.
|
|
12
|
+
* type="interactive" a filter toggle (aria-pressed), or a drill to a filtered
|
|
13
|
+
* view when `href` or `showTrailingIcon` is set.
|
|
14
|
+
*
|
|
15
|
+
* STATIC, v1 to v2. Three v1 values did not match Figma and now do:
|
|
16
|
+
* Outline & Fill stroke v1 Subtle, Figma Strong (Neutral Bold, accents plain).
|
|
17
|
+
* Neutral Naked text v1 Neutral/Bold, Figma Neutral/Base.
|
|
18
|
+
* Stroke placement v1 drew a 1px border INSIDE the box, which made every
|
|
19
|
+
* Static badge 2px taller than the set (24 not 22).
|
|
20
|
+
* Figma strokes outside; a box-shadow does the same.
|
|
21
|
+
* Figma's default leading icon is the placeholder `error`; in code the icon is
|
|
22
|
+
* passed explicitly, so existing call sites render exactly as before.
|
|
23
|
+
*
|
|
24
|
+
* DISMISSIBLE and INTERACTIVE, every size, with or without icons:
|
|
25
|
+
* outer frame invisible, min width and height Accessibility/Touch Target/AA
|
|
26
|
+
* (44). The pill keeps its size (20 / 22 / 22) inside it.
|
|
27
|
+
* Badge.Pill fill, stroke (outside), CornerRadius/Large, focus ring.
|
|
28
|
+
* Container.Main Padding/XXXTight left and right, Padding/XXXXTight top and
|
|
29
|
+
* bottom, NO gap.
|
|
30
|
+
* Container.Label Padding/XXXTight left and right; Field Label to value
|
|
31
|
+
* Gap/XXTight.
|
|
32
|
+
* icons the leading icon, the check, the chevron and the x sit
|
|
33
|
+
* directly in Container.Main, each in its own frame only:
|
|
34
|
+
* 12 / 14 / 16.
|
|
35
|
+
* Result: 4 from the pill edge to an icon, 4 from an icon to the text, 8 from
|
|
36
|
+
* the edge to text alone. With every icon on the badge stops at 240.
|
|
37
|
+
*
|
|
38
|
+
* FOCUS RING: Stroke/FocusRing/Base at FocusRing/Width, FocusRing/Offset outside
|
|
39
|
+
* the pill, on keyboard focus only. On Dismissible the x is focusable but the
|
|
40
|
+
* ring goes around the whole badge.
|
|
41
|
+
*/
|
|
42
|
+
import React, { useState } from "react";
|
|
43
|
+
import { Icon } from "../icon/icon.jsx";
|
|
44
|
+
|
|
45
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
46
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
47
|
+
const X = (n) => `var(--contextual-layout-units-${n})`;
|
|
48
|
+
const Y = (n) => `var(--semantic-type-${n})`;
|
|
49
|
+
|
|
50
|
+
export const BADGE_TYPES = ["static", "dismissible", "interactive"];
|
|
51
|
+
/** The eight Status values, in the set's own order. Interactive takes the first four of BADGE_INTERACTIVE_STATUS only. */
|
|
52
|
+
export const BADGE_STATUS = [
|
|
53
|
+
"Neutral", "Severe", "Negative", "Positive", "Attention", "Info",
|
|
54
|
+
"Accent 2", "Accent 3",
|
|
55
|
+
];
|
|
56
|
+
/** Attention, Severe and the accents have no Action/Status tokens. */
|
|
57
|
+
export const BADGE_INTERACTIVE_STATUS = ["Neutral", "Info", "Positive", "Negative"];
|
|
58
|
+
export const BADGE_SIZES = ["Small", "Medium", "Large"];
|
|
59
|
+
export const BADGE_STYLES = ["Outline & Fill", "Fill", "Outline", "Naked"];
|
|
60
|
+
|
|
61
|
+
/* Static and Dismissible colours, read per variant from the set. Neutral binds
|
|
62
|
+
* Base and Bold rungs, Attention's Outline stroke is Strong, and the accents
|
|
63
|
+
* are named (Jade, Mauve) with an unsuffixed stroke, so this is a table. */
|
|
64
|
+
const FAMILY = {
|
|
65
|
+
"Neutral": { fill: "fill-static-neutral-base", strokeStrong: "stroke-static-neutral-bold", strokeSubtle: "stroke-static-neutral-bold",
|
|
66
|
+
text: "foreground-static-neutral-bold", textNaked: "foreground-static-neutral-base" },
|
|
67
|
+
"Severe": { fill: "fill-static-severe-subtle", strokeStrong: "stroke-static-severe-strong", strokeSubtle: "stroke-static-severe-subtle",
|
|
68
|
+
text: "foreground-static-severe-on-subtle" },
|
|
69
|
+
"Negative": { fill: "fill-static-negative-subtle", strokeStrong: "stroke-static-negative-strong", strokeSubtle: "stroke-static-negative-subtle",
|
|
70
|
+
text: "foreground-static-negative-on-subtle" },
|
|
71
|
+
"Positive": { fill: "fill-static-positive-subtle", strokeStrong: "stroke-static-positive-strong", strokeSubtle: "stroke-static-positive-subtle",
|
|
72
|
+
text: "foreground-static-positive-on-subtle" },
|
|
73
|
+
"Attention": { fill: "fill-static-attention-subtle", strokeStrong: "stroke-static-attention-strong", strokeSubtle: "stroke-static-attention-strong",
|
|
74
|
+
text: "foreground-static-attention-on-subtle" },
|
|
75
|
+
"Info": { fill: "fill-static-info-subtle", strokeStrong: "stroke-static-info-strong", strokeSubtle: "stroke-static-info-subtle",
|
|
76
|
+
text: "foreground-static-info-on-subtle" },
|
|
77
|
+
"Accent 2": { fill: "fill-static-accent-jade-subtle", strokeStrong: "stroke-static-accent-jade", strokeSubtle: "stroke-static-accent-jade",
|
|
78
|
+
text: "foreground-static-accent-jade-on-subtle" },
|
|
79
|
+
"Accent 3": { fill: "fill-static-accent-mauve-subtle", strokeStrong: "stroke-static-accent-mauve", strokeSubtle: "stroke-static-accent-mauve",
|
|
80
|
+
text: "foreground-static-accent-mauve-on-subtle" },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/* Interactive colours: the Action/Status family. `$` is the status key. */
|
|
84
|
+
const ACTION = (s) => {
|
|
85
|
+
const k = (BADGE_INTERACTIVE_STATUS.includes(s) ? s : "Neutral").toLowerCase();
|
|
86
|
+
const t = (n) => C(n.replace("$", k));
|
|
87
|
+
return {
|
|
88
|
+
off: {
|
|
89
|
+
fill: { rest: t("fill-action-status-$-subtle-rest"), hover: t("fill-action-status-$-subtle-hover"), pressed: t("fill-action-status-$-subtle-pressed") },
|
|
90
|
+
stroke: { rest: t("stroke-action-status-$-rest"), hover: t("stroke-action-status-$-hover"), pressed: t("stroke-action-status-$-pressed") },
|
|
91
|
+
text: { rest: t("foreground-action-status-$-on-subtle-rest"), hover: t("foreground-action-status-$-on-subtle-hover"), pressed: t("foreground-action-status-$-on-subtle-pressed") },
|
|
92
|
+
},
|
|
93
|
+
on: {
|
|
94
|
+
fill: { rest: t("fill-action-status-$-strong-rest"), hover: t("fill-action-status-$-strong-hover"), pressed: t("fill-action-status-$-strong-pressed") },
|
|
95
|
+
text: t("foreground-action-status-$-on-strong"),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const BADGE_SIZE_SPEC = {
|
|
101
|
+
Small: { fontSize: "font-size-xxs", lineHeight: "line-height-xxs-single", icon: 11, ico: 12, contentMax: 200 },
|
|
102
|
+
Medium: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 12, ico: 14, contentMax: 196 },
|
|
103
|
+
Large: { fontSize: "font-size-s", lineHeight: "line-height-xs-single", icon: 14, ico: 16, contentMax: 192 },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Layout constants. Static keeps v1's asymmetric padding; the other types use T.pill / T.text. */
|
|
107
|
+
export const L = {
|
|
108
|
+
radius: U("cornerradius-large"), // CornerRadius/Large
|
|
109
|
+
radiusX: U("cornerradius-full"), // CornerRadius/Full, the x
|
|
110
|
+
border: U("borderwidth-base"), // BorderWidth/Base
|
|
111
|
+
padTop: U("padding-xxxxtight"), // Padding/XXXXTight
|
|
112
|
+
padBottom: U("padding-xxxxtight"),
|
|
113
|
+
padLeft: U("padding-xxxtight"), // Static only
|
|
114
|
+
padRight: U("padding-xxtight"), // Static only
|
|
115
|
+
gap: U("gap-xxxtight"), // Static only
|
|
116
|
+
};
|
|
117
|
+
export const T = {
|
|
118
|
+
pill: U("padding-xxxtight"), // Container.Main left and right
|
|
119
|
+
text: U("padding-xxxtight"), // Container.Label left and right
|
|
120
|
+
fieldGap: U("gap-xxtight"), // Field Label to value
|
|
121
|
+
rowGap: U("gap-xtight"), // badges in a wrapping row
|
|
122
|
+
touchW: U("accessibility-touch-target-aa-width"),
|
|
123
|
+
touchH: U("accessibility-touch-target-aa-height"),
|
|
124
|
+
ring: C("stroke-focusring-base"),
|
|
125
|
+
ringWidth: X("focusring-width"),
|
|
126
|
+
ringOffset: X("focusring-offset"),
|
|
127
|
+
fieldMax: 80, // raw: Field Label max width, no width token
|
|
128
|
+
badgeMax: 240, // raw: the whole badge with every icon on
|
|
129
|
+
duration: "var(--motion-duration-2)",
|
|
130
|
+
easing: "var(--motion-easing-standard)",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const font = (spec, weight = "weight-medium") => ({
|
|
134
|
+
fontFamily: "inherit", fontSize: Y(spec.fontSize), lineHeight: Y(spec.lineHeight),
|
|
135
|
+
fontWeight: Y(weight), letterSpacing: Y("letter-spacing-extraspacious"), whiteSpace: "nowrap",
|
|
136
|
+
});
|
|
137
|
+
const cut = { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
|
|
138
|
+
const outer = { display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
139
|
+
minWidth: T.touchW, minHeight: T.touchH, boxSizing: "border-box", padding: 0, margin: 0, border: 0,
|
|
140
|
+
background: "transparent", font: "inherit", color: "inherit", textDecoration: "none", outline: "none" };
|
|
141
|
+
const strokeShadow = (stroke) => (stroke ? `0 0 0 ${L.border} ${stroke}` : "none");
|
|
142
|
+
/* Focus ring: FocusRing/Width of Stroke/FocusRing/Base, FocusRing/Offset outside the pill,
|
|
143
|
+
* as an outline so the gap shows whatever surface the badge sits on. No white halo. */
|
|
144
|
+
const focusRing = (focused) => (focused
|
|
145
|
+
? { outline: `${T.ringWidth} solid ${T.ring}`, outlineOffset: T.ringOffset }
|
|
146
|
+
: { outline: "none" });
|
|
147
|
+
const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${T.easing}, box-shadow ${T.duration} ${T.easing}`;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {"static"|"dismissible"|"interactive"} type
|
|
151
|
+
* @param {string} label the text (on Dismissible with a Field Label, the value)
|
|
152
|
+
* @param {string} fieldLabel Dismissible only, for example "Campus:" (type the colon)
|
|
153
|
+
* @param {boolean} showFieldLabel Dismissible only
|
|
154
|
+
* @param {string} status one of BADGE_STATUS (Interactive: BADGE_INTERACTIVE_STATUS)
|
|
155
|
+
* @param {string} size Small | Medium | Large
|
|
156
|
+
* @param {string} badgeStyle Outline & Fill | Fill | Outline | Naked
|
|
157
|
+
* @param {string} leadingIcon Material Symbols ligature; omitted means no icon
|
|
158
|
+
* @param {boolean} showTrailingIcon Interactive only: the drill chevron
|
|
159
|
+
* @param {boolean} selected Interactive, controlled
|
|
160
|
+
* @param {boolean} defaultSelected Interactive, uncontrolled
|
|
161
|
+
* @param {function} onChange Interactive: (selected) => void
|
|
162
|
+
* @param {function} onRemove Dismissible: called when the x is pressed
|
|
163
|
+
* @param {string} href Interactive drill: renders a link
|
|
164
|
+
* @param {boolean} disabled
|
|
165
|
+
* @param {boolean} removeDisabled Dismissible: only the x is disabled
|
|
166
|
+
* @param {"hover"|"pressed"|"focused"} forceState documentation only: show a state without the pointer (Storybook, Code Connect previews)
|
|
167
|
+
*/
|
|
168
|
+
export function Badge({
|
|
169
|
+
type = "static",
|
|
170
|
+
label,
|
|
171
|
+
fieldLabel,
|
|
172
|
+
showFieldLabel = false,
|
|
173
|
+
status = "Neutral",
|
|
174
|
+
size = "Small",
|
|
175
|
+
badgeStyle = "Outline & Fill",
|
|
176
|
+
leadingIcon,
|
|
177
|
+
trailingIcon,
|
|
178
|
+
showTrailingIcon = false,
|
|
179
|
+
selected,
|
|
180
|
+
defaultSelected = false,
|
|
181
|
+
onChange,
|
|
182
|
+
onRemove,
|
|
183
|
+
href,
|
|
184
|
+
disabled = false,
|
|
185
|
+
removeDisabled = false,
|
|
186
|
+
forceState,
|
|
187
|
+
className = "",
|
|
188
|
+
style,
|
|
189
|
+
...rest
|
|
190
|
+
}) {
|
|
191
|
+
const spec = BADGE_SIZE_SPEC[size] || BADGE_SIZE_SPEC.Small;
|
|
192
|
+
const [inner, setInner] = useState(defaultSelected);
|
|
193
|
+
const [hoveredState, setHovered] = useState(false);
|
|
194
|
+
const [pressedState, setPressed] = useState(false);
|
|
195
|
+
const [focusedState, setFocused] = useState(false);
|
|
196
|
+
const hovered = hoveredState || forceState === "hover";
|
|
197
|
+
const pressed = pressedState || forceState === "pressed";
|
|
198
|
+
const focused = focusedState || forceState === "focused";
|
|
199
|
+
const painted = {
|
|
200
|
+
"Outline & Fill": { fill: true, stroke: "strong" },
|
|
201
|
+
"Fill": { fill: true, stroke: null },
|
|
202
|
+
"Outline": { fill: false, stroke: "subtle" },
|
|
203
|
+
"Naked": { fill: false, stroke: null },
|
|
204
|
+
}[badgeStyle] || { fill: true, stroke: "strong" };
|
|
205
|
+
|
|
206
|
+
/* ── Static (v1 component, Figma-corrected) ─────────────────────────── */
|
|
207
|
+
if (type === "static") {
|
|
208
|
+
const fam = FAMILY[status] || FAMILY.Neutral;
|
|
209
|
+
const stroke = painted.stroke === "strong" ? C(fam.strokeStrong) : painted.stroke === "subtle" ? C(fam.strokeSubtle) : null;
|
|
210
|
+
return (
|
|
211
|
+
<span className={`pw-badge ${className}`} data-type="static" data-status={status} data-size={size}
|
|
212
|
+
style={{
|
|
213
|
+
display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: L.gap,
|
|
214
|
+
paddingTop: L.padTop, paddingRight: L.padRight, paddingBottom: L.padBottom, paddingLeft: L.padLeft,
|
|
215
|
+
borderRadius: L.radius,
|
|
216
|
+
background: painted.fill ? C(fam.fill) : "transparent",
|
|
217
|
+
boxShadow: stroke ? `0 0 0 ${L.border} ${stroke}` : "none",
|
|
218
|
+
color: C(badgeStyle === "Naked" && fam.textNaked ? fam.textNaked : fam.text),
|
|
219
|
+
...font(spec), ...style,
|
|
220
|
+
}}
|
|
221
|
+
{...rest}>
|
|
222
|
+
{leadingIcon && <Icon name={leadingIcon} size={spec.icon} fill={1} />}
|
|
223
|
+
{label}
|
|
224
|
+
{trailingIcon && <Icon name={trailingIcon} size={spec.icon} />}
|
|
225
|
+
</span>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pillBase = {
|
|
230
|
+
display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: 0,
|
|
231
|
+
paddingTop: L.padTop, paddingBottom: L.padBottom, paddingLeft: T.pill, paddingRight: T.pill,
|
|
232
|
+
borderRadius: L.radius, maxWidth: showFieldLabel ? "none" : T.badgeMax, minWidth: 0,
|
|
233
|
+
transition, ...font(spec),
|
|
234
|
+
};
|
|
235
|
+
const textSlot = { display: "inline-flex", minWidth: 0, paddingLeft: T.text, paddingRight: T.text };
|
|
236
|
+
const ico = (name, fill = 0) => <Icon name={name} size={spec.ico} fill={fill} style={{ flexShrink: 0 }} />;
|
|
237
|
+
|
|
238
|
+
/* ── Dismissible ─────────────────────────────────────────────────────── */
|
|
239
|
+
if (type === "dismissible") {
|
|
240
|
+
const fam = FAMILY[status] || FAMILY.Neutral;
|
|
241
|
+
const stroke = painted.stroke === "strong" ? C(fam.strokeStrong) : painted.stroke === "subtle" ? C(fam.strokeSubtle) : null;
|
|
242
|
+
const xOff = disabled || removeDisabled;
|
|
243
|
+
const name = showFieldLabel && fieldLabel ? `${fieldLabel} ${label}` : label;
|
|
244
|
+
const xBg = xOff ? "transparent" : pressed ? C("fill-action-secondary-pressed") : hovered ? C("fill-action-secondary-hover") : "transparent";
|
|
245
|
+
const xFg = xOff ? C("foreground-action-disabled") : pressed ? C("foreground-action-secondary-pressed") : hovered ? C("foreground-action-secondary-hover") : C("foreground-action-secondary-rest");
|
|
246
|
+
return (
|
|
247
|
+
<span className={`pw-badge pw-badge--dismissible ${className}`} data-type="dismissible" data-status={status} data-size={size}
|
|
248
|
+
style={{ ...outer, ...style }} {...rest}>
|
|
249
|
+
<span className="pw-badge__pill" style={{
|
|
250
|
+
...pillBase,
|
|
251
|
+
background: painted.fill ? C(fam.fill) : "transparent",
|
|
252
|
+
color: C(badgeStyle === "Naked" && fam.textNaked ? fam.textNaked : fam.text),
|
|
253
|
+
boxShadow: strokeShadow(stroke), ...focusRing(focused),
|
|
254
|
+
}}>
|
|
255
|
+
{leadingIcon && ico(leadingIcon, 1)}
|
|
256
|
+
<span className="pw-badge__label" style={{ ...textSlot, gap: T.fieldGap }}>
|
|
257
|
+
{showFieldLabel && fieldLabel && <span style={{ ...cut, maxWidth: T.fieldMax }}>{fieldLabel}</span>}
|
|
258
|
+
<span style={{ ...cut, maxWidth: spec.contentMax, fontWeight: Y("weight-semibold") }}>{label}</span>
|
|
259
|
+
</span>
|
|
260
|
+
<button type="button" className="pw-badge__remove" aria-label={`Remove ${name}`}
|
|
261
|
+
aria-disabled={xOff || undefined}
|
|
262
|
+
onClick={(e) => { if (xOff) { e.preventDefault(); return; } onRemove?.(e); }}
|
|
263
|
+
onMouseEnter={() => !xOff && setHovered(true)}
|
|
264
|
+
onMouseLeave={() => { setHovered(false); setPressed(false); }}
|
|
265
|
+
onMouseDown={() => !xOff && setPressed(true)}
|
|
266
|
+
onMouseUp={() => setPressed(false)}
|
|
267
|
+
onFocus={(e) => { if (e.target.matches?.(":focus-visible")) setFocused(true); }}
|
|
268
|
+
onBlur={() => { setFocused(false); setPressed(false); }}
|
|
269
|
+
onKeyDown={(e) => { if ((e.key === " " || e.key === "Enter") && !xOff) setPressed(true); }}
|
|
270
|
+
onKeyUp={() => setPressed(false)}
|
|
271
|
+
style={{
|
|
272
|
+
position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
273
|
+
width: spec.ico, height: spec.ico, padding: 0, margin: 0, border: 0, outline: "none",
|
|
274
|
+
borderRadius: L.radiusX, background: xBg, color: xFg, flexShrink: 0,
|
|
275
|
+
cursor: xOff ? "not-allowed" : "pointer", transition,
|
|
276
|
+
}}>
|
|
277
|
+
<Icon name="close" size={spec.ico} />
|
|
278
|
+
{/* the 44 press area, centred on the x (only the x is pressable) */}
|
|
279
|
+
<span aria-hidden="true" style={{ position: "absolute", left: "50%", top: "50%", transform: "translate(-50%, -50%)", width: T.touchW, height: T.touchH }} />
|
|
280
|
+
</button>
|
|
281
|
+
</span>
|
|
282
|
+
</span>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── Interactive ─────────────────────────────────────────────────────── */
|
|
287
|
+
const on = selected !== undefined ? selected : inner;
|
|
288
|
+
const a = ACTION(status);
|
|
289
|
+
const st = pressed ? "pressed" : hovered ? "hover" : "rest";
|
|
290
|
+
const offFill = painted.fill || st !== "rest";
|
|
291
|
+
const fill = disabled ? C("fill-action-disabled") : on ? a.on.fill[st] : (offFill ? a.off.fill[st] : "transparent");
|
|
292
|
+
const fg = disabled ? C("foreground-action-disabled") : on ? a.on.text : a.off.text[st];
|
|
293
|
+
const stroke = disabled || on || !painted.stroke ? null : a.off.stroke[st];
|
|
294
|
+
const drill = !!href || showTrailingIcon;
|
|
295
|
+
const lead = on ? "check" : leadingIcon;
|
|
296
|
+
const Tag = href ? "a" : "button";
|
|
297
|
+
const handlers = {
|
|
298
|
+
onMouseEnter: () => !disabled && setHovered(true),
|
|
299
|
+
onMouseLeave: () => { setHovered(false); setPressed(false); },
|
|
300
|
+
onMouseDown: () => !disabled && setPressed(true),
|
|
301
|
+
onMouseUp: () => setPressed(false),
|
|
302
|
+
onFocus: (e) => { if (e.target.matches?.(":focus-visible")) setFocused(true); },
|
|
303
|
+
onBlur: () => { setFocused(false); setPressed(false); },
|
|
304
|
+
onKeyDown: (e) => { if ((e.key === " " || e.key === "Enter") && !disabled) setPressed(true); },
|
|
305
|
+
onKeyUp: () => setPressed(false),
|
|
306
|
+
onClick: (e) => {
|
|
307
|
+
if (disabled) { e.preventDefault(); return; }
|
|
308
|
+
if (drill) { rest.onClick?.(e); return; }
|
|
309
|
+
const next = !on; if (selected === undefined) setInner(next); onChange?.(next);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
return (
|
|
313
|
+
<Tag className={`pw-badge pw-badge--interactive ${className}`} data-type="interactive" data-status={status} data-size={size}
|
|
314
|
+
{...(href ? { href } : { type: "button" })}
|
|
315
|
+
{...(!drill ? { "aria-pressed": on } : null)}
|
|
316
|
+
aria-disabled={disabled || undefined}
|
|
317
|
+
{...rest} {...handlers}
|
|
318
|
+
style={{ ...outer, cursor: disabled ? "not-allowed" : "pointer", ...style }}>
|
|
319
|
+
<span className="pw-badge__pill" style={{
|
|
320
|
+
...pillBase, background: fill, color: fg,
|
|
321
|
+
boxShadow: strokeShadow(stroke), ...focusRing(focused),
|
|
322
|
+
}}>
|
|
323
|
+
{lead && ico(lead, on ? 0 : 1)}
|
|
324
|
+
<span className="pw-badge__label" style={textSlot}><span style={{ ...cut, display: "block", maxWidth: spec.contentMax }}>{label}</span></span>
|
|
325
|
+
{showTrailingIcon && ico(trailingIcon || "chevron_right")}
|
|
326
|
+
</span>
|
|
327
|
+
</Tag>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export default Badge;
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* a 240px nav, no item radius, a 4px stripe rounded at 4, a 20px glyph, a 22px
|
|
9
9
|
* module mark, raw rgba hover fills, and a 56px NavHeader Figma does not draw.
|
|
10
10
|
*/
|
|
11
|
-
import React from "react";
|
|
11
|
+
import React, { useState } from "react";
|
|
12
12
|
import { DemoChrome, mount } from "../_demo/harness.jsx";
|
|
13
|
-
import { NavShell } from "./nav-shell.jsx";
|
|
13
|
+
import { NavShell, ACCOUNTING_APP_SWITCHER } from "./nav-shell.jsx";
|
|
14
14
|
import { PageTemplate } from "../page-template/page-template.jsx";
|
|
15
15
|
import { NAV_SECTIONS } from "../../src/stories/Library/SideNav/sidenavDemoData.jsx";
|
|
16
16
|
|
|
@@ -25,14 +25,26 @@ const ORG = { id: "gcc", name: "Grace Community Church", campus: "Knoxville", in
|
|
|
25
25
|
const USER = { name: "Jo Lopez", initials: "JL", email: "jo@gracecommunity.org" };
|
|
26
26
|
|
|
27
27
|
function App() {
|
|
28
|
+
// The app switcher's on/off, the same switch as the web component's
|
|
29
|
+
// `app-switcher` attribute. It lives in the page so it works in every width.
|
|
30
|
+
const [appSwitcher, setAppSwitcher] = useState(false);
|
|
31
|
+
const [app, setApp] = useState("financials-home");
|
|
28
32
|
return (
|
|
29
33
|
<DemoChrome title="NavShell" widths={WIDTHS}
|
|
30
34
|
blurb={"TopNav, SideNav and the page area composed into one application frame. " +
|
|
31
35
|
"Desktop pushes the page with a collapsible nav, Tablet locks the 72px rail, " +
|
|
32
|
-
"Mobile opens the nav as an overlay from the hamburger."}>
|
|
36
|
+
"Mobile opens the nav as an overlay from the hamburger. Tick App switcher to turn on the Accounting app switcher."}>
|
|
33
37
|
<NavShell activeModuleId="accounting" org={ORG} user={USER}
|
|
34
|
-
navSections={NAV_SECTIONS} activeNavId="balance_sheet" onNavigate={() => {}}
|
|
38
|
+
navSections={NAV_SECTIONS} activeNavId="balance_sheet" onNavigate={() => {}}
|
|
39
|
+
appSwitcher={appSwitcher ? { ...ACCOUNTING_APP_SWITCHER, activeId: app, onSelect: setApp } : null}>
|
|
35
40
|
<PageTemplate>
|
|
41
|
+
<label style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)",
|
|
42
|
+
fontSize: Y("font-size-s"), lineHeight: Y("line-height-s-single"),
|
|
43
|
+
color: C("foreground-static-neutral-base"), cursor: "pointer" }}>
|
|
44
|
+
<input id="navshell-app-switcher" type="checkbox" checked={appSwitcher}
|
|
45
|
+
onChange={(e) => setAppSwitcher(e.target.checked)} />
|
|
46
|
+
App switcher (Accounting)
|
|
47
|
+
</label>
|
|
36
48
|
<h1 style={{ margin: 0, fontSize: Y("font-size-xl"), lineHeight: Y("line-height-xl-single"),
|
|
37
49
|
fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") }}>
|
|
38
50
|
Balance Sheet
|
|
@@ -75,6 +75,20 @@ function useBreakpoint() {
|
|
|
75
75
|
* defaultCollapsed boolean — initial desktop collapse state (default false)
|
|
76
76
|
* children ReactNode — page content (renders inside Shell.Main)
|
|
77
77
|
*/
|
|
78
|
+
// The Accounting module's apps, as Figma's Accounting.AppSwitcher lists them.
|
|
79
|
+
// The web component falls back to this when `app-switcher` is on and no
|
|
80
|
+
// `appSwitcher` property has been set.
|
|
81
|
+
export const ACCOUNTING_APP_SWITCHER = {
|
|
82
|
+
label: "Financials Home",
|
|
83
|
+
icon: "apps",
|
|
84
|
+
home: { id: "financials-home", label: "Financials Home" },
|
|
85
|
+
apps: [
|
|
86
|
+
{ id: "accounts-payable", label: "Accounts Payable" },
|
|
87
|
+
{ id: "accounts-receivable", label: "Accounts Receivable" },
|
|
88
|
+
{ id: "bank-reconciliation", label: "Bank Reconciliation" },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
78
92
|
export function NavShell({
|
|
79
93
|
// TopNav
|
|
80
94
|
modules = DEFAULT_MODULES,
|
|
@@ -93,6 +107,9 @@ export function NavShell({
|
|
|
93
107
|
navHeaderEnd = null,
|
|
94
108
|
navChildren = null,
|
|
95
109
|
navFooter = null,
|
|
110
|
+
// Module app switcher in the SideNav (Figma Accounting.AppSwitcher). Off
|
|
111
|
+
// when null; see SideNav's `appSwitcher` for the shape.
|
|
112
|
+
appSwitcher = null,
|
|
96
113
|
|
|
97
114
|
// Shell
|
|
98
115
|
defaultCollapsed = false,
|
|
@@ -233,6 +250,7 @@ export function NavShell({
|
|
|
233
250
|
hideCollapseButton={isTablet || isMobile}
|
|
234
251
|
headerEnd={navHeaderEnd}
|
|
235
252
|
footer={navFooter}
|
|
253
|
+
appSwitcher={appSwitcher}
|
|
236
254
|
>
|
|
237
255
|
{navChildren}
|
|
238
256
|
</SideNav>
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* 2026-10-07, 40003951-2927, does not exist in the file.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import React, { useState, useRef } from "react";
|
|
26
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
27
27
|
import { Scrollable } from "../scrollbar/scrollbar.jsx";
|
|
28
28
|
import ReactDOM from "react-dom";
|
|
29
29
|
|
|
@@ -105,7 +105,9 @@ export const T = {
|
|
|
105
105
|
navActive: c("foreground-action-secondary-pressed"),
|
|
106
106
|
navDisabled: c("foreground-action-disabled"),
|
|
107
107
|
secondary: c("foreground-static-neutral-subtle"), // PopoverMenu.SectionHeader
|
|
108
|
-
|
|
108
|
+
// NavSectionLabel. Subtle, not Faint: Faint at 11px measured 4.23:1 on
|
|
109
|
+
// white in Amplify Light, under AA's 4.5. Figma SectionLabel rebound to match.
|
|
110
|
+
secondarySubtle: c("foreground-static-neutral-subtle"),
|
|
109
111
|
strong: c("foreground-static-neutral-strong"), // PopoverMenu.Item, tooltip
|
|
110
112
|
},
|
|
111
113
|
icon: {
|
|
@@ -171,7 +173,8 @@ export const L = {
|
|
|
171
173
|
itemPadH: u("padding-xxtight"),
|
|
172
174
|
// Container.HasChildren: 2px padding around a 16px frame holding a 12px
|
|
173
175
|
// expand_more glyph, 20px wide in total. Was a 40px box with a 10px glyph.
|
|
174
|
-
chevPad:
|
|
176
|
+
chevPad: "var(--semantic-layout-units-padding-xxxxtight, 2px)",
|
|
177
|
+
chevBox: 20, // Container.HasChildren, a measurement (no token needed)
|
|
175
178
|
chevFrame: 16, // no token in Figma, icon frame
|
|
176
179
|
chevGlyph: 12, // no token in Figma, expand_more glyph
|
|
177
180
|
// Level 1 (nesting) rows: rowStart 12 + container.main 24 + label 8, so the
|
|
@@ -358,7 +361,11 @@ export function SideNavItem({
|
|
|
358
361
|
2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
|
|
359
362
|
should be one. It is now not rendered at all when collapsed. */}
|
|
360
363
|
{!isChild && hasChildren && !isSidebarCollapsed && (
|
|
361
|
-
|
|
364
|
+
// 20 = 2 + the 16 frame + 2. A plain width: it was a calc() on
|
|
365
|
+
// Padding/XXXXTight, and on a token set without that name (the
|
|
366
|
+
// 10.x packages) the whole width was dropped, the box shrank to the
|
|
367
|
+
// glyph and the chevron sat flush against the row's edge.
|
|
368
|
+
<div style={{ width: L.chevBox, height: L.iconWrap, display: "flex",
|
|
362
369
|
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
363
370
|
opacity: isSidebarCollapsed ? 0 : 1,
|
|
364
371
|
overflow: "hidden",
|
|
@@ -490,7 +497,11 @@ export function PopoverRow({ item, onClick, activeId }) {
|
|
|
490
497
|
const color = T.text.strong;
|
|
491
498
|
return (
|
|
492
499
|
<div onClick={() => onClick(item.id)}
|
|
500
|
+
role="menuitem" tabIndex={0}
|
|
501
|
+
aria-current={isActive ? "page" : undefined}
|
|
502
|
+
onKeyDown={e => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onClick(item.id))}
|
|
493
503
|
onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
504
|
+
onFocus={() => setH(true)} onBlur={() => setH(false)}
|
|
494
505
|
// PopoverMenu.Item: min 44, padding 12 all round, radius CornerRadius/Base.
|
|
495
506
|
style={{ position: "relative", display: "flex", alignItems: "center", minHeight: L.itemH,
|
|
496
507
|
boxSizing: "border-box", padding: "var(--semantic-layout-units-padding-tight)",
|
|
@@ -516,6 +527,173 @@ export function PopoverRow({ item, onClick, activeId }) {
|
|
|
516
527
|
);
|
|
517
528
|
}
|
|
518
529
|
|
|
530
|
+
|
|
531
|
+
// Is the event inside `el`? composedPath, because inside a web component a
|
|
532
|
+
// document listener sees e.target retargeted to the HOST element, so
|
|
533
|
+
// el.contains(e.target) is false for a click on el itself. That made a second
|
|
534
|
+
// click on an open trigger close the panel on mousedown and reopen it on click.
|
|
535
|
+
const eventInside = (el, e) =>
|
|
536
|
+
!!el && (typeof e.composedPath === "function" ? e.composedPath().includes(el) : el.contains(e.target));
|
|
537
|
+
|
|
538
|
+
// ─── AppSwitcher ──────────────────────────────────────────────────────────────
|
|
539
|
+
// Figma: Accounting.AppSwitcher (set 40018575:65691), placed as the FIRST child
|
|
540
|
+
// of SideNavMenu in SideNav.Local.Accounting (40016737:141666). A module-level
|
|
541
|
+
// switcher between the module's own applications (Financials Home, Accounts
|
|
542
|
+
// Payable, Accounts Receivable...). Off unless `appSwitcher` is passed to SideNav.
|
|
543
|
+
//
|
|
544
|
+
// Anatomy, read from the variants on 2026-11-06:
|
|
545
|
+
// wrapper padding XTight top/bottom, XXXXTight sides (expanded); min 44
|
|
546
|
+
// Container.Main 44 tall, radius CornerRadius/Base, padding XXTight sides,
|
|
547
|
+
// gap 2, Button/BorderWidth stroke, Widget shadow
|
|
548
|
+
// Action Icon 36 (padding XTight) > 24 box > `apps` at 16
|
|
549
|
+
// Label Label/Button/Small: 14 / 500 / 20 / 0.3, truncates
|
|
550
|
+
// RowEnd 20 box > `chevron_right` at 14 (expanded only)
|
|
551
|
+
// PopoverMenu 2px to the right of the wrapper, top aligned: 220 to 360
|
|
552
|
+
// wide, CornerRadius/Large, 0.5 Stroke/Static/Neutral/Faint,
|
|
553
|
+
// Fill/Surface/Elevated, Elevation/Lift; items padding XTight /
|
|
554
|
+
// XXTight, gap XXXTight. Home row, a separator, then the apps;
|
|
555
|
+
// the current app takes the selected fill and the stripe.
|
|
556
|
+
//
|
|
557
|
+
// States (fill / stroke / glyph+label):
|
|
558
|
+
// Rest none / Primary/Subtle/Rest 0.75 / Static/Neutral/Bold
|
|
559
|
+
// Hover Primary/Subtle/Hover / Primary/Strong/Hover / On Subtle/Hover
|
|
560
|
+
// Pressed-Open Primary/Subtle/Pressed / Primary/Strong/Pressed 1px / On Subtle/Pressed
|
|
561
|
+
// The chevron is Action/Secondary/Rest at rest and follows the label otherwise.
|
|
562
|
+
//
|
|
563
|
+
// Rail: Figma draws a 60 square that overhangs the 48 column by 6 a side. The
|
|
564
|
+
// overhang is transparent padding only, and the scroll view clips horizontally,
|
|
565
|
+
// so here the wrapper keeps the 8px vertical padding and the 48x44 Main sits
|
|
566
|
+
// exactly where Figma puts it. The target is 48x60, above the AA 44.
|
|
567
|
+
//
|
|
568
|
+
// The Figma shadow colours the Widget geometry with Brand/600 @ 16%, which has
|
|
569
|
+
// no semantic token; --elevation-widget is the same offsets on the system
|
|
570
|
+
// shadow colour.
|
|
571
|
+
const APP_T = {
|
|
572
|
+
restStroke: c("stroke-action-primary-subtle-rest"),
|
|
573
|
+
hoverFill: c("fill-action-primary-subtle-hover"),
|
|
574
|
+
hoverStroke: c("stroke-action-primary-strong-hover"),
|
|
575
|
+
hoverFg: c("foreground-action-primary-on-subtle-hover"),
|
|
576
|
+
openFill: c("fill-action-primary-subtle-pressed"),
|
|
577
|
+
openStroke: c("stroke-action-primary-strong-pressed"),
|
|
578
|
+
openFg: c("foreground-action-primary-on-subtle-pressed"),
|
|
579
|
+
restFg: c("foreground-static-neutral-bold"),
|
|
580
|
+
restChevron: c("foreground-action-secondary-rest"),
|
|
581
|
+
separator: c("stroke-static-neutral-base"),
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
export function AppSwitcher({ label, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false }) {
|
|
585
|
+
const [hov, setHov] = useState(false);
|
|
586
|
+
const [press, setPress] = useState(false);
|
|
587
|
+
const [open, setOpen] = useState(false);
|
|
588
|
+
const [rect, setRect] = useState(null);
|
|
589
|
+
const wrapRef = useRef(null);
|
|
590
|
+
const btnRef = useRef(null);
|
|
591
|
+
const menuRef = useRef(null);
|
|
592
|
+
|
|
593
|
+
const toggle = () => {
|
|
594
|
+
if (open) { setOpen(false); return; }
|
|
595
|
+
setRect(wrapRef.current ? wrapRef.current.getBoundingClientRect() : null);
|
|
596
|
+
setOpen(true);
|
|
597
|
+
};
|
|
598
|
+
const choose = (id) => { setOpen(false); onSelect && onSelect(id); btnRef.current && btnRef.current.focus(); };
|
|
599
|
+
|
|
600
|
+
// Close on a click outside either box, and on Escape (focus returns to the trigger).
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
if (!open) return;
|
|
603
|
+
const down = (e) => {
|
|
604
|
+
if (eventInside(wrapRef.current, e)) return;
|
|
605
|
+
if (eventInside(menuRef.current, e)) return;
|
|
606
|
+
setOpen(false);
|
|
607
|
+
};
|
|
608
|
+
const key = (e) => { if (e.key === "Escape") { setOpen(false); btnRef.current && btnRef.current.focus(); } };
|
|
609
|
+
document.addEventListener("mousedown", down);
|
|
610
|
+
document.addEventListener("keydown", key);
|
|
611
|
+
return () => { document.removeEventListener("mousedown", down); document.removeEventListener("keydown", key); };
|
|
612
|
+
}, [open]);
|
|
613
|
+
|
|
614
|
+
const active = open || press;
|
|
615
|
+
const fg = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restFg;
|
|
616
|
+
const chevron = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restChevron;
|
|
617
|
+
const homeItem = home || { id: "__home", label };
|
|
618
|
+
|
|
619
|
+
return (
|
|
620
|
+
// Full column width in both states: 218 expanded, the rail's 48 collapsed,
|
|
621
|
+
// where Figma's Container.Main is 48x44 (one rail item is a 44 square).
|
|
622
|
+
<div ref={wrapRef} style={{ boxSizing: "border-box", width: "100%",
|
|
623
|
+
minHeight: L.itemH, flexShrink: 0,
|
|
624
|
+
padding: collapsed
|
|
625
|
+
? `${L.textPad} 0`
|
|
626
|
+
: `${L.textPad} ${L.chevPad}` }}>
|
|
627
|
+
<button ref={btnRef} type="button" onClick={toggle}
|
|
628
|
+
aria-haspopup="menu" aria-expanded={open}
|
|
629
|
+
aria-label={collapsed ? `Switch application: ${label}` : undefined}
|
|
630
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
631
|
+
onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
|
|
632
|
+
style={{ display: "flex", alignItems: "center", width: "100%", height: L.itemH,
|
|
633
|
+
boxSizing: "border-box", margin: 0, cursor: "pointer", fontFamily: "inherit",
|
|
634
|
+
justifyContent: collapsed ? "center" : "flex-start",
|
|
635
|
+
gap: collapsed ? 0 : "var(--semantic-layout-units-gap-xxxtight)",
|
|
636
|
+
paddingLeft: L.itemPadH, paddingRight: L.itemPadH,
|
|
637
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
638
|
+
background: active ? APP_T.openFill : hov ? APP_T.hoverFill : "transparent",
|
|
639
|
+
// Drawn inside, as Figma strokes, so 0.75 and 1px do not move the content.
|
|
640
|
+
border: "none",
|
|
641
|
+
boxShadow: `inset 0 0 0 ${active ? "var(--contextual-layout-units-button-borderwidth-selected)" : "var(--contextual-layout-units-button-borderwidth-rest)"} ${active ? APP_T.openStroke : hov ? APP_T.hoverStroke : APP_T.restStroke}, var(--elevation-widget)`,
|
|
642
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
643
|
+
{/* Action Icon: 36 box, 24 wrapper, the glyph at its 16 frame */}
|
|
644
|
+
<span style={{ width: 36, height: 36, display: "flex", alignItems: "center",
|
|
645
|
+
justifyContent: "center", flexShrink: 0 }}>
|
|
646
|
+
<span className="material-symbols-rounded" aria-hidden="true"
|
|
647
|
+
style={{ fontSize: L.iconInner, lineHeight: 1, display: "block", color: fg,
|
|
648
|
+
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24",
|
|
649
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>{icon}</span>
|
|
650
|
+
</span>
|
|
651
|
+
{!collapsed && (
|
|
652
|
+
<span style={{ flex: 1, minWidth: 0, textAlign: "left",
|
|
653
|
+
fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)",
|
|
654
|
+
lineHeight: "var(--semantic-type-line-height-s-single)",
|
|
655
|
+
letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
656
|
+
color: fg, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
657
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>{label}</span>
|
|
658
|
+
)}
|
|
659
|
+
{!collapsed && (
|
|
660
|
+
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center",
|
|
661
|
+
justifyContent: "center", flexShrink: 0 }}>
|
|
662
|
+
<span className="material-symbols-rounded" aria-hidden="true"
|
|
663
|
+
style={{ fontSize: 14, lineHeight: 1, display: "block", color: chevron,
|
|
664
|
+
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24" }}>chevron_right</span>
|
|
665
|
+
</span>
|
|
666
|
+
)}
|
|
667
|
+
</button>
|
|
668
|
+
|
|
669
|
+
{open && rect && ReactDOM.createPortal(
|
|
670
|
+
<div ref={menuRef} role="menu" aria-label={label}
|
|
671
|
+
style={{ position: "fixed", left: `calc(${rect.right}px + var(--semantic-layout-units-gap-xxxtight))`,
|
|
672
|
+
top: rect.top, zIndex: 1000, boxSizing: "border-box",
|
|
673
|
+
minWidth: 220, maxWidth: 360,
|
|
674
|
+
maxHeight: `calc(100vh - ${Math.round(rect.top)}px - var(--semantic-layout-units-padding-base))`,
|
|
675
|
+
overflowY: "auto",
|
|
676
|
+
background: T.surface.navElevated,
|
|
677
|
+
border: `var(--semantic-layout-units-borderwidth-xthin, 0.5px) solid ${c("stroke-static-neutral-faint")}`,
|
|
678
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-large)",
|
|
679
|
+
boxShadow: c2("--elevation-lift"),
|
|
680
|
+
padding: "var(--semantic-layout-units-padding-xtight) var(--semantic-layout-units-padding-xxtight)",
|
|
681
|
+
display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xxxtight)",
|
|
682
|
+
animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
683
|
+
<PopoverRow item={homeItem} onClick={choose} activeId={activeId} />
|
|
684
|
+
{apps.length > 0 && (
|
|
685
|
+
<div role="separator" style={{ padding: "var(--semantic-layout-units-padding-xtight)" }}>
|
|
686
|
+
<div style={{ borderTop: `0.5px solid ${APP_T.separator}` }} />
|
|
687
|
+
</div>
|
|
688
|
+
)}
|
|
689
|
+
{apps.map(a => <PopoverRow key={a.id} item={a} onClick={choose} activeId={activeId} />)}
|
|
690
|
+
</div>,
|
|
691
|
+
document.body
|
|
692
|
+
)}
|
|
693
|
+
</div>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
519
697
|
// ─── CollapseButton ───────────────────────────────────────────────────────────
|
|
520
698
|
// Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
|
|
521
699
|
// + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
|
|
@@ -573,7 +751,7 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
|
|
|
573
751
|
// ─── SideNav (top-level container) ────────────────────────────────────────────
|
|
574
752
|
// ── NavSectionLabel ──────────────────────────────────────────────────────────
|
|
575
753
|
// Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
|
|
576
|
-
// Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/
|
|
754
|
+
// Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Subtle
|
|
577
755
|
// Container: h 40, padding 6 left, 6 right, 12 vertical (read 2026-11-03; was
|
|
578
756
|
// 4 / 4 / 8 in neutral-base)
|
|
579
757
|
// In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
|
|
@@ -747,6 +925,10 @@ export function SideNav({
|
|
|
747
925
|
headerEnd = null,
|
|
748
926
|
children = null,
|
|
749
927
|
footer = null,
|
|
928
|
+
// Module app switcher (Figma Accounting.AppSwitcher). Off when null.
|
|
929
|
+
// { label, icon?, home?: {id,label}, apps: [{id,label}], activeId, onSelect }
|
|
930
|
+
// Renders as the first entry of the scrolling menu, in both rail states.
|
|
931
|
+
appSwitcher = null,
|
|
750
932
|
}) {
|
|
751
933
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
752
934
|
const [popoverId, setPopoverId] = useState(null);
|
|
@@ -895,6 +1077,8 @@ export function SideNav({
|
|
|
895
1077
|
paddingRight: collapsed ? L.navColPadH : L.navPadH }}
|
|
896
1078
|
>
|
|
897
1079
|
|
|
1080
|
+
{appSwitcher && <AppSwitcher {...appSwitcher} collapsed={collapsed} />}
|
|
1081
|
+
|
|
898
1082
|
{/* Sectioned render: when `sections` prop is provided */}
|
|
899
1083
|
{sections && sections.map(({ section, items: secItems }, sIdx) => (
|
|
900
1084
|
<div key={section} style={{ display: "flex", flexDirection: "column",
|
|
@@ -306,7 +306,12 @@ export function Tooltip({
|
|
|
306
306
|
useEffect(() => {
|
|
307
307
|
if (!isOpen) return;
|
|
308
308
|
const onKey = (e) => { if (e.key === "Escape") { setDismissed(true); setTapped(false); } };
|
|
309
|
-
|
|
309
|
+
// composedPath: inside a web component e.target is the HOST, so contains()
|
|
310
|
+
// would treat a tap on the trigger itself as a tap outside.
|
|
311
|
+
const onDown = (e) => {
|
|
312
|
+
const inside = (el) => !!el && (typeof e.composedPath === "function" ? e.composedPath().includes(el) : el.contains(e.target));
|
|
313
|
+
if (!inside(triggerRef.current) && !inside(tipRef.current)) { setHover(false); setTapped(false); }
|
|
314
|
+
};
|
|
310
315
|
document.addEventListener("keydown", onKey);
|
|
311
316
|
document.addEventListener("pointerdown", onDown);
|
|
312
317
|
return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onDown); };
|
|
@@ -359,6 +359,13 @@ export const DEFAULT_ACTIONS = [
|
|
|
359
359
|
{ icon: "notifications_unread", label: "Notifications" },
|
|
360
360
|
];
|
|
361
361
|
|
|
362
|
+
// Is the event inside `el`? composedPath, because inside a web component a
|
|
363
|
+
// document listener sees e.target retargeted to the HOST element, so
|
|
364
|
+
// el.contains(e.target) is false for a click on el itself. That made a second
|
|
365
|
+
// click on an open trigger close the panel on mousedown and reopen it on click.
|
|
366
|
+
const eventInside = (el, e) =>
|
|
367
|
+
!!el && (typeof e.composedPath === "function" ? e.composedPath().includes(el) : el.contains(e.target));
|
|
368
|
+
|
|
362
369
|
function TopNavActionIcon({ icon, label, onClick }) {
|
|
363
370
|
const [hov, setHov] = useState(false);
|
|
364
371
|
const [press, setPress] = useState(false);
|
|
@@ -1061,7 +1068,7 @@ export function TopNav({
|
|
|
1061
1068
|
const close = () => setOpenPanel(null);
|
|
1062
1069
|
|
|
1063
1070
|
useEffect(() => {
|
|
1064
|
-
const h = (e) => { if (!navRef.current
|
|
1071
|
+
const h = (e) => { if (!eventInside(navRef.current, e)) close(); };
|
|
1065
1072
|
document.addEventListener("mousedown", h);
|
|
1066
1073
|
return () => document.removeEventListener("mousedown", h);
|
|
1067
1074
|
}, []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helloimjolopez-pathway/pathway-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Pathway Design System React components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"./sidenav": "./components/sidenav/sidenav.jsx",
|
|
18
18
|
"./nav-shell": "./components/nav-shell/nav-shell.jsx",
|
|
19
19
|
"./page-template": "./components/page-template/page-template.jsx",
|
|
20
|
-
"./tooltip": "./components/tooltip/tooltip.jsx"
|
|
20
|
+
"./tooltip": "./components/tooltip/tooltip.jsx",
|
|
21
|
+
"./badge": "./components/badge/badge.jsx"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
24
|
"react": ">=17",
|