@orianatech/pire 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -10,7 +10,8 @@ Requires Node 20+ and pnpm.
10
10
  ```bash
11
11
  pnpm install
12
12
  pnpm storybook # docs + playground at http://localhost:6006
13
- pnpm build # dist/ (ESM + CJS + .d.ts + CSS)
13
+ pnpm build # dist/ (ESM + CJS + .d.ts + CSS), icon codegen first
14
+ pnpm icons:generate # re-inline the Carbon glyphs listed in icon-map.ts
14
15
  pnpm typecheck # tsc --noEmit
15
16
  pnpm lint # biome
16
17
  ```
@@ -76,14 +77,45 @@ Override tokens, never component rules:
76
77
 
77
78
  ### Icons
78
79
 
79
- Names are Lucide slugs; the shipped pack is IBM Carbon, resolved through an internal map. Glyphs
80
- load from a CDN by default self-host in production:
80
+ **Zero configuration, and no network.** Every glyph is inlined into the bundle at build time and
81
+ rendered as inline SVG, so icons paint offline, cost no requests, and report nothing to anyone.
82
+
83
+ ```tsx
84
+ <Icon name="shopping-cart" size={20} /> {/* decorative: aria-hidden */}
85
+ <Icon name="lock" size={20} label="Locked record" /> {/* meaningful: role="img" + aria-label */}
86
+ ```
87
+
88
+ Names are canonical **Lucide slugs**; the shipped pack is IBM Carbon, reached through an internal
89
+ map, so a pack swap never touches a call site. The same names are what the `icon` prop on `Button`,
90
+ `IconButton`, `Card`, `KpiTile`, `Tabs`, `EmptyState` and `InlineMessage` takes.
91
+
92
+ Glyphs are filled with `currentColor`, so an icon takes the colour of the text it sits in — style the
93
+ container, not the icon. `size` is the pixel box: 16 in dense UI, 20 in headers, 24 in tiles.
94
+
95
+ An unrecognised name renders an empty box of the right size and logs one `[pire]` console warning
96
+ naming it. The warning is compiled out of production builds.
97
+
98
+ The bundled set covers what the ERP screens use. For anything else, register the *inner* markup of a
99
+ 32×32 SVG once at startup:
81
100
 
82
101
  ```ts
83
102
  import { configureIcons } from '@orianatech/pire';
84
- configureIcons({ base: '/assets/icons/' }); // folder of <slug>.svg
103
+
104
+ configureIcons({
105
+ resolve: (name) => (name === 'oriana-mark' ? '<circle cx="16" cy="16" r="8"/>' : undefined),
106
+ });
85
107
  ```
86
108
 
109
+ > **Upgrading from 0.3 or earlier.** Glyphs used to be fetched per render from `cdn.jsdelivr.net`,
110
+ > which meant blank icons in an offline PWA, so apps self-hosted the SVGs and called
111
+ > `configureIcons({ base })`. That is now unnecessary: **delete the `configureIcons({ base })` call
112
+ > and the vendored `<slug>.svg` files.** `base` and `pack` are still accepted so existing code keeps
113
+ > compiling, but they are ignored and warn in development. The `lucide` pack is gone —
114
+ > `pack="lucide"` now renders the Carbon glyph.
115
+
116
+ Adding a glyph to the library itself is one line in `src/components/core/icon-map.ts` plus a build;
117
+ see the Icons section of `CLAUDE.md`.
118
+
87
119
  ## Migrating from the HTML/JSX prototypes
88
120
 
89
121
  | Prototype API | Package API |
@@ -104,7 +136,13 @@ configureIcons({ base: '/assets/icons/' }); // folder of <slug>.svg
104
136
 
105
137
  ## Toolchain notes
106
138
 
