@helloimjolopez-pathway/pathway-components 0.7.1 → 0.8.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.
@@ -15,18 +15,19 @@
15
15
  * STATIC, v1 to v2. Three v1 values did not match Figma and now do:
16
16
  * Outline & Fill stroke v1 Subtle, Figma Strong (Neutral Bold, accents plain).
17
17
  * Neutral Naked text v1 Neutral/Bold, Figma Neutral/Base.
18
- * Stroke placement Figma draws the stroke inside a frame that grows by the
19
- * stroke width, so content keeps its padding to the
20
- * border; the 1px box-shadow here renders the same edge.
18
+ * Stroke placement Inside the badge, which keeps its size in every style
19
+ * (Small 20, Medium and Large 22). An inset box-shadow
20
+ * draws it without changing the box.
21
21
  * Figma's default leading icon is the placeholder `error`; in code the icon is
22
22
  * passed explicitly, so existing call sites render exactly as before.
23
23
  *
24
24
  * DISMISSIBLE and INTERACTIVE, every size, with or without icons:
25
25
  * outer frame invisible, min width and height Accessibility/Touch Target/AA
26
26
  * (44). The pill keeps its size (20 / 22 / 22) inside it.
27
- * Focus Ring wraps Badge.Pill; FocusRing/Offset from the border, ring outside.
28
- * Badge.Pill fill, stroke (inside its frame), CornerRadius/Large.
29
- * Container.Main Padding/XXXTight left and right, Padding/XXXXTight top and
27
+ * Focus Ring wraps Container.Main, padding FocusRing/Offset, ring outside
28
+ * when Focused.
29
+ * Container.Main the badge: fill, stroke inside, CornerRadius/Large;
30
+ * Padding/XXXTight left and right, Padding/XXXXTight top and
30
31
  * bottom, NO gap.
31
32
  * Container.Label Padding/XXXTight left and right; Field Label to value
32
33
  * Gap/XXTight.
@@ -99,7 +100,7 @@ const ACTION = (s) => {
99
100
  };
100
101
 
101
102
  export const BADGE_SIZE_SPEC = {
102
- Small: { fontSize: "font-size-xxs", lineHeight: "line-height-xxs-single", icon: 11, ico: 12, contentMax: 200 },
103
+ Small: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 11, ico: 12, contentMax: 200, height: 20 },
103
104
  Medium: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 12, ico: 14, contentMax: 196 },
104
105
  Large: { fontSize: "font-size-s", lineHeight: "line-height-xs-single", icon: 14, ico: 16, contentMax: 192 },
105
106
  };
