@helloimjolopez-pathway/pathway-components 0.2.3 → 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>
@@ -513,6 +513,7 @@ function OrgRow({ org, active, onSelect }) {
513
513
  onMouseLeave={() => setHov(false)}
514
514
  aria-current={active ? "true" : undefined}
515
515
  style={{
516
+ position: "relative",
516
517
  display: "flex", alignItems: "center", gap: 12, width: "100%", textAlign: "left",
517
518
  padding: "8px 8px", border: "none", cursor: "pointer", borderRadius: 8,
518
519
  fontFamily: "inherit",
@@ -520,6 +521,17 @@ function OrgRow({ org, active, onSelect }) {
520
521
  transition: noMotion ? "none" : "background var(--motion-duration-2) var(--motion-easing-standard)",
521
522
  }}
522
523
  >
524
+ {/* indicator.stripe on the selected row, as OrgSwitcher.Row State=Selected
525
+ draws it (40018011:16090): 4 wide, 44 tall, right corners rounded 8,
526
+ Fill/Action/Selection/Indicator. The same stripe as the side nav. */}
527
+ {active && (
528
+ <span aria-hidden="true" style={{
529
+ position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)",
530
+ width: 4, height: 44,
531
+ borderRadius: "0 var(--semantic-layout-units-cornerradius-base) var(--semantic-layout-units-cornerradius-base) 0",
532
+ background: "var(--semantic-color-fill-action-selection-indicator)",
533
+ }} />
534
+ )}
523
535
  {/* Logo tile, org initials on brand fill (falls back from a logo image) */}
524
536
  <div style={{
525
537
  width: 44, height: 44, borderRadius: 6, flexShrink: 0, background: PANEL_T.logoBg,
@@ -538,7 +550,8 @@ function OrgRow({ org, active, onSelect }) {
538
550
  <ModuleCluster />
539
551
  </div>
540
552
  {/* Trailing chevron */}
541
- <Icon name="chevron_right" size={20} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
553
+ {/* 12, as OrgSwitcher.Row's trailing chevron in Figma. Was 20. */}
554
+ <Icon name="chevron_right" size={12} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
542
555
  </button>
543
556
  );
544
557
  }
@@ -577,7 +590,8 @@ export function OrgSwitcherPanel({
577
590
  display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "var(--semantic-layout-units-padding-xtight)", borderRadius: "var(--semantic-layout-units-cornerradius-base)",
578
591
  border: `1px solid ${PANEL_T.border}`,
579
592
  }}>
580
- <Icon name="search" size={20} style={{ color: PANEL_T.icon }} />
593
+ {/* 18, as OrgSwitcher.Panel's Field.Search icon in Figma. Was 20. */}
594
+ <Icon name="search" size={18} style={{ color: PANEL_T.icon }} />
581
595
  <input
582
596
  type="text"
583
597
  value={query}
@@ -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: u("padding-xxxxtight"),
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
- <div style={{ width: `calc(${L.chevFrame}px + 2 * ${L.chevPad})`, height: L.iconWrap, display: "flex",
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)",
@@ -501,7 +510,10 @@ export function PopoverRow({ item, onClick, activeId }) {
501
510
  <div style={{ position: "absolute", left: 0, top: "var(--semantic-layout-units-padding-xtight)",
502
511
  bottom: "var(--semantic-layout-units-padding-xtight)", width: L.stripeW,
503
512
  borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
504
- backgroundColor: isActive ? T.indicator : "transparent",
513
+ // PopoverMenu.Item's Stripe binds Fill/Action/Primary/Strong/Pressed in
514
+ // Figma (40018914:3149), not the nav's Selection/Indicator. Same value
515
+ // in Light; in Midnight they differ, so this follows Figma.
516
+ backgroundColor: isActive ? c("fill-action-primary-strong-pressed") : "transparent",
505
517
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
506
518
  {/* Label/Menu/Base/Medium: 14 / 500 / 20 / 0.3 (the repo's `spacious`). */}
507
519
  <span style={{ fontFamily: "'Red Hat Text',sans-serif",
@@ -513,6 +525,173 @@ export function PopoverRow({ item, onClick, activeId }) {
513
525
  );
514
526
  }
515
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
+
516
695
  // ─── CollapseButton ───────────────────────────────────────────────────────────
517
696
  // Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
518
697
  // + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
@@ -744,6 +923,10 @@ export function SideNav({
744
923
  headerEnd = null,
745
924
  children = null,
746
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,
747
930
  }) {
748
931
  const [expanded, setExpanded] = useState(defaultExpanded);
749
932
  const [popoverId, setPopoverId] = useState(null);
@@ -892,6 +1075,8 @@ export function SideNav({
892
1075
  paddingRight: collapsed ? L.navColPadH : L.navPadH }}
893
1076
  >
894
1077
 
1078
+ {appSwitcher && <AppSwitcher {...appSwitcher} collapsed={collapsed} />}
1079
+
895
1080
  {/* Sectioned render: when `sections` prop is provided */}
896
1081
  {sections && sections.map(({ section, items: secItems }, sIdx) => (
897
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
- 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); };
@@ -351,19 +351,32 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
351
351
  *
352
352
  * THE STATE FILL BELONGS ON THE 28 BOX, not on the touch target.
353
353
  */
354
+ // TopNav.Actions in Figma (40007082:7313): help, settings, notifications_unread,
355
+ // each a 16 glyph in a 28 layer in a 44 target. This drew "notifications" twice.
354
356
  export const DEFAULT_ACTIONS = [
355
- { icon: "notifications", label: "Notifications" },
356
- { icon: "notifications", label: "Alerts" },
357
+ { icon: "help", label: "Help" },
358
+ { icon: "settings", label: "Settings" },
359
+ { icon: "notifications_unread", label: "Notifications" },
357
360
  ];
358
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
+
359
369
  function TopNavActionIcon({ icon, label, onClick }) {
360
370
  const [hov, setHov] = useState(false);
361
371
  const [press, setPress] = useState(false);
362
372
  const fill = press ? T.controlPressed : hov ? T.controlHover : "transparent";
363
373
  const fg = press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
364
374
  return (
375
+ // The 44 target is the button itself. It used to be a min-size wrapper
376
+ // with a height:100% button, which a min-height parent does not resolve,
377
+ // so the clickable box collapsed to the 28 layer.
365
378
  <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
366
- minHeight: L.actionTarget, minWidth: L.actionTarget }}>
379
+ width: L.actionTarget, height: L.actionTarget, flexShrink: 0 }}>
367
380
  <button type="button" aria-label={label} onClick={onClick}
368
381
  onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
369
382
  onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
@@ -382,7 +395,8 @@ function TopNavActionIcon({ icon, label, onClick }) {
382
395
  }
383
396
 
384
397
  export function TopNavActions({ breakpoint = "desktop", actions, onNotifications, onMore }) {
385
- const items = (actions || DEFAULT_ACTIONS).map((a) => ({ ...a, onClick: a.onClick || onNotifications }));
398
+ const items = (actions || DEFAULT_ACTIONS).map((a) => ({
399
+ ...a, onClick: a.onClick || (a.icon === "notifications_unread" ? onNotifications : undefined) }));
386
400
  if (breakpoint === "mobile" && items.length > 1) {
387
401
  return (
388
402
  <div style={{ display: "flex", alignItems: "center" }}>
@@ -1054,7 +1068,7 @@ export function TopNav({
1054
1068
  const close = () => setOpenPanel(null);
1055
1069
 
1056
1070
  useEffect(() => {
1057
- const h = (e) => { if (!navRef.current?.contains(e.target)) close(); };
1071
+ const h = (e) => { if (!eventInside(navRef.current, e)) close(); };
1058
1072
  document.addEventListener("mousedown", h);
1059
1073
  return () => document.removeEventListener("mousedown", h);
1060
1074
  }, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.2.3",
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",