107
- - `pnpm build` runs `tsup` for the JS bundles, then `tsc -p tsconfig.build.json` for the
139
+ - `pnpm build` first runs `scripts/generate-icon-data.mjs`, which inlines the Carbon SVGs listed in
140
+ `src/components/core/icon-map.ts` into a committed `icon-data.generated.ts`. `@carbon/icons` is a
141
+ **devDependency on purpose** — it depends on `@ibm/telemetry-js` and runs `ibmtelemetry` from a
142
+ `postinstall` script, which as a runtime dependency would execute in every app that installs Pire.
143
+ The generated file is committed because typecheck, lint, tests and Storybook all read `src/`
144
+ without building; its install script is denied in `pnpm-workspace.yaml`.
145
+ - `pnpm build` then runs `tsup` for the JS bundles, then `tsc -p tsconfig.build.json` for the
108
146
  `.d.ts` files, then `scripts/postbuild-types.mjs`. tsup's own declaration pipeline is not
109
147
  compatible with the TypeScript 7 native compiler, so `dts` is off in `tsup.config.ts` —
110
148
  leave it that way. The post-pass makes the emitted declarations resolvable under
@@ -1,22 +1,37 @@
1
- import * as React from 'react';
1
+ import type * as React from 'react';
2
+ /** @deprecated Retained only so existing `pack` values still typecheck. Glyphs are bundled from
3
+ * the Carbon pack; there is no second pack and no remote fetch, so the value is ignored. */
2
4
  export type IconPack = 'carbon' | 'lucide';
3
5
  export interface IconConfig {
4
- /** Pack used when an <Icon> does not specify one. Default 'carbon'. */
6
+ /** @deprecated Ignored. The bundled Carbon pack is the only pack `lucide` was a remote-URL
7
+ * pack and no longer exists. Setting this warns in development. */
5
8
  pack?: IconPack;
6
- /** Serve glyphs from your own origin: a folder of <slug>.svg files. Recommended in production. */
9
+ /** @deprecated Ignored — glyphs are inlined in the bundle, so there is nothing to serve from a
10
+ * folder. Apps that vendored `<slug>.svg` files into `public/` to survive offline can delete
11
+ * both the files and this call. Setting it warns in development. */
7
12
  base?: string;
13
+ /** Escape hatch for a glyph Pire does not bundle, or to override one. Return the *inner* markup
14
+ * of a 32x32 SVG (`'<path d="…"/>'`), or `undefined` to fall through to the bundled set. The
15
+ * string is injected as-is, so it must be authored markup, never user input. */
16
+ resolve?: (name: string) => string | undefined;
8
17
  }
9
- /** Point the whole app at a self-hosted glyph folder, or switch packs, once at startup. */
18
+ /** Optional. The bundled Carbon set needs no configuration this exists only to register glyphs
19
+ * Pire does not ship, once at startup. */
10
20
  export declare function configureIcons(next: IconConfig): void;
11
21
  export interface IconProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'> {
12
- /** Canonical (Lucide) slug, e.g. "shopping-cart". Mapped automatically for the Carbon pack. */
22
+ /** Canonical (Lucide) slug, e.g. "shopping-cart". Translated to the Carbon pack automatically. */
13
23
  name: string;
14
24
  /** Pixel box. 16 in dense UI, 20 in headers, 24 in tiles. */
15
25
  size?: number;
16
26
  /** Accessible name. Omit for decorative icons — they are hidden from assistive tech. */
17
27
  label?: string;
28
+ /** @deprecated Ignored. See {@link IconConfig.pack}. */
18
29
  pack?: IconPack;
19
30
  }
20
- /** Glyph painted as a CSS mask over currentColor, so it inherits text colour everywhere.
21
- * A missing glyph degrades to empty space rather than a solid block. */
31
+ /** Glyph rendered as inline SVG filled with `currentColor`, so it inherits text colour everywhere
32
+ * and paints with no network at all which the previous CDN-backed implementation did not, leaving
33
+ * every icon blank offline in the PWAs that consume this library.
34
+ *
35
+ * An unknown name still renders nothing, so a typo cannot collapse a layout, but it warns in
36
+ * development: the silent blank gap was the failure mode worth killing. */
22
37
  export declare function Icon({ name, size, label, pack, style, ...rest }: IconProps): React.JSX.Element;
