@helloimjolopez-pathway/pathway-components 0.2.4 → 0.3.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.
|
@@ -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;
|
|
@@ -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
|
|
|
@@ -171,7 +171,8 @@ export const L = {
|
|
|
171
171
|
itemPadH: u("padding-xxtight"),
|
|
172
172
|
// Container.HasChildren: 2px padding around a 16px frame holding a 12px
|
|
173
173
|
// expand_more glyph, 20px wide in total. Was a 40px box with a 10px glyph.
|
|
174
|
-
chevPad:
|
|
174
|
+
chevPad: "var(--semantic-layout-units-padding-xxxxtight, 2px)",
|
|
175
|
+
chevBox: 20, // Container.HasChildren, a measurement (no token needed)
|
|
175
176
|
chevFrame: 16, // no token in Figma, icon frame
|
|
176
177
|
chevGlyph: 12, // no token in Figma, expand_more glyph
|
|
177
178
|
// Level 1 (nesting) rows: rowStart 12 + container.main 24 + label 8, so the
|
|
@@ -358,7 +359,11 @@ export function SideNavItem({
|
|
|
358
359
|
2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
|
|
359
360
|
should be one. It is now not rendered at all when collapsed. */}
|
|
360
361
|
{!isChild && hasChildren && !isSidebarCollapsed && (
|
|
361
|
-
|
|
362
|
+
// 20 = 2 + the 16 frame + 2. A plain width: it was a calc() on
|
|
363
|
+
// Padding/XXXXTight, and on a token set without that name (the
|
|
364
|
+
// 10.x packages) the whole width was dropped, the box shrank to the
|
|
365
|
+
// glyph and the chevron sat flush against the row's edge.
|
|
366
|
+
<div style={{ width: L.chevBox, height: L.iconWrap, display: "flex",
|
|
362
367
|
alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
363
368
|
opacity: isSidebarCollapsed ? 0 : 1,
|
|
364
369
|
overflow: "hidden",
|
|
@@ -490,7 +495,11 @@ export function PopoverRow({ item, onClick, activeId }) {
|
|
|
490
495
|
const color = T.text.strong;
|
|
491
496
|
return (
|
|
492
497
|
<div onClick={() => onClick(item.id)}
|
|
498
|
+
role="menuitem" tabIndex={0}
|
|
499
|
+
aria-current={isActive ? "page" : undefined}
|
|
500
|
+
onKeyDown={e => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onClick(item.id))}
|
|
493
501
|
onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
|
502
|
+
onFocus={() => setH(true)} onBlur={() => setH(false)}
|
|
494
503
|
// PopoverMenu.Item: min 44, padding 12 all round, radius CornerRadius/Base.
|
|
495
504
|
style={{ position: "relative", display: "flex", alignItems: "center", minHeight: L.itemH,
|
|
496
505
|
boxSizing: "border-box", padding: "var(--semantic-layout-units-padding-tight)",
|
|
@@ -516,6 +525,173 @@ export function PopoverRow({ item, onClick, activeId }) {
|
|
|
516
525
|
);
|
|
517
526
|
}
|
|
518
527
|
|
|
528
|
+
|
|
529
|
+
// Is the event inside `el`? composedPath, because inside a web component a
|
|
530
|
+
// document listener sees e.target retargeted to the HOST element, so
|
|
531
|
+
// el.contains(e.target) is false for a click on el itself. That made a second
|
|
532
|
+
// click on an open trigger close the panel on mousedown and reopen it on click.
|
|
533
|
+
const eventInside = (el, e) =>
|
|
534
|
+
!!el && (typeof e.composedPath === "function" ? e.composedPath().includes(el) : el.contains(e.target));
|
|
535
|
+
|
|
536
|
+
// ─── AppSwitcher ──────────────────────────────────────────────────────────────
|
|
537
|
+
// Figma: Accounting.AppSwitcher (set 40018575:65691), placed as the FIRST child
|
|
538
|
+
// of SideNavMenu in SideNav.Local.Accounting (40016737:141666). A module-level
|
|
539
|
+
// switcher between the module's own applications (Financials Home, Accounts
|
|
540
|
+
// Payable, Accounts Receivable...). Off unless `appSwitcher` is passed to SideNav.
|
|
541
|
+
//
|
|
542
|
+
// Anatomy, read from the variants on 2026-11-06:
|
|
543
|
+
// wrapper padding XTight top/bottom, XXXXTight sides (expanded); min 44
|
|
544
|
+
// Container.Main 44 tall, radius CornerRadius/Base, padding XXTight sides,
|
|
545
|
+
// gap 2, Button/BorderWidth stroke, Widget shadow
|
|
546
|
+
// Action Icon 36 (padding XTight) > 24 box > `apps` at 16
|
|
547
|
+
// Label Label/Button/Small: 14 / 500 / 20 / 0.3, truncates
|
|
548
|
+
// RowEnd 20 box > `chevron_right` at 14 (expanded only)
|
|
549
|
+
// PopoverMenu 2px to the right of the wrapper, top aligned: 220 to 360
|
|
550
|
+
// wide, CornerRadius/Large, 0.5 Stroke/Static/Neutral/Faint,
|
|
551
|
+
// Fill/Surface/Elevated, Elevation/Lift; items padding XTight /
|
|
552
|
+
// XXTight, gap XXXTight. Home row, a separator, then the apps;
|
|
553
|
+
// the current app takes the selected fill and the stripe.
|
|
554
|
+
//
|
|
555
|
+
// States (fill / stroke / glyph+label):
|
|
556
|
+
// Rest none / Primary/Subtle/Rest 0.75 / Static/Neutral/Bold
|
|
557
|
+
// Hover Primary/Subtle/Hover / Primary/Strong/Hover / On Subtle/Hover
|
|
558
|
+
// Pressed-Open Primary/Subtle/Pressed / Primary/Strong/Pressed 1px / On Subtle/Pressed
|
|
559
|
+
// The chevron is Action/Secondary/Rest at rest and follows the label otherwise.
|
|
560
|
+
//
|
|
561
|
+
// Rail: Figma draws a 60 square that overhangs the 48 column by 6 a side. The
|
|
562
|
+
// overhang is transparent padding only, and the scroll view clips horizontally,
|
|
563
|
+
// so here the wrapper keeps the 8px vertical padding and the 48x44 Main sits
|
|
564
|
+
// exactly where Figma puts it. The target is 48x60, above the AA 44.
|
|
565
|
+
//
|
|
566
|
+
// The Figma shadow colours the Widget geometry with Brand/600 @ 16%, which has
|
|
567
|
+
// no semantic token; --elevation-widget is the same offsets on the system
|
|
568
|
+
// shadow colour.
|
|
569
|
+
const APP_T = {
|
|
570
|
+
restStroke: c("stroke-action-primary-subtle-rest"),
|
|
571
|
+
hoverFill: c("fill-action-primary-subtle-hover"),
|
|
572
|
+
hoverStroke: c("stroke-action-primary-strong-hover"),
|
|
573
|
+
hoverFg: c("foreground-action-primary-on-subtle-hover"),
|
|
574
|
+
openFill: c("fill-action-primary-subtle-pressed"),
|
|
575
|
+
openStroke: c("stroke-action-primary-strong-pressed"),
|
|
576
|
+
openFg: c("foreground-action-primary-on-subtle-pressed"),
|
|
577
|
+
restFg: c("foreground-static-neutral-bold"),
|
|
578
|
+
restChevron: c("foreground-action-secondary-rest"),
|
|
579
|
+
separator: c("stroke-static-neutral-base"),
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
export function AppSwitcher({ label, icon = "apps", home, apps = [], activeId, onSelect, collapsed = false }) {
|
|
583
|
+
const [hov, setHov] = useState(false);
|
|
584
|
+
const [press, setPress] = useState(false);
|
|
585
|
+
const [open, setOpen] = useState(false);
|
|
586
|
+
const [rect, setRect] = useState(null);
|
|
587
|
+
const wrapRef = useRef(null);
|
|
588
|
+
const btnRef = useRef(null);
|
|
589
|
+
const menuRef = useRef(null);
|
|
590
|
+
|
|
591
|
+
const toggle = () => {
|
|
592
|
+
if (open) { setOpen(false); return; }
|
|
593
|
+
setRect(wrapRef.current ? wrapRef.current.getBoundingClientRect() : null);
|
|
594
|
+
setOpen(true);
|
|
595
|
+
};
|
|
596
|
+
const choose = (id) => { setOpen(false); onSelect && onSelect(id); btnRef.current && btnRef.current.focus(); };
|
|
597
|
+
|
|
598
|
+
// Close on a click outside either box, and on Escape (focus returns to the trigger).
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (!open) return;
|
|
601
|
+
const down = (e) => {
|
|
602
|
+
if (eventInside(wrapRef.current, e)) return;
|
|
603
|
+
if (eventInside(menuRef.current, e)) return;
|
|
604
|
+
setOpen(false);
|
|
605
|
+
};
|
|
606
|
+
const key = (e) => { if (e.key === "Escape") { setOpen(false); btnRef.current && btnRef.current.focus(); } };
|
|
607
|
+
document.addEventListener("mousedown", down);
|
|
608
|
+
document.addEventListener("keydown", key);
|
|
609
|
+
return () => { document.removeEventListener("mousedown", down); document.removeEventListener("keydown", key); };
|
|
610
|
+
}, [open]);
|
|
611
|
+
|
|
612
|
+
const active = open || press;
|
|
613
|
+
const fg = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restFg;
|
|
614
|
+
const chevron = active ? APP_T.openFg : hov ? APP_T.hoverFg : APP_T.restChevron;
|
|
615
|
+
const homeItem = home || { id: "__home", label };
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
// Full column width in both states: 218 expanded, the rail's 48 collapsed,
|
|
619
|
+
// where Figma's Container.Main is 48x44 (one rail item is a 44 square).
|
|
620
|
+
<div ref={wrapRef} style={{ boxSizing: "border-box", width: "100%",
|
|
621
|
+
minHeight: L.itemH, flexShrink: 0,
|
|
622
|
+
padding: collapsed
|
|
623
|
+
? `${L.textPad} 0`
|
|
624
|
+
: `${L.textPad} ${L.chevPad}` }}>
|
|
625
|
+
<button ref={btnRef} type="button" onClick={toggle}
|
|
626
|
+
aria-haspopup="menu" aria-expanded={open}
|
|
627
|
+
aria-label={collapsed ? `Switch application: ${label}` : undefined}
|
|
628
|
+
onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
629
|
+
onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
|
|
630
|
+
style={{ display: "flex", alignItems: "center", width: "100%", height: L.itemH,
|
|
631
|
+
boxSizing: "border-box", margin: 0, cursor: "pointer", fontFamily: "inherit",
|
|
632
|
+
justifyContent: collapsed ? "center" : "flex-start",
|
|
633
|
+
gap: collapsed ? 0 : "var(--semantic-layout-units-gap-xxxtight)",
|
|
634
|
+
paddingLeft: L.itemPadH, paddingRight: L.itemPadH,
|
|
635
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-base)",
|
|
636
|
+
background: active ? APP_T.openFill : hov ? APP_T.hoverFill : "transparent",
|
|
637
|
+
// Drawn inside, as Figma strokes, so 0.75 and 1px do not move the content.
|
|
638
|
+
border: "none",
|
|
639
|
+
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)`,
|
|
640
|
+
transition: "background-color var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
641
|
+
{/* Action Icon: 36 box, 24 wrapper, the glyph at its 16 frame */}
|
|
642
|
+
<span style={{ width: 36, height: 36, display: "flex", alignItems: "center",
|
|
643
|
+
justifyContent: "center", flexShrink: 0 }}>
|
|
644
|
+
<span className="material-symbols-rounded" aria-hidden="true"
|
|
645
|
+
style={{ fontSize: L.iconInner, lineHeight: 1, display: "block", color: fg,
|
|
646
|
+
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24",
|
|
647
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>{icon}</span>
|
|
648
|
+
</span>
|
|
649
|
+
{!collapsed && (
|
|
650
|
+
<span style={{ flex: 1, minWidth: 0, textAlign: "left",
|
|
651
|
+
fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)",
|
|
652
|
+
lineHeight: "var(--semantic-type-line-height-s-single)",
|
|
653
|
+
letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
|
|
654
|
+
color: fg, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
655
|
+
transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>{label}</span>
|
|
656
|
+
)}
|
|
657
|
+
{!collapsed && (
|
|
658
|
+
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center",
|
|
659
|
+
justifyContent: "center", flexShrink: 0 }}>
|
|
660
|
+
<span className="material-symbols-rounded" aria-hidden="true"
|
|
661
|
+
style={{ fontSize: 14, lineHeight: 1, display: "block", color: chevron,
|
|
662
|
+
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24" }}>chevron_right</span>
|
|
663
|
+
</span>
|
|
664
|
+
)}
|
|
665
|
+
</button>
|
|
666
|
+
|
|
667
|
+
{open && rect && ReactDOM.createPortal(
|
|
668
|
+
<div ref={menuRef} role="menu" aria-label={label}
|
|
669
|
+
style={{ position: "fixed", left: `calc(${rect.right}px + var(--semantic-layout-units-gap-xxxtight))`,
|
|
670
|
+
top: rect.top, zIndex: 1000, boxSizing: "border-box",
|
|
671
|
+
minWidth: 220, maxWidth: 360,
|
|
672
|
+
maxHeight: `calc(100vh - ${Math.round(rect.top)}px - var(--semantic-layout-units-padding-base))`,
|
|
673
|
+
overflowY: "auto",
|
|
674
|
+
background: T.surface.navElevated,
|
|
675
|
+
border: `var(--semantic-layout-units-borderwidth-xthin, 0.5px) solid ${c("stroke-static-neutral-faint")}`,
|
|
676
|
+
borderRadius: "var(--semantic-layout-units-cornerradius-large)",
|
|
677
|
+
boxShadow: c2("--elevation-lift"),
|
|
678
|
+
padding: "var(--semantic-layout-units-padding-xtight) var(--semantic-layout-units-padding-xxtight)",
|
|
679
|
+
display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xxxtight)",
|
|
680
|
+
animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
|
|
681
|
+
<PopoverRow item={homeItem} onClick={choose} activeId={activeId} />
|
|
682
|
+
{apps.length > 0 && (
|
|
683
|
+
<div role="separator" style={{ padding: "var(--semantic-layout-units-padding-xtight)" }}>
|
|
684
|
+
<div style={{ borderTop: `0.5px solid ${APP_T.separator}` }} />
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
687
|
+
{apps.map(a => <PopoverRow key={a.id} item={a} onClick={choose} activeId={activeId} />)}
|
|
688
|
+
</div>,
|
|
689
|
+
document.body
|
|
690
|
+
)}
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
519
695
|
// ─── CollapseButton ───────────────────────────────────────────────────────────
|
|
520
696
|
// Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
|
|
521
697
|
// + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
|
|
@@ -747,6 +923,10 @@ export function SideNav({
|
|
|
747
923
|
headerEnd = null,
|
|
748
924
|
children = null,
|
|
749
925
|
footer = null,
|
|
926
|
+
// Module app switcher (Figma Accounting.AppSwitcher). Off when null.
|
|
927
|
+
// { label, icon?, home?: {id,label}, apps: [{id,label}], activeId, onSelect }
|
|
928
|
+
// Renders as the first entry of the scrolling menu, in both rail states.
|
|
929
|
+
appSwitcher = null,
|
|
750
930
|
}) {
|
|
751
931
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
752
932
|
const [popoverId, setPopoverId] = useState(null);
|
|
@@ -895,6 +1075,8 @@ export function SideNav({
|
|
|
895
1075
|
paddingRight: collapsed ? L.navColPadH : L.navPadH }}
|
|
896
1076
|
>
|
|
897
1077
|
|
|
1078
|
+
{appSwitcher && <AppSwitcher {...appSwitcher} collapsed={collapsed} />}
|
|
1079
|
+
|
|
898
1080
|
{/* Sectioned render: when `sections` prop is provided */}
|
|
899
1081
|
{sections && sections.map(({ section, items: secItems }, sIdx) => (
|
|
900
1082
|
<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.0",
|
|
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",
|