@helloimjolopez-pathway/pathway-components 0.7.0 → 0.7.2

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,17 +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 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.
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
- * Badge.Pill fill, stroke (outside), CornerRadius/Large, focus ring.
28
- * 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
29
31
  * bottom, NO gap.
30
32
  * Container.Label Padding/XXXTight left and right; Field Label to value
31
33
  * Gap/XXTight.
@@ -98,7 +100,7 @@ const ACTION = (s) => {
98
100
  };
99
101
 
100
102
  export const BADGE_SIZE_SPEC = {
101
- 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 },
102
104
  Medium: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 12, ico: 14, contentMax: 196 },
103
105
  Large: { fontSize: "font-size-s", lineHeight: "line-height-xs-single", icon: 14, ico: 16, contentMax: 192 },
104
106
  };
@@ -138,12 +140,15 @@ const cut = { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSp
138
140
  const outer = { display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
139
141
  minWidth: T.touchW, minHeight: T.touchH, boxSizing: "border-box", padding: 0, margin: 0, border: 0,
140
142
  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. */
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. */
144
147
  const focusRing = (focused) => (focused
145
148
  ? { outline: `${T.ringWidth} solid ${T.ring}`, outlineOffset: T.ringOffset }
146
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 } : {});
147
152
  const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${T.easing}, box-shadow ${T.duration} ${T.easing}`;
148
153
 
149
154
  /**
@@ -212,9 +217,10 @@ export function Badge({
212
217
  style={{
213
218
  display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: L.gap,
214
219
  paddingTop: L.padTop, paddingRight: L.padRight, paddingBottom: L.padBottom, paddingLeft: L.padLeft,
220
+ ...fixedHeight(spec),
215
221
  borderRadius: L.radius,
216
222
  background: painted.fill ? C(fam.fill) : "transparent",
217
- boxShadow: stroke ? `0 0 0 ${L.border} ${stroke}` : "none",
223
+ boxShadow: stroke ? `inset 0 0 0 ${L.border} ${stroke}` : "none",
218
224
  color: C(badgeStyle === "Naked" && fam.textNaked ? fam.textNaked : fam.text),
219
225
  ...font(spec), ...style,
220
226
  }}
@@ -230,7 +236,7 @@ export function Badge({
230
236
  display: "inline-flex", alignItems: "center", boxSizing: "border-box", gap: 0,
231
237
  paddingTop: L.padTop, paddingBottom: L.padBottom, paddingLeft: T.pill, paddingRight: T.pill,
232
238
  borderRadius: L.radius, maxWidth: showFieldLabel ? "none" : T.badgeMax, minWidth: 0,
233
- transition, ...font(spec),
239
+ transition, ...font(spec), ...fixedHeight(spec),
234
240
  };
235
241
  const textSlot = { display: "inline-flex", minWidth: 0, paddingLeft: T.text, paddingRight: T.text };
236
242
  const ico = (name, fill = 0) => <Icon name={name} size={spec.ico} fill={fill} style={{ flexShrink: 0 }} />;
@@ -255,7 +261,7 @@ export function Badge({
255
261
  {leadingIcon && ico(leadingIcon, 1)}
256
262
  <span className="pw-badge__label" style={{ ...textSlot, gap: T.fieldGap }}>
257
263
  {showFieldLabel && fieldLabel && <span style={{ ...cut, maxWidth: T.fieldMax }}>{fieldLabel}</span>}
258
- <span style={{ ...cut, maxWidth: spec.contentMax, fontWeight: Y("weight-semibold") }}>{label}</span>
264
+ <span style={{ ...cut, maxWidth: spec.contentMax }}>{label}</span>
259
265
  </span>
260
266
  <button type="button" className="pw-badge__remove" aria-label={`Remove ${name}`}
261
267
  aria-disabled={xOff || undefined}
@@ -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,29 @@ export function ModuleIcon({
384
384
  );
385
385
  }
386
386
 
387
+ /**
388
+ * Module.Mark, Size=Large, Style=Emphasis (Figma): the module's mark in a 28
389
+ * tinted box beside its name. Used in the SideNav header of a module shell.
390
+ * box 28, CornerRadius/Small, Fill/Static/Brand/Faint
391
+ * glyph ModuleIcon at 20, mono, Fill/Static/Brand/Strongest
392
+ * label 16 / 600, Fill/Static/Brand/Strongest, gap Gap/XXTight
393
+ */
394
+ export function ModuleMark({ module, label }) {
395
+ const ink = "var(--semantic-color-fill-static-brand-strongest)";
396
+ return (
397
+ <span style={{ display: "inline-flex", alignItems: "center", minWidth: 0,
398
+ gap: "var(--semantic-layout-units-gap-xxtight)", paddingRight: "var(--semantic-layout-units-padding-xxxtight)" }}>
399
+ <span aria-hidden="true" style={{ width: 28, height: 28, flexShrink: 0, display: "inline-flex",
400
+ alignItems: "center", justifyContent: "center",
401
+ borderRadius: "var(--semantic-layout-units-cornerradius-small)",
402
+ background: "var(--semantic-color-fill-static-brand-faint)", color: ink }}>
403
+ <ModuleIcon module={module} size={20} color="mono" />
404
+ </span>
405
+ <span style={{ fontFamily: "var(--semantic-type-family-brand)", fontWeight: "var(--semantic-type-weight-semibold)",
406
+ fontSize: "var(--semantic-type-font-size-r)", lineHeight: "var(--semantic-type-line-height-r-single)",
407
+ color: ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{label}</span>
408
+ </span>
409
+ );
410
+ }
411
+
387
412
  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
@@ -309,7 +286,10 @@ export function NavShell({
309
286
  hideCollapseButton={isTablet || isMobile}
310
287
  headerEnd={navHeaderEnd}
311
288
  footer={navFooter}
312
- appSwitcher={appSwitcher ? { ...appSwitcher, activeId: appActive, onSelect: handleAppSelect } : null}
289
+ headerStart={appSwitcher && appSwitcher.moduleMark ? <ModuleMark {...appSwitcher.moduleMark} /> : null}
290
+ appSwitcher={appSwitcher ? { ...appSwitcher, activeId: appActive, onSelect: handleAppSelect,
291
+ // Mobile opens the menu as a BottomSheet (Figma 40016737:141824).
292
+ presentation: isMobile ? "sheet" : "popover" } : null}
313
293
  >
314
294
  {navChildren}
315
295
  </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,113 @@
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
+ // Title of the mobile BottomSheet (Figma "Financial Applications").
55
+ sheetTitle: "Financial Applications",
56
+ home: { id: "financials-home", label: "Financials Home",
57
+ navItems: accountingNav("fh", ["Dashboard", "Enter", "Manage", "View", "Reports", "Modify", "Help", "Utilities", "Security Setup"]) },
58
+ apps: [
59
+ { id: "accounts-payable", label: "Accounts Payable", navItems: accountingNav("ap", APP_NAV_STANDARD) },
60
+ { id: "accounts-receivable", label: "Accounts Receivable", navItems: accountingNav("ar", APP_NAV_STANDARD) },
61
+ { id: "general-ledger", label: "General Ledger", navItems: accountingNav("gl", APP_NAV_STANDARD) },
62
+ { id: "fixed-assets", label: "Fixed Assets", navItems: accountingNav("fa", ["Dashboard", "Manage", "Reports", "Modify", "Help"]) },
63
+ { id: "purchasing-management", label: "Purchasing Management", navItems: accountingNav("pm", ["Dashboard", "Enter", "Manage", "Reports", "Modify", "Help"]) },
64
+ ],
65
+ };
66
+ const MOBILE_MAX = 767; // below 768, as NavShell's breakpoint
67
+
68
+ function useIsMobile() {
69
+ const get = () => typeof window !== "undefined" && window.innerWidth <= MOBILE_MAX;
70
+ const [m, setM] = useState(get);
71
+ useEffect(() => {
72
+ const on = () => setM(get());
73
+ window.addEventListener("resize", on);
74
+ return () => window.removeEventListener("resize", on);
75
+ }, []);
76
+ return m;
77
+ }
78
+
79
+ export function AccountingSideNav({
80
+ appSwitcher = ACCOUNTING_APP_SWITCHER,
81
+ activeAppId, defaultAppId, onAppSelect,
82
+ activeId, onNavigate,
83
+ presentation = "auto",
84
+ ...rest
85
+ }) {
86
+ const apps = [appSwitcher.home, ...(appSwitcher.apps || [])].filter(Boolean);
87
+ const [ownApp, setOwnApp] = useState(defaultAppId ?? (appSwitcher.home && appSwitcher.home.id));
88
+ const appId = activeAppId ?? ownApp;
89
+ const current = apps.find((a) => a.id === appId) || apps[0];
90
+ const items = (current && current.navItems) || [];
91
+ const [ownActive, setOwnActive] = useState(items[0] && items[0].id);
92
+ const active = activeId ?? ownActive;
93
+ const isMobile = useIsMobile();
94
+ const mode = presentation === "auto" ? (isMobile ? "sheet" : "popover") : presentation;
95
+
96
+ const navigate = (id) => { setOwnActive(id); onNavigate && onNavigate(id); };
97
+ const selectApp = (id) => {
98
+ setOwnApp(id);
99
+ onAppSelect && onAppSelect(id);
100
+ const next = apps.find((a) => a.id === id);
101
+ const first = next && next.navItems && next.navItems[0];
102
+ if (first) navigate(first.id);
103
+ };
104
+
105
+ return (
106
+ <SideNav key={current && current.id} items={items} activeId={active} onNavigate={navigate}
107
+ headerStart={appSwitcher.moduleMark ? <ModuleMark {...appSwitcher.moduleMark} /> : null}
108
+ appSwitcher={{ ...appSwitcher, activeId: current && current.id, onSelect: selectApp, presentation: mode }}
109
+ {...rest} />
110
+ );
111
+ }
112
+
113
+ export default AccountingSideNav;