@@ -1,23 +1,38 @@
1
- import * as React from 'react';
1
+ import type * as React from 'react';
2
+ /** @deprecated Retained only so existing `pack` values still typecheck. Glyphs are bundled from
3
+ * the Carbon pack; there is no second pack and no remote fetch, so the value is ignored. */
2
4
  export type IconPack = 'carbon' | 'lucide';
3
5
  export interface IconConfig {
4
- /** Pack used when an <Icon> does not specify one. Default 'carbon'. */
6
+ /** @deprecated Ignored. The bundled Carbon pack is the only pack `lucide` was a remote-URL
7
+ * pack and no longer exists. Setting this warns in development. */
5
8
  pack?: IconPack;
6
- /** Serve glyphs from your own origin: a folder of <slug>.svg files. Recommended in production. */
9
+ /** @deprecated Ignored — glyphs are inlined in the bundle, so there is nothing to serve from a
10
+ * folder. Apps that vendored `<slug>.svg` files into `public/` to survive offline can delete
11
+ * both the files and this call. Setting it warns in development. */
7
12
  base?: string;
13
+ /** Escape hatch for a glyph Pire does not bundle, or to override one. Return the *inner* markup
14
+ * of a 32x32 SVG (`'<path d="…"/>'`), or `undefined` to fall through to the bundled set. The
15
+ * string is injected as-is, so it must be authored markup, never user input. */
16
+ resolve?: (name: string) => string | undefined;
8
17
  }
9
- /** Point the whole app at a self-hosted glyph folder, or switch packs, once at startup. */
18
+ /** Optional. The bundled Carbon set needs no configuration this exists only to register glyphs
19
+ * Pire does not ship, once at startup. */
10
20
  export declare function configureIcons(next: IconConfig): void;
11
21
  export interface IconProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'> {
12
- /** Canonical (Lucide) slug, e.g. "shopping-cart". Mapped automatically for the Carbon pack. */
22
+ /** Canonical (Lucide) slug, e.g. "shopping-cart". Translated to the Carbon pack automatically. */
13
23
  name: string;
14
24
  /** Pixel box. 16 in dense UI, 20 in headers, 24 in tiles. */
15
25
  size?: number;
16
26
  /** Accessible name. Omit for decorative icons — they are hidden from assistive tech. */
17
27
  label?: string;
28
+ /** @deprecated Ignored. See {@link IconConfig.pack}. */
18
29
  pack?: IconPack;
19
30
  }
20
- /** Glyph painted as a CSS mask over currentColor, so it inherits text colour everywhere.
21
- * A missing glyph degrades to empty space rather than a solid block. */
31
+ /** Glyph rendered as inline SVG filled with `currentColor`, so it inherits text colour everywhere
32
+ * and paints with no network at all which the previous CDN-backed implementation did not, leaving
33
+ * every icon blank offline in the PWAs that consume this library.
34
+ *
35
+ * An unknown name still renders nothing, so a typo cannot collapse a layout, but it warns in
36
+ * development: the silent blank gap was the failure mode worth killing. */
22
37
  export declare function Icon({ name, size, label, pack, style, ...rest }: IconProps): React.JSX.Element;
23
38
  //# sourceMappingURL=Icon.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Icon.d.ts","sourceRoot":"","sources":["../../../src/components/core/Icon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AA4B3C,MAAM,WAAW,UAAU;IACzB,uEAAuE;IACvE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,QAAsC;AAErF,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,eAAe,CAAC,EAAE,UAAU,CAAC;IACxF,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB;AAED;yEACyE;AACzE,wBAAgB,IAAI,CAAC,EAAE,IAAI,EAAE,IAAS,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,qBAuB/E"}