@@ -139,13 +140,15 @@ const cut = { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSp
139
140
  const outer = { display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
140
141
  minWidth: T.touchW, minHeight: T.touchH, boxSizing: "border-box", padding: 0, margin: 0, border: 0,
141
142
  background: "transparent", font: "inherit", color: "inherit", textDecoration: "none", outline: "none" };
142
- const strokeShadow = (stroke) => (stroke ? `0 0 0 ${L.border} ${stroke}` : "none");
143
- /* Focus ring: FocusRing/Width of Stroke/FocusRing/Base, FocusRing/Offset from the pill's
144
- * border, as an outline so the gap shows whatever surface the badge sits on. No white halo.
145
- * The border is a 1px box-shadow outside the box, so the offset adds it when there is one. */
146
- const focusRing = (focused, stroke) => (focused
147
- ? { outline: `${T.ringWidth} solid ${T.ring}`, outlineOffset: stroke ? `calc(${T.ringOffset} + ${L.border})` : T.ringOffset }
143
+ const strokeShadow = (stroke) => (stroke ? `inset 0 0 0 ${L.border} ${stroke}` : "none");
144
+ /* Focus ring: FocusRing/Width of Stroke/FocusRing/Base, FocusRing/Offset outside the pill
145
+ * (its border is inside it), as an outline so the gap shows whatever surface the badge sits
146
+ * on. No white halo. */
147
+ const focusRing = (focused) => (focused
148
+ ? { outline: `${T.ringWidth} solid ${T.ring}`, outlineOffset: T.ringOffset }
148
149
  : { outline: "none" });
150
+ /* Small has a fixed height (20) with its 12 text centred, so it needs no top and bottom padding. */
151
+ const fixedHeight = (spec) => (spec.height ? { height: spec.height, paddingTop: 0, paddingBottom: 0 } : {});
149
152
  const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${T.easing}, box-shadow ${T.duration} ${T.easing}`;
150
153
 
151
154
  /**
@@ -214,9 +217,10 @@ export function Badge({
214
217
  style={{
215
218
  display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: L.gap,
216
219
  paddingTop: L.padTop, paddingRight: L.padRight, paddingBottom: L.padBottom, paddingLeft: L.padLeft,
220
+ ...fixedHeight(spec),
217
221
  borderRadius: L.radius,
218
222
  background: painted.fill ? C(fam.fill) : "transparent",
219
- boxShadow: stroke ? `0 0 0 ${L.border} ${stroke}` : "none",
223
+ boxShadow: stroke ? `inset 0 0 0 ${L.border} ${stroke}` : "none",
220
224
  color: C(badgeStyle === "Naked" && fam.textNaked ? fam.textNaked : fam.text),
221
225
  ...font(spec), ...style,
222
226
  }}
@@ -232,7 +236,7 @@ export function Badge({
232
236
  display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: 0,
233
237
  paddingTop: L.padTop, paddingBottom: L.padBottom, paddingLeft: T.pill, paddingRight: T.pill,
234
238
  borderRadius: L.radius, maxWidth: showFieldLabel ? "none" : T.badgeMax, minWidth: 0,
235
- transition, ...font(spec),
239
+ transition, ...font(spec), ...fixedHeight(spec),
236
240
  };
237
241
  const textSlot = { display: "inline-flex", minWidth: 0, paddingLeft: T.text, paddingRight: T.text };
238
242
  const ico = (name, fill = 0) => <Icon name={name} size={spec.ico} fill={fill} style={{ flexShrink: 0 }} />;
@@ -252,12 +256,12 @@ export function Badge({
252
256
  ...pillBase,
253
257
  background: painted.fill ? C(fam.fill) : "transparent",
254
258
  color: C(badgeStyle === "Naked" && fam.textNaked ? fam.textNaked : fam.text),
255
- boxShadow: strokeShadow(stroke), ...focusRing(focused, stroke),
259
+ boxShadow: strokeShadow(stroke), ...focusRing(focused),
256
260
  }}>
257
261
  {leadingIcon && ico(leadingIcon, 1)}
258
262
  <span className="pw-badge__label" style={{ ...textSlot, gap: T.fieldGap }}>
259
263
  {showFieldLabel && fieldLabel && <span style={{ ...cut, maxWidth: T.fieldMax }}>{fieldLabel}</span>}
260
- <span style={{ ...cut, maxWidth: spec.contentMax, fontWeight: Y("weight-semibold") }}>{label}</span>
264
+ <span style={{ ...cut, maxWidth: spec.contentMax }}>{label}</span>
261
265
  </span>
262
266
  <button type="button" className="pw-badge__remove" aria-label={`Remove ${name}`}
263
267
  aria-disabled={xOff || undefined}
@@ -320,7 +324,7 @@ export function Badge({
320
324
  style={{ ...outer, cursor: disabled ? "not-allowed" : "pointer", ...style }}>
321
325
  <span className="pw-badge__pill" style={{
322
326
  ...pillBase, background: fill, color: fg,
323
- boxShadow: strokeShadow(stroke), ...focusRing(focused, stroke),
327
+ boxShadow: strokeShadow(stroke), ...focusRing(focused),
324
328
  }}>
325
329
  {lead && ico(lead, on ? 0 : 1)}
326
330
  <span className="pw-badge__label" style={textSlot}><span style={{ ...cut, display: "block", maxWidth: spec.contentMax }}>{label}</span></span>
@@ -64,10 +64,8 @@ export const T = {
64
64
  handle: C("fill-static-neutral-strong"),
65
65
  title: C("foreground-static-neutral-strong"),
66
66
  footerBorder: C("stroke-static-neutral-faint"),
67
- /* Figma's effect style `BottomSheet` is a drop shadow of x -6, y 0, blur 36
68
- on Cool Neutral/900 at 16%. It binds a PRIMITIVE colour and has no CSS
69
- token. --elevation-sheet is the nearest token that exists, the same
70
- choice Dropdown made for PopoverMenu's shadow. Kept by Jo, 2026-11-10. */
67
+ /* Elevation/Sheet, the shadow Figma's BottomSheet component now uses. The
68
+ old one-off BottomSheet effect style was retired on 2026-11-11. */
71
69
  shadow: "var(--elevation-sheet)",
72
70
  };
73
71
 
@@ -147,7 +147,7 @@ export const T = {
147
147
  // the canvas but only 1.69:1 against the Primary fill, so a ring drawn on the
148
148
  // fill would fail WCAG 2.4.11. The offset puts it on the surface instead.
149
149
  focusOutline: `${CL("focusring-width")} solid ${SC("stroke-focusring-base")}`,
150
- focusOffset: CL("focusring-offset"),
150
+ focusOffset: "0",
151
151
  };
152
152
 
153
153
  // Type is a scale now, not 111 composites, so each size composes its own five
@@ -19,6 +19,10 @@ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
19
19
  // Note: some tokens in §5.3 of the spec are missing from the token file.
20
20
  // Fallbacks are used where noted and will be updated once Figma is re-synced.
21
21
  const V = {
22
+ // Keyboard focus ring
23
+ focusOutline: "var(--contextual-layout-units-focusring-width) solid var(--semantic-color-stroke-focusring-base)",
24
+ focusOffset: "0",
25
+
22
26
  // Box fill, standard checked / indeterminate
23
27
  fillCheckedBase: "var(--semantic-color-fill-action-primary-strong-rest)",
24
28
  fillCheckedHover: "var(--semantic-color-fill-action-primary-strong-hover)",
@@ -198,6 +202,7 @@ export function Checkbox({
198
202
  const inputRef = useRef(null);
199
203
  const [hovered, setHovered] = useState(false);
200
204
  const [focused, setFocused] = useState(false);
205
+ const [focusVisible, setFocusVisible] = useState(false);
201
206
  const [pressed, setPressed] = useState(false);
202
207
 
203
208
  // The indeterminate state can only be set on the DOM element: not via HTML.
@@ -331,8 +336,8 @@ export function Checkbox({
331
336
  disabled={disabled}
332
337
  aria-describedby={describedBy}
333
338
  onChange={e => onChange && onChange(e.target.checked)}
334
- onFocus={() => setFocused(true)}
335
- onBlur={() => setFocused(false)}
339
+ onFocus={e => { setFocused(true); setFocusVisible(e.target.matches(":focus-visible")); }}
340
+ onBlur={() => { setFocused(false); setFocusVisible(false); }}
336
341
  style={{
337
342
  position: "absolute",
338
343
  width: boxSize,
@@ -355,9 +360,14 @@ export function Checkbox({
355
360
  width: boxSize,
356
361
  height: boxSize,
357
362
  borderRadius: V.radius,
358
- border: `${BORDER_WIDTH}px solid ${borderColor}`,
363
+ // In Figma the Focused ring takes the border's place.
364
+ border: `${BORDER_WIDTH}px solid ${focusVisible && !disabled ? "transparent" : borderColor}`,
359
365
  backgroundColor: boxFill,
360
366
  boxSizing: "border-box",
367
+ // Keyboard focus ring, the same stroke ring every Pathway control uses:
368
+ // FocusRing/Width of Stroke/FocusRing/Base, FocusRing/Offset outside the box.
369
+ outline: focusVisible && !disabled ? V.focusOutline : "none",
370
+ outlineOffset: V.focusOffset,
361
371
  transition: noMotion ? "none" : "background-color var(--motion-duration-2) var(--motion-easing-decelerate), border-color var(--motion-duration-2) var(--motion-easing-decelerate)",
362
372
  flexShrink: 0,
363
373
  }}
@@ -46,6 +46,8 @@
46
46
  import React, { useState } from "react";
47
47
  import { Icon } from "./icon.jsx";
48
48
  import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
49
+ import { FOCUS_RING_LAYER, injectFocusRing } from "../_shared/focus-ring.js";
50
+ injectFocusRing();
49
51
 
50
52
  const C = (n) => `var(--semantic-color-${n})`;
51
53
  const U = (n) => `var(--semantic-layout-units-${n})`;
@@ -130,7 +132,7 @@ export function ActionIcon({
130
132
  onMouseDown={() => setPress(true)}
131
133
  onMouseUp={() => setPress(false)}
132
134
  onBlur={() => setPress(false)}
133
- className={`pw-action-icon ${className}`}
135
+ className={`pw-action-icon ${FOCUS_RING_LAYER} ${className}`}
134
136
  data-size={size}
135
137
  style={{
136
138
  display: "inline-flex", alignItems: "center", justifyContent: "center",
@@ -147,7 +149,7 @@ export function ActionIcon({
147
149
  The fill belongs on this box, not on the button: painting the target
148
150
  gives a hover area visibly larger than the one in the design, which
149
151
  is the bug the Widget header shipped with once. */}
150
- <span style={{
152
+ <span data-pw-ring-target="" style={{
151
153
  display: "inline-flex", alignItems: "center", justifyContent: "center",
152
154
  width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
153
155
  background: press ? st.layerPress : hov ? st.layerHover : "transparent",
@@ -384,4 +384,31 @@ export function ModuleIcon({
384
384
  );
385
385
  }
386
386
 
387
+ /**
388
+ * Module.Mark (Figma): the module's mark in a 22 tinted box beside its name.
389
+ * Used in the SideNav header of a module shell (SideNav.Local.Accounting,
390
+ * read 2026-11-12).
391
+ * box 22, CornerRadius/Small, Fill/Static/Brand/Faint
392
+ * glyph ModuleIcon at 16, mono, Fill/Static/Brand/Strongest
393
+ * label Text/Body/Small/Semibold: 14 / 600 / 20 / 0.3 (the repo's `spacious`),
394
+ * Fill/Static/Brand/Strongest, gap Gap/XXTight
395
+ */
396
+ export function ModuleMark({ module, label }) {
397
+ const ink = "var(--semantic-color-fill-static-brand-strongest)";
398
+ return (
399
+ <span style={{ display: "inline-flex", alignItems: "center", minWidth: 0,
400
+ gap: "var(--semantic-layout-units-gap-xxtight)", paddingRight: "var(--semantic-layout-units-padding-xxxtight)" }}>
401
+ <span aria-hidden="true" style={{ width: 22, height: 22, flexShrink: 0, display: "inline-flex",
402
+ alignItems: "center", justifyContent: "center",
403
+ borderRadius: "var(--semantic-layout-units-cornerradius-small)",
404
+ background: "var(--semantic-color-fill-static-brand-faint)", color: ink }}>
405
+ <ModuleIcon module={module} size={16} color="mono" />
406
+ </span>
407
+ <span style={{ fontFamily: "var(--semantic-type-family-brand)", fontWeight: "var(--semantic-type-weight-semibold)",
408
+ fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)",
409
+ letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{label}</span>
410
+ </span>
411
+ );
412
+ }
413
+
387
414
  export default ModuleIcon;
@@ -13,6 +13,7 @@
13
13
  import React, { useState, useEffect, useCallback } from "react";
14
14
  import TopNav, { DEFAULT_MODULES } from "../top-nav/top-nav.jsx";
15
15
  import { SideNav } from "../sidenav/sidenav.jsx";
16
+ import { ModuleMark } from "../module-icon/module-icon.jsx";
16
17
 
17
18
  // ─── CONSTANTS ────────────────────────────────────────────────────────────────
18
19
  const TOPNAV_H = 60; // px — spec §9.3 rule 1
@@ -75,33 +76,9 @@ function useBreakpoint() {
75
76
  * defaultCollapsed boolean — initial desktop collapse state (default false)
76
77
  * children ReactNode — page content (renders inside Shell.Main)
77
78
  */
78
- // The Accounting module's apps, as Figma's Accounting.AppSwitcher lists them,
79
- // each with its own side nav. The web component falls back to this when
80
- // `app-switcher` is on and no `appSwitcher` property has been set.
81
- //
82
- // Financials Home's nav is Figma's SideNav.Local.Accounting (40016737:141666),
83
- // icons included. Figma draws no other app's nav, so Accounts Payable,
84
- // Accounts Receivable and Bank Reconciliation use the item lists the widget
85
- // demo read from production (Bank Reconciliation takes Bank Account
86
- // Management's), on the same Figma icons.
87
- const accountingNav = (app, labels) => labels.map((l) => ({ id: `${app}-${l.toLowerCase().replace(/\s+/g, "-")}`, label: l, icon: ACCOUNTING_ICONS[l] }));
88
- const ACCOUNTING_ICONS = {
89
- Dashboard: "space_dashboard", Enter: "input", Manage: "bookmark_manager", View: "visibility",
90
- Reports: "insert_chart", Modify: "edit_square", Help: "help", Utilities: "tools_wrench",
91
- "Security Setup": "security",
92
- };
93
- const APP_NAV_STANDARD = ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Utilities", "Help"];
94
- export const ACCOUNTING_APP_SWITCHER = {
95
- label: "Financials Home",
96
- icon: "apps",
97
- home: { id: "financials-home", label: "Financials Home",
98
- navItems: accountingNav("fh", ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Help", "Utilities", "Security Setup"]) },
99
- apps: [
100
- { id: "accounts-payable", label: "Accounts Payable", navItems: accountingNav("ap", APP_NAV_STANDARD) },
101
- { id: "accounts-receivable", label: "Accounts Receivable", navItems: accountingNav("ar", APP_NAV_STANDARD) },
102
- { id: "bank-reconciliation", label: "Bank Reconciliation", navItems: accountingNav("br", APP_NAV_STANDARD) },
103
- ],
104
- };
79
+ // The Accounting module's apps and their navs live with the Accounting side
80
+ // nav, which also ships on its own as <pathway-accounting-sidenav>.
81
+ export { ACCOUNTING_APP_SWITCHER } from "../sidenav/accounting-side-nav.jsx";
105
82
 
106
83
  export function NavShell({
107
84
  // TopNav
@@ -112,6 +89,10 @@ export function NavShell({
112
89
  user,
113
90
  onModuleSelect,
114
91
  onOrgSelect,
92
+ // TopNav Slot.RowEnd. Undefined follows the module: an appSwitcher may carry
93
+ // `topNav: { showSearch, showActions }` (Accounting turns both off).
94
+ showSearch,
95
+ showActions,
115
96
 
116
97
  // SideNav
117
98
  navItems,
@@ -256,6 +237,8 @@ export function NavShell({
256
237
  onModuleSelect={onModuleSelect}
257
238
  onOrgSelect={onOrgSelect}
258
239
  onSideNavToggle={handleSideNavToggle}
240
+ showSearch={showSearch ?? (appSwitcher && appSwitcher.topNav && appSwitcher.topNav.showSearch) ?? true}
241
+ showActions={showActions ?? (appSwitcher && appSwitcher.topNav && appSwitcher.topNav.showActions) ?? true}
259
242
  />
260
243
  </div>
261
244
 
@@ -309,7 +292,10 @@ export function NavShell({
309
292
  hideCollapseButton={isTablet || isMobile}
310
293
  headerEnd={navHeaderEnd}
311
294
  footer={navFooter}
312
- appSwitcher={appSwitcher ? { ...appSwitcher, activeId: appActive, onSelect: handleAppSelect } : null}
295
+ headerStart={appSwitcher && appSwitcher.moduleMark ? <ModuleMark {...appSwitcher.moduleMark} /> : null}
296
+ appSwitcher={appSwitcher ? { ...appSwitcher, activeId: appActive, onSelect: handleAppSelect,
297
+ // Mobile opens the menu as a BottomSheet (Figma 40016737:141824).
298
+ presentation: isMobile ? "sheet" : "popover" } : null}
313
299
  >
314
300
  {navChildren}
315
301
  </SideNav>
@@ -310,8 +310,8 @@ export function OrgSwitcher({
310
310
  onFocus={e => {
311
311
  // Only show focus ring on keyboard navigation, not on mouse click
312
312
  if (e.target.matches?.(':focus-visible')) {
313
- e.currentTarget.style.outline = `2px solid ${T.iconHover}`;
314
- e.currentTarget.style.outlineOffset = "2px";
313
+ e.currentTarget.style.outline = "var(--contextual-layout-units-focusring-width) solid var(--semantic-color-stroke-focusring-base)";
314
+ e.currentTarget.style.outlineOffset = "0";
315
315
  }
316
316
  }}
317
317
  onBlur={e => (e.currentTarget.style.outline = "none")}
@@ -171,8 +171,8 @@ const CSS = `
171
171
  .pw-radio[data-disabled] .pw-radio__dot{background:${T.dotDisabled}}
172
172
  .pw-radio[data-disabled] .pw-radio__label{color:${T.labelDisabled}}
173
173
 
174
- .pw-radio:is([data-focus-visible],[data-force=Focused]) .pw-radio__mark{background:${T.focusFill};
175
- outline:${CL("focusring-width")} solid ${T.focusRing};outline-offset:${CL("focusring-offset")}}
174
+ .pw-radio:is([data-focus-visible],[data-force=Focused]) .pw-radio__mark{background:${T.focusFill};border-color:transparent;
175
+ outline:${CL("focusring-width")} solid ${T.focusRing};outline-offset:0}
176
176
 
177
177
  .pw-radio-mark{display:inline-grid;place-items:center;width:${L.target};height:${L.targetH};flex:none}
178
178
 
@@ -80,7 +80,7 @@ export const T = {
80
80
  messageRadius: SL("cornerradius-small"),
81
81
  sheetRadius: RL("sheet-corner-radius-cornerradius"),
82
82
  focusWidth: CL("focusring-width"),
83
- focusOffset: CL("focusring-offset"),
83
+ focusOffset: "0",
84
84
  // Spacing
85
85
  sectionGap: SL("gap-medium"),
86
86
  progressGap: SL("gap-xtight"),
@@ -28,6 +28,9 @@
28
28
 
29
29
  import React, { useState, useEffect, useRef } from "react";
30
30
  import { Icon } from "../icon/icon.jsx";
31
+ import { FOCUS_RING, injectFocusRing } from "../_shared/focus-ring.js";
32
+
33
+ injectFocusRing();
31
34
 
32
35
  // ─── DESIGN TOKENS ─────────────────────────────────────────────────────────────
33
36
  // Every value is a semantic CSS variable. Fallbacks are the resolved values from
@@ -575,6 +578,7 @@ export function TopNavSearch({
575
578
  (Figma node 40006967:20917, Container.Icon h32 w32 rounded-full). */}
576
579
  <button
577
580
  ref={collapsedBtnRef}
581
+ className={FOCUS_RING}
578
582
  type="button"
579
583
  aria-label="Open search"
580
584
  aria-expanded={expanded}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * AccountingSideNav: the Accounting module's side nav as one component.
3
+ *
4
+ * Figma: SideNav.Local.Accounting (40016737:141666) with Accounting.AppSwitcher
5
+ * (40018575:65691) as the first entry of its menu, and the Financials
6
+ * Module.Mark in the header.
7
+ *
8
+ * It is the same SideNav that NavShell renders for Accounting, packaged so a
9
+ * host that owns its own top bar and page frame can use the Accounting nav on
10
+ * its own (as <pathway-accounting-sidenav>, see pathway-accounting-sidenav.js).
11
+ * NavShell keeps composing TopNav + SideNav itself; this does not replace it.
12
+ *
13
+ * Behaviour, as in NavShell:
14
+ * - the switcher names the current application;
15
+ * - choosing one swaps the nav to that application's items and lands on its
16
+ * first item (its Dashboard);
17
+ * - below 768 the switcher opens the Financial Applications BottomSheet,
18
+ * from 768 up the popover beside the nav.
19
+ *
20
+ * Props:
21
+ * activeAppId / defaultAppId controlled / initial application id
22
+ * onAppSelect(id) an application was chosen
23
+ * activeId / onNavigate(id) the active nav item, as on SideNav
24
+ * presentation "auto" (default), "popover" or "sheet"
25
+ * appSwitcher override the module data (same shape as
26
+ * ACCOUNTING_APP_SWITCHER)
27
+ * ...rest passed to SideNav (collapsed, onCollapseChange,
28
+ * hideCollapseButton, footer, headerEnd, ...)
29
+ */
30
+ import React, { useEffect, useState } from "react";
31
+ import { SideNav } from "./sidenav.jsx";
32
+ import { ModuleMark } from "../module-icon/module-icon.jsx";
33
+
34
+ // The Accounting module's applications, each with its own side nav. NavShell's
35
+ // `app-switcher` and <pathway-accounting-sidenav> fall back to this when no
36
+ // switcher data has been set.
37
+ //
38
+ // Financials Home's nav is Figma's SideNav.Local.Accounting (40016737:141666),
39
+ // icons included, with Dashboard first. Figma draws no other application's
40
+ // nav, so the others use the item lists read from production, on the same
41
+ // Figma icons.
42
+ const accountingNav = (app, labels) => labels.map((l) => ({ id: `${app}-${l.toLowerCase().replace(/\s+/g, "-")}`, label: l, icon: ACCOUNTING_ICONS[l] }));
43
+ const ACCOUNTING_ICONS = {
44
+ Dashboard: "space_dashboard", Enter: "input", Manage: "bookmark_manager", View: "visibility",
45
+ Reports: "insert_chart", Modify: "edit_square", Help: "help", Utilities: "tools_wrench",
46
+ "Security Setup": "security",
47
+ };
48
+ const APP_NAV_STANDARD = ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Utilities", "Help"];
49
+ export const ACCOUNTING_APP_SWITCHER = {
50
+ label: "Financials Home",
51
+ icon: "apps",
52
+ // The module's mark in the SideNav header (Figma Module.Mark, Accounting).
53
+ moduleMark: { module: "accounting", label: "Financials" },
54
+ // The Accounting TopNav is TopNav.Global with Search and Actions hidden
55
+ // (Figma 40007879:18072): module switcher, org switcher and profile only.
56
+ topNav: { showSearch: false, showActions: false },
57
+ // Title of the mobile BottomSheet (Figma "Financial Applications").
58
+ sheetTitle: "Financial Applications",
59
+ home: { id: "financials-home", label: "Financials Home",
60
+ navItems: accountingNav("fh", ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Help", "Utilities", "Security Setup"]) },
61
+ apps: [
62
+ { id: "accounts-payable", label: "Accounts Payable", navItems: accountingNav("ap", APP_NAV_STANDARD) },
63
+ { id: "accounts-receivable", label: "Accounts Receivable", navItems: accountingNav("ar", APP_NAV_STANDARD) },
64
+ { id: "general-ledger", label: "General Ledger", navItems: accountingNav("gl", APP_NAV_STANDARD) },
65
+ { id: "fixed-assets", label: "Fixed Assets", navItems: accountingNav("fa", ["Dashboard", "Manage", "Reports", "Modify", "Help"]) },
66
+ { id: "purchasing-management", label: "Purchasing Management", navItems: accountingNav("pm", ["Dashboard", "Enter", "Manage", "Reports", "Modify", "Help"]) },
67
+ ],
68
+ };
69
+ const MOBILE_MAX = 767; // below 768, as NavShell's breakpoint
70
+
71
+ function useIsMobile() {
72
+ const get = () => typeof window !== "undefined" && window.innerWidth <= MOBILE_MAX;
73
+ const [m, setM] = useState(get);
74
+ useEffect(() => {
75
+ const on = () => setM(get());
76
+ window.addEventListener("resize", on);
77
+ return () => window.removeEventListener("resize", on);
78
+ }, []);
79
+ return m;
80
+ }
81
+
82
+ export function AccountingSideNav({
83
+ appSwitcher = ACCOUNTING_APP_SWITCHER,
84
+ activeAppId, defaultAppId, onAppSelect,
85
+ activeId, onNavigate,
86
+ presentation = "auto",
87
+ ...rest
88
+ }) {
89
+ const apps = [appSwitcher.home, ...(appSwitcher.apps || [])].filter(Boolean);
90
+ const [ownApp, setOwnApp] = useState(defaultAppId ?? (appSwitcher.home && appSwitcher.home.id));
91
+ const appId = activeAppId ?? ownApp;
92
+ const current = apps.find((a) => a.id === appId) || apps[0];
93
+ const items = (current && current.navItems) || [];
94
+ const [ownActive, setOwnActive] = useState(items[0] && items[0].id);
95
+ const active = activeId ?? ownActive;
96
+ const isMobile = useIsMobile();
97
+ const mode = presentation === "auto" ? (isMobile ? "sheet" : "popover") : presentation;
98
+
99
+ const navigate = (id) => { setOwnActive(id); onNavigate && onNavigate(id); };
100
+ const selectApp = (id) => {
101
+ setOwnApp(id);
102
+ onAppSelect && onAppSelect(id);
103
+ const next = apps.find((a) => a.id === id);
104
+ const first = next && next.navItems && next.navItems[0];
105
+ if (first) navigate(first.id);
106
+ };
107
+
108
+ return (
109
+ <SideNav key={current && current.id} items={items} activeId={active} onNavigate={navigate}
110
+ headerStart={appSwitcher.moduleMark ? <ModuleMark {...appSwitcher.moduleMark} /> : null}
111
+ appSwitcher={{ ...appSwitcher, activeId: current && current.id, onSelect: selectApp, presentation: mode }}
112
+ {...rest} />
113
+ );
114
+ }
115
+
116
+ export default AccountingSideNav;