@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.
@@ -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
- secondarySubtle: c("foreground-static-neutral-faint"), // NavSectionLabel
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: u("padding-xxxxtight"),
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
- <div style={{ width: `calc(${L.chevFrame}px + 2 * ${L.chevPad})`, height: L.iconWrap, display: "flex",
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/Faint
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
- const onDown = (e) => { if (!triggerRef.current?.contains(e.target) && !tipRef.current?.contains(e.target)) { setHover(false); setTapped(false); } };
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?.contains(e.target)) close(); };
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.2.4",
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",