1
+ {"version":3,"file":"Icon.d.ts","sourceRoot":"","sources":["../../../src/components/core/Icon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AA2BpC;6FAC6F;AAC7F,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE3C,MAAM,WAAW,UAAU;IACzB;wEACoE;IACpE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB;;yEAEqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;qFAEiF;IACjF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAChD;AAID;2CAC2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAerD;AAED,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,eAAe,CAAC,EAAE,UAAU,CAAC;IACxF,kGAAkG;IAClG,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB;AAED;;;;;4EAK4E;AAC5E,wBAAgB,IAAI,CAAC,EAAE,IAAI,EAAE,IAAS,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,qBAqC/E"}
@@ -0,0 +1 @@
1
+ export declare const ICON_MARKUP: Record<string, string>;
@@ -0,0 +1,2 @@
1
+ export declare const ICON_MARKUP: Record<string, string>;
2
+ //# sourceMappingURL=icon-data.generated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icon-data.generated.d.ts","sourceRoot":"","sources":["../../../src/components/core/icon-data.generated.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAgE9C,CAAC"}
@@ -0,0 +1,13 @@
1
+ /** Canonical Pire icon names are Lucide slugs; the shipped IBM Carbon pack is reached through
2
+ * this map, so swapping packs never touches a call site. A new icon name used by a component
3
+ * must be added here — that is the whole registration step.
4
+ *
5
+ * This file is also the single source of truth for *which glyphs get vendored*:
6
+ * `scripts/generate-icon-data.mjs` reads it and inlines the matching SVG from
7
+ * `@carbon/icons` into `icon-data.generated.ts`. The script cannot import TypeScript, so it
8
+ * parses this object textually. **Every entry must stay a literal `'name': 'slug',` pair.**
9
+ * A spread, a computed key or a template literal is invisible to the generator: the glyph
10
+ * never gets vendored and `Icon` warns at runtime instead of rendering. Multiple names may
11
+ * point at one slug (`boxes` and `package` both mean `box`); the generator de-duplicates.
12
+ */
13
+ export declare const CARBON_MAP: Record<string, string>;
@@ -0,0 +1,14 @@
1
+ /** Canonical Pire icon names are Lucide slugs; the shipped IBM Carbon pack is reached through
2
+ * this map, so swapping packs never touches a call site. A new icon name used by a component
3
+ * must be added here — that is the whole registration step.
4
+ *
5
+ * This file is also the single source of truth for *which glyphs get vendored*:
6
+ * `scripts/generate-icon-data.mjs` reads it and inlines the matching SVG from
7
+ * `@carbon/icons` into `icon-data.generated.ts`. The script cannot import TypeScript, so it
8
+ * parses this object textually. **Every entry must stay a literal `'name': 'slug',` pair.**
9
+ * A spread, a computed key or a template literal is invisible to the generator: the glyph
10
+ * never gets vendored and `Icon` warns at runtime instead of rendering. Multiple names may
11
+ * point at one slug (`boxes` and `package` both mean `box`); the generator de-duplicates.
12
+ */
13
+ export declare const CARBON_MAP: Record<string, string>;
14
+ //# sourceMappingURL=icon-map.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icon-map.d.ts","sourceRoot":"","sources":["../../../src/components/core/icon-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAiB7C,CAAC"}
@@ -8,10 +8,17 @@ export interface ShellBarProps extends Omit<React.HTMLAttributes<HTMLElement>, '
8
8
  title?: React.ReactNode;
9
9
  onMenu?: () => void;
10
10
  onSearch?: () => void;
11
+ /** Accessible name for the menu button. Defaults to English, and the bar has no access to the app's
12
+ * catalogue — a Spanish product must pass its own or ship an English screen-reader label. */
13
+ menuLabel?: string;
14
+ /** Accessible name for the search button, for the same reason as {@link ShellBarProps.menuLabel}.
15
+ * Until this existed the only way to localise it was to drop `onSearch` and re-render the icon by
16
+ * hand, which is how a shell ends up with two search icons. */
17
+ searchLabel?: string;
11
18
  /** IconButtons with variant="inverse". */
12
19
  actions?: React.ReactNode;
13
20
  /** Full name — initials render into the avatar. */
14
21
  user?: string;
15
22
  }
16
23
  /** Fixed 48px application chrome. One per page, always the first landmark. */
17
- export declare function ShellBar({ product, monogram, title, onMenu, onSearch, actions, user, className, ...rest }: ShellBarProps): React.JSX.Element;
24
+ export declare function ShellBar({ product, monogram, title, onMenu, onSearch, menuLabel, searchLabel, actions, user, className, ...rest }: ShellBarProps): React.JSX.Element;
@@ -8,11 +8,18 @@ export interface ShellBarProps extends Omit<React.HTMLAttributes<HTMLElement>, '
8
8
  title?: React.ReactNode;
9
9
  onMenu?: () => void;
10
10
  onSearch?: () => void;
11
+ /** Accessible name for the menu button. Defaults to English, and the bar has no access to the app's
12
+ * catalogue — a Spanish product must pass its own or ship an English screen-reader label. */
13
+ menuLabel?: string;
14
+ /** Accessible name for the search button, for the same reason as {@link ShellBarProps.menuLabel}.
15
+ * Until this existed the only way to localise it was to drop `onSearch` and re-render the icon by
16
+ * hand, which is how a shell ends up with two search icons. */
17
+ searchLabel?: string;
11
18
  /** IconButtons with variant="inverse". */
12
19
  actions?: React.ReactNode;
13
20
  /** Full name — initials render into the avatar. */
14
21
  user?: string;
15
22
  }
16
23
  /** Fixed 48px application chrome. One per page, always the first landmark. */
17
- export declare function ShellBar({ product, monogram, title, onMenu, onSearch, actions, user, className, ...rest }: ShellBarProps): React.JSX.Element;
24
+ export declare function ShellBar({ product, monogram, title, onMenu, onSearch, menuLabel, searchLabel, actions, user, className, ...rest }: ShellBarProps): React.JSX.Element;
18
25
  //# sourceMappingURL=ShellBar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ShellBar.d.ts","sourceRoot":"","sources":["../../../src/components/navigation/ShellBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAIpC,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IACrF,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAID,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,aAAa,qBAaxH"}
1
+ {"version":3,"file":"ShellBar.d.ts","sourceRoot":"","sources":["../../../src/components/navigation/ShellBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAIpC,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IACrF,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;kGAC8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;oEAEgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAID,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAA6B,EAClG,WAAsB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,aAAa,qBAa3E"}
@@ -1,9 +1,24 @@
1
- import type * as React from 'react';
1
+ import * as React from 'react';
2
+ /** A second-level destination, nested under a first-level entry.
3
+ *
4
+ * Deliberately has no `icon`: only the first level carries one, so the second level reads as an
5
+ * indented list of labels under a single glyph rather than a wall of decoration. Leaving `icon` off
6
+ * the *type* is what stops that drifting back in one call site at a time. */
7
+ export interface SideNavChildItem {
8
+ id: string;
9
+ label: string;
10
+ count?: number;
11
+ }
2
12
  export interface SideNavItem {
3
13
  id: string;
4
14
  label: string;
5
15
  icon?: string;
6
16
  count?: number;
17
+ /** Second-level destinations. Supplying this turns the entry into a collapsible group: it stops
18
+ * being a destination itself (pressing it expands, never navigates) and `id` becomes the
19
+ * expansion key used by `expandedIds` / `defaultExpandedIds`, not something `onChange` can ever
20
+ * emit. Omit it and the entry behaves exactly as it always has. */
21
+ items?: SideNavChildItem[];
7
22
  }
8
23
  export interface SideNavGroup {
9
24
  label?: string;
@@ -13,10 +28,47 @@ export interface SideNavProps extends Omit<React.HTMLAttributes<HTMLElement>, 'o
13
28
  /** Spaces-and-pages hierarchy: one group per space. */
14
29
  groups: SideNavGroup[];
15
30
  value?: string;
16
- /** Fires with the selected item id — not a DOM change event. */
31
+ /** Fires with the selected item id — not a DOM change event. Only ever a destination id: a
32
+ * first-level entry that owns `items` is a disclosure, and toggling it does not fire this. */
17
33
  onChange?: (id: string) => void;
18
- /** 48px icon rail. Labels move into tooltips. */
34
+ /** Expanded first-level entry ids the controlled half of the pair. Pass it and you own expansion
35
+ * outright, including expanding the group that holds `value`. */
36
+ expandedIds?: string[];
37
+ /** Initial expanded ids for the uncontrolled case. Omit it and the group holding `value` opens
38
+ * itself; supply it and it is taken literally, so `value`'s group can start closed if that is
39
+ * genuinely what you want. */
40
+ defaultExpandedIds?: string[];
41
+ /** Fires with every expanded id after a toggle, controlled or not. */
42
+ onExpandedChange?: (ids: string[]) => void;
43
+ /** 48px icon rail. Labels move into tooltips, and second-level items are not rendered at all —
44
+ * see the note on {@link SideNav}. */
19
45
  collapsed?: boolean;
20
46
  footer?: React.ReactNode;
21
47
  }
22
- export declare function SideNav({ groups, value, onChange, collapsed, footer, className, ...rest }: SideNavProps): React.JSX.Element;
48
+ /** Primary navigation. A first-level entry is either a destination (icon + label, selectable) or a
49
+ * collapsible group (icon + label + chevron) when it carries `items` — the two coexist in one list,
50
+ * which is how a 35-destination ERP menu fits without showing all 35 at once.
51
+ *
52
+ * Both levels are plain `Button`s and the disclosure is React Aria's `Disclosure` /
53
+ * `DisclosureGroup`, so `aria-expanded`, `aria-controls`, the panel's `role="group"` and
54
+ * `aria-labelledby`, and Enter/Space on the trigger all come from React Aria rather than from
55
+ * hand-rolled ARIA here.
56
+ *
57
+ * Three things worth knowing before you use it:
58
+ *
59
+ * - **The panel is always in the DOM.** React Aria hides a collapsed panel with
60
+ * `hidden="until-found"`, applied imperatively in a layout effect, so find-in-page can reveal it.
61
+ * A test asserting a collapsed child is *absent* will fail; assert `aria-expanded` on the trigger,
62
+ * or visibility, instead. The corollary in CSS: any `display` we set on the panel would beat the
63
+ * UA's `[hidden] { display: none }`, leaving collapsed children focusable under
64
+ * `aria-hidden="true"`. `.pire-sidenav-subnav[hidden]` in `components.css` is that guard.
65
+ * - **`collapsed` and expansion are different axes, and the rail wins.** With `collapsed` set there
66
+ * are no labels, and a second level is nothing *but* labels, so children are not rendered in the
67
+ * rail. A group's row becomes an ordinary icon button that records the expansion (so the group is
68
+ * already open when the rail expands) and lights up while it holds `value`, which is the one thing
69
+ * a rail must still communicate. It follows that a nav with collapsible groups needs a way back
70
+ * out of the rail — `ShellBar`'s `onMenu` — or 25 of those 35 destinations are unreachable, for
71
+ * everyone, exactly as the labels already are.
72
+ * - **Several groups can be open at once**, and there is no accordion mode. Closing the group you
73
+ * are in to open another is the wrong trade in a nav whose whole job is showing where you are. */
74
+ export declare function SideNav({ groups, value, onChange, expandedIds, defaultExpandedIds, onExpandedChange, collapsed, footer, className, ...rest }: SideNavProps): React.JSX.Element;
@@ -1,9 +1,24 @@
1
- import type * as React from 'react';
1
+ import * as React from 'react';
2
+ /** A second-level destination, nested under a first-level entry.
3
+ *
4
+ * Deliberately has no `icon`: only the first level carries one, so the second level reads as an
5
+ * indented list of labels under a single glyph rather than a wall of decoration. Leaving `icon` off
6
+ * the *type* is what stops that drifting back in one call site at a time. */
7
+ export interface SideNavChildItem {
8
+ id: string;
9
+ label: string;
10
+ count?: number;
11
+ }
2
12
  export interface SideNavItem {
3
13
  id: string;
4
14
  label: string;
5
15
  icon?: string;
6
16
  count?: number;
17
+ /** Second-level destinations. Supplying this turns the entry into a collapsible group: it stops
18
+ * being a destination itself (pressing it expands, never navigates) and `id` becomes the
19
+ * expansion key used by `expandedIds` / `defaultExpandedIds`, not something `onChange` can ever
20
+ * emit. Omit it and the entry behaves exactly as it always has. */
21
+ items?: SideNavChildItem[];
7
22
  }
8
23
  export interface SideNavGroup {
9
24
  label?: string;
@@ -13,11 +28,48 @@ export interface SideNavProps extends Omit<React.HTMLAttributes<HTMLElement>, 'o
13
28
  /** Spaces-and-pages hierarchy: one group per space. */
14
29
  groups: SideNavGroup[];
15
30
  value?: string;
16
- /** Fires with the selected item id — not a DOM change event. */
31
+ /** Fires with the selected item id — not a DOM change event. Only ever a destination id: a
32
+ * first-level entry that owns `items` is a disclosure, and toggling it does not fire this. */
17
33
  onChange?: (id: string) => void;
18
- /** 48px icon rail. Labels move into tooltips. */
34
+ /** Expanded first-level entry ids the controlled half of the pair. Pass it and you own expansion
35
+ * outright, including expanding the group that holds `value`. */
36
+ expandedIds?: string[];
37
+ /** Initial expanded ids for the uncontrolled case. Omit it and the group holding `value` opens
38
+ * itself; supply it and it is taken literally, so `value`'s group can start closed if that is
39
+ * genuinely what you want. */
40
+ defaultExpandedIds?: string[];
41
+ /** Fires with every expanded id after a toggle, controlled or not. */
42
+ onExpandedChange?: (ids: string[]) => void;
43
+ /** 48px icon rail. Labels move into tooltips, and second-level items are not rendered at all —
44
+ * see the note on {@link SideNav}. */
19
45
  collapsed?: boolean;
20
46
  footer?: React.ReactNode;
21
47
  }
22
- export declare function SideNav({ groups, value, onChange, collapsed, footer, className, ...rest }: SideNavProps): React.JSX.Element;
48
+ /** Primary navigation. A first-level entry is either a destination (icon + label, selectable) or a
49
+ * collapsible group (icon + label + chevron) when it carries `items` — the two coexist in one list,
50
+ * which is how a 35-destination ERP menu fits without showing all 35 at once.
51
+ *
52
+ * Both levels are plain `Button`s and the disclosure is React Aria's `Disclosure` /
53
+ * `DisclosureGroup`, so `aria-expanded`, `aria-controls`, the panel's `role="group"` and
54
+ * `aria-labelledby`, and Enter/Space on the trigger all come from React Aria rather than from
55
+ * hand-rolled ARIA here.
56
+ *
57
+ * Three things worth knowing before you use it:
58
+ *
59
+ * - **The panel is always in the DOM.** React Aria hides a collapsed panel with
60
+ * `hidden="until-found"`, applied imperatively in a layout effect, so find-in-page can reveal it.
61
+ * A test asserting a collapsed child is *absent* will fail; assert `aria-expanded` on the trigger,
62
+ * or visibility, instead. The corollary in CSS: any `display` we set on the panel would beat the
63
+ * UA's `[hidden] { display: none }`, leaving collapsed children focusable under
64
+ * `aria-hidden="true"`. `.pire-sidenav-subnav[hidden]` in `components.css` is that guard.
65
+ * - **`collapsed` and expansion are different axes, and the rail wins.** With `collapsed` set there
66
+ * are no labels, and a second level is nothing *but* labels, so children are not rendered in the
67
+ * rail. A group's row becomes an ordinary icon button that records the expansion (so the group is
68
+ * already open when the rail expands) and lights up while it holds `value`, which is the one thing
69
+ * a rail must still communicate. It follows that a nav with collapsible groups needs a way back
70
+ * out of the rail — `ShellBar`'s `onMenu` — or 25 of those 35 destinations are unreachable, for
71
+ * everyone, exactly as the labels already are.
72
+ * - **Several groups can be open at once**, and there is no accordion mode. Closing the group you
73
+ * are in to open another is the wrong trade in a nav whose whole job is showing where you are. */
74
+ export declare function SideNav({ groups, value, onChange, expandedIds, defaultExpandedIds, onExpandedChange, collapsed, footer, className, ...rest }: SideNavProps): React.JSX.Element;
23
75
  //# sourceMappingURL=SideNav.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SideNav.d.ts","sourceRoot":"","sources":["../../../src/components/navigation/SideNav.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAMpC,MAAM,WAAW,WAAW;IAAG,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE;AACzF,MAAM,WAAW,YAAY;IAAG,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,WAAW,EAAE,CAAA;CAAE;AAEtE,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,UAAU,CAAC;IACvF,uDAAuD;IACvD,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC1B;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,qBAwBvG"}
1
+ {"version":3,"file":"SideNav.d.ts","sourceRoot":"","sources":["../../../src/components/navigation/SideNav.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAO/B;;;;8EAI8E;AAC9E,MAAM,WAAW,gBAAgB;IAAG,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE;AAE/E,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;wEAGoE;IACpE,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,YAAY;IAAG,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,WAAW,EAAE,CAAA;CAAE;AAEtE,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,UAAU,CAAC;IACvF,uDAAuD;IACvD,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;mGAC+F;IAC/F,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;sEACkE;IAClE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;mCAE+B;IAC/B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B,sEAAsE;IACtE,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC3C;2CACuC;IACvC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC1B;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;qGAyBqG;AACrG,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,kBAAkB,EAAE,gBAAgB,EAClG,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,qBAwGtD"}
@@ -188,6 +188,28 @@
188
188
  .pire-sidenav-item[data-hovered]{background:var(--surface-hover)}
189
189
  .pire-sidenav-item[data-selected="true"]{background:var(--surface-selected);border-left-color:var(--accent-bg);color:var(--accent-subtle-fg);font:var(--type-body-strong)}
190
190
  .pire-sidenav-count{margin-left:auto;font:var(--type-code);color:var(--text-tertiary)}
191
+ /* Second level. `-tree` is React Aria's DisclosureGroup, `-section` one Disclosure; both exist only
192
+ to keep the rows in a column, since the wrappers are divs the component cannot avoid. */
193
+ .pire-sidenav-tree,.pire-sidenav-section{display:flex;flex-direction:column}
194
+ /* The trailing chevron. Its own auto margin pushes it to the edge on a group row with no count; when
195
+ there is a count, the count's auto margin has already claimed the free space and a second auto here
196
+ would split it and leave the count floating mid-row — hence the reset. */
197
+ .pire-sidenav-chevron{margin-left:auto;color:var(--text-tertiary);transition:transform var(--dur-fast) var(--ease-standard)}
198
+ .pire-sidenav-count + .pire-sidenav-chevron{margin-left:0}
199
+ .pire-sidenav-section:not([data-expanded]) .pire-sidenav-chevron{transform:rotate(-90deg)}
200
+ /* Marks a group row whose child is the current destination: the accent tick and label weight of a
201
+ selection without its fill, so the trail to `aria-current` reads at a glance and still loses to it.
202
+ Also the only cue left in the icon rail, where the children are not rendered. */
203
+ .pire-sidenav-item[data-active-child="true"]{border-left-color:var(--accent-bg);color:var(--accent-subtle-fg);font:var(--type-body-strong)}
204
+ /* React Aria animates the panel through --disclosure-panel-height, and applies `hidden` only once the
205
+ animation settles. The `display` above therefore has to be undone for the collapsed state: it beats
206
+ the UA's `[hidden]{display:none}`, and a visible-to-the-layout panel under `aria-hidden="true"`
207
+ leaves every child in the tab order — an aria-hidden-focus defect, not just a cosmetic one. */
208
+ .pire-sidenav-subnav{display:flex;flex-direction:column;overflow:hidden;height:var(--disclosure-panel-height,auto);transition:height var(--dur-fast) var(--ease-standard)}
209
+ .pire-sidenav-subnav[hidden]{display:none}
210
+ /* Indented to the parent's *label*, not its icon: page padding + the 16px glyph box + the row gap.
211
+ Condensed height because a two-level menu is 35 rows, not 10. */
212
+ .pire-sidenav-subitem{height:var(--row-h-condensed);padding-left:calc(var(--sp-4) + var(--sp-4) + var(--sp-3))}
191
213
  .pire-sidenav-footer{margin-top:auto;padding:var(--sp-3) var(--sp-4);border-top:1px solid var(--border-subtle)}
192
214
 
193
215
  /* ---- Feedback -------------------------------------------------------- */