@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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,187 @@
1
+ // DataTable — card layout below the 768px breakpoint (offlot#37). Below
2
+ // that width the table scrolled its columns out of reach with no visible
3
+ // affordance (worse: the `md:sticky` actions column scrolled away WITH the
4
+ // last data columns instead of staying reachable). These tests pin the
5
+ // replacement: below the breakpoint, no <table> at all — one card per row,
6
+ // every ViewModel column present as a label/value pair, actions always
7
+ // visible, and a native <select> standing in for the header-click sort
8
+ // affordance that has no header to attach to down here.
9
+ //
10
+ // Viewport is driven the same way embedded-list-input.test.tsx does it —
11
+ // happy-dom's real innerWidth backs useIsNarrowViewport's matchMedia query,
12
+ // so no matchMedia mock is needed.
13
+
14
+ import { describe, expect, mock, test } from "bun:test";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { defaultPrimitives } from "../primitives";
17
+ import { fireEvent, render, screen, within } from "./test-utils";
18
+
19
+ const { DataTable } = defaultPrimitives;
20
+
21
+ function setViewportWidth(width: number): void {
22
+ (
23
+ window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
24
+ ).happyDOM.setInnerWidth(width);
25
+ }
26
+
27
+ function withViewportWidth(width: number, run: () => void): void {
28
+ const originalWidth = window.innerWidth;
29
+ setViewportWidth(width);
30
+ try {
31
+ run();
32
+ } finally {
33
+ setViewportWidth(originalWidth);
34
+ }
35
+ }
36
+
37
+ const LONG_BIO =
38
+ "Anna joined the handler team in 2019 and has led onboarding for every partner integration since, focusing on payment reconciliation edge cases and cross-border tax handling.";
39
+
40
+ const COLUMNS = [
41
+ { field: "name", label: "Name", type: "string", sortable: true },
42
+ { field: "email", label: "Email", type: "string", sortable: false },
43
+ { field: "role", label: "Role", type: "string", sortable: true },
44
+ { field: "bio", label: "Bio", type: "string", sortable: false },
45
+ ] as const;
46
+
47
+ const ROWS = [
48
+ {
49
+ id: "u1",
50
+ values: { name: "Anna Beispiel", email: "anna@haendler.de", role: "Admin", bio: LONG_BIO },
51
+ },
52
+ ];
53
+
54
+ describe("DataTable — cards below 768px", () => {
55
+ test("desktop viewport: table renders as before, no cards", () => {
56
+ withViewportWidth(1024, () => {
57
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
58
+ expect(screen.getByTestId("t").tagName).toBe("TABLE");
59
+ expect(screen.queryByTestId("t-cards")).toBeNull();
60
+ });
61
+ });
62
+
63
+ test("narrow viewport: no <table>, one card per row, every column present as label + value", () => {
64
+ withViewportWidth(500, () => {
65
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
66
+ expect(document.querySelector("table")).toBeNull();
67
+ const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
68
+ // Title = first column (none highlighted here) — still findable, and
69
+ // not duplicated as a label/value pair below.
70
+ expect(card.textContent).toContain("Anna Beispiel");
71
+ for (const col of COLUMNS.filter((c) => c.field !== "name")) {
72
+ expect(within(card).getByText(col.label)).not.toBeNull();
73
+ }
74
+ expect(within(card).getByTestId("cell-u1-email").textContent).toBe("anna@haendler.de");
75
+ expect(within(card).getByTestId("cell-u1-role").textContent).toBe("Admin");
76
+ expect(within(card).getByTestId("cell-u1-bio").textContent).toBe(LONG_BIO);
77
+ });
78
+ });
79
+
80
+ test("highlighted column becomes the card title and does not repeat as a label/value pair", () => {
81
+ withViewportWidth(500, () => {
82
+ const columns = COLUMNS.map((c) => (c.field === "role" ? { ...c, highlighted: true } : c));
83
+ render(<DataTable columns={columns} rows={ROWS} testId="t" />);
84
+ const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
85
+ expect(within(card).queryByText("Role")).toBeNull();
86
+ expect(within(card).getByText("Name")).not.toBeNull();
87
+ });
88
+ });
89
+
90
+ test("a value the table would truncate is shown in full in the card, without the truncate class", () => {
91
+ const originalWidth = window.innerWidth;
92
+ try {
93
+ setViewportWidth(1024);
94
+ const { unmount } = render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
95
+ const desktopCell = screen.getByTestId("cell-u1-bio");
96
+ expect(desktopCell.className).toContain("truncate");
97
+ unmount();
98
+
99
+ setViewportWidth(500);
100
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
101
+ const cardCell = screen.getByTestId("cell-u1-bio");
102
+ expect(cardCell.className).not.toContain("truncate");
103
+ expect(cardCell.textContent).toBe(LONG_BIO);
104
+ } finally {
105
+ setViewportWidth(originalWidth);
106
+ }
107
+ });
108
+
109
+ test("row actions are present in the DOM and operable in card mode", async () => {
110
+ const originalWidth = window.innerWidth;
111
+ setViewportWidth(500);
112
+ try {
113
+ const onTrigger = mock();
114
+ render(
115
+ <DataTable
116
+ columns={COLUMNS}
117
+ rows={ROWS}
118
+ rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
119
+ testId="t"
120
+ />,
121
+ );
122
+ const button = screen.getByTestId("row-u1-action-edit");
123
+ await userEvent.setup().click(button);
124
+ expect(onTrigger).toHaveBeenCalledTimes(1);
125
+ } finally {
126
+ setViewportWidth(originalWidth);
127
+ }
128
+ });
129
+
130
+ test("empty state renders the same way in card mode, no card container", () => {
131
+ withViewportWidth(500, () => {
132
+ render(<DataTable columns={COLUMNS} rows={[]} testId="t" />);
133
+ expect(screen.getByTestId("t-empty")).not.toBeNull();
134
+ expect(screen.queryByTestId("t-cards")).toBeNull();
135
+ });
136
+ });
137
+ });
138
+
139
+ // No column headers below the breakpoint, so SortableHeader's click-to-sort
140
+ // has nothing to attach to — a native <select> fed from the sortable
141
+ // columns is the whole replacement, only rendered when there is something
142
+ // to sort and somewhere for the result to go.
143
+ describe("DataTable — card-mode sort select", () => {
144
+ test("lists only the sortable columns, both directions, and reports the picked one", () => {
145
+ withViewportWidth(500, () => {
146
+ const onSortChange = mock();
147
+ render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
148
+ const select = screen.getByTestId("t-sort") as HTMLSelectElement;
149
+ const optionLabels = Array.from(select.options).map((o) => o.textContent);
150
+ expect(optionLabels).toEqual(["Unsorted", "Name ↑", "Name ↓", "Role ↑", "Role ↓"]);
151
+
152
+ fireEvent.change(select, { target: { value: "role:desc" } });
153
+ expect(onSortChange).toHaveBeenCalledWith({ field: "role", dir: "desc" });
154
+ });
155
+ });
156
+
157
+ test("no select without onSortChange — nothing to wire it to", () => {
158
+ withViewportWidth(500, () => {
159
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
160
+ expect(screen.queryByTestId("t-sort")).toBeNull();
161
+ });
162
+ });
163
+
164
+ test("no select when no column is sortable", () => {
165
+ withViewportWidth(500, () => {
166
+ const onSortChange = mock();
167
+ const nonSortableColumns = COLUMNS.map((c) => ({ ...c, sortable: false }));
168
+ render(
169
+ <DataTable
170
+ columns={nonSortableColumns}
171
+ rows={ROWS}
172
+ onSortChange={onSortChange}
173
+ testId="t"
174
+ />,
175
+ );
176
+ expect(screen.queryByTestId("t-sort")).toBeNull();
177
+ });
178
+ });
179
+
180
+ test("desktop viewport never renders the select — header clicks already cover it", () => {
181
+ withViewportWidth(1024, () => {
182
+ const onSortChange = mock();
183
+ render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
184
+ expect(screen.queryByTestId("t-sort")).toBeNull();
185
+ });
186
+ });
187
+ });
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
440
440
  expect(screen.queryByText("Tours")).toBeNull();
441
441
  });
442
442
 
443
- test("fill=true applies h-svh on root and min-h-0 on inset/main", () => {
443
+ test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
444
444
  renderShell(
445
445
  <WorkspaceShell
446
446
  brand={<div>Brand</div>}
447
447
  schema={schema}
448
448
  user={{ id: "u1", roles: ["admin"] }}
449
- fill
450
449
  >
451
450
  <div>content</div>
452
451
  </WorkspaceShell>,
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
462
461
  expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
463
462
  });
464
463
 
465
- // #1692: default (no fill) must not force viewport-lock classes
466
- test("without fill does not apply h-svh or min-h-0", () => {
464
+ // fill={false} is the escape hatch back to page-scroll: must not apply
465
+ // viewport-lock classes.
466
+ test("fill={false} does not apply h-svh or min-h-0", () => {
467
467
  renderShell(
468
468
  <WorkspaceShell
469
469
  brand={<div>Brand</div>}
470
470
  schema={schema}
471
471
  user={{ id: "u1", roles: ["admin"] }}
472
+ fill={false}
472
473
  >
473
474
  <div>content</div>
474
475
  </WorkspaceShell>,
package/src/icons.tsx ADDED
@@ -0,0 +1,199 @@
1
+ // Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
2
+ // is the closed vocabulary a feature author can write (packages/types/src/
3
+ // nav-icon.ts); `satisfies` below makes this map a compile-time drift
4
+ // guard — a key added to one without the other fails the build.
5
+ //
6
+ // Runtime data (provider-supplied nav/action icons) is only typed as plain
7
+ // `string` at the resolved-tree layer, so an unknown key can still show up
8
+ // at runtime on occasion — `Icon` falls back to rendering the raw key as
9
+ // text rather than crashing, mirroring the old `ActionGlyph` behavior in
10
+ // nav-tree.tsx.
11
+ //
12
+ // Buttons resolve icons through the same map — importing it from the nav
13
+ // layout module would couple them to the sidebar.
14
+ import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
15
+ import {
16
+ AlertTriangle,
17
+ Archive,
18
+ ArrowLeft,
19
+ ArrowRight,
20
+ BarChart3,
21
+ Bell,
22
+ BookOpen,
23
+ Building,
24
+ Calculator,
25
+ CalendarDays,
26
+ Check,
27
+ CheckCircle2,
28
+ ChevronDown,
29
+ ChevronRight,
30
+ ClipboardList,
31
+ Clock,
32
+ Coins,
33
+ Copy,
34
+ CreditCard,
35
+ Download,
36
+ ExternalLink,
37
+ Eye,
38
+ EyeOff,
39
+ FileText,
40
+ Filter,
41
+ Flag,
42
+ Folder,
43
+ FolderOpen,
44
+ Gauge,
45
+ Hash,
46
+ Home,
47
+ Info,
48
+ KeyRound,
49
+ Languages,
50
+ Layers,
51
+ LayoutDashboard,
52
+ LayoutGrid,
53
+ LineChart,
54
+ Link,
55
+ List,
56
+ Loader2,
57
+ Lock,
58
+ Mail,
59
+ MapPin,
60
+ MoreHorizontal,
61
+ MoreVertical,
62
+ Package,
63
+ Palette,
64
+ Pencil,
65
+ Phone,
66
+ PiggyBank,
67
+ Plus,
68
+ Printer,
69
+ Receipt,
70
+ RefreshCw,
71
+ Rocket,
72
+ Save,
73
+ Search,
74
+ Send,
75
+ Server,
76
+ Settings,
77
+ Share2,
78
+ Shield,
79
+ ShieldCheck,
80
+ Sparkles,
81
+ Star,
82
+ Table,
83
+ Tag,
84
+ Trash2,
85
+ TrendingUp,
86
+ Undo2,
87
+ Upload,
88
+ User,
89
+ Users,
90
+ Wallet,
91
+ Wand2,
92
+ X,
93
+ XCircle,
94
+ } from "lucide-react";
95
+ import type { ReactNode } from "react";
96
+ import { cn } from "./lib/cn";
97
+
98
+ export const NAV_ICONS = {
99
+ dashboard: LayoutDashboard,
100
+ "layout-grid": LayoutGrid,
101
+ "book-open": BookOpen,
102
+ "clipboard-list": ClipboardList,
103
+ package: Package,
104
+ gauge: Gauge,
105
+ list: List,
106
+ table: Table,
107
+ layers: Layers,
108
+ building: Building,
109
+ calculator: Calculator,
110
+ wallet: Wallet,
111
+ coins: Coins,
112
+ "credit-card": CreditCard,
113
+ "piggy-bank": PiggyBank,
114
+ receipt: Receipt,
115
+ chart: LineChart,
116
+ "bar-chart": BarChart3,
117
+ trending: TrendingUp,
118
+ sparkles: Sparkles,
119
+ wand: Wand2,
120
+ calendar: CalendarDays,
121
+ file: FileText,
122
+ folder: Folder,
123
+ "folder-open": FolderOpen,
124
+ home: Home,
125
+ bell: Bell,
126
+ shield: Shield,
127
+ "shield-check": ShieldCheck,
128
+ send: Send,
129
+ settings: Settings,
130
+ users: Users,
131
+ user: User,
132
+ search: Search,
133
+ tag: Tag,
134
+ key: KeyRound,
135
+ link: Link,
136
+ palette: Palette,
137
+ share: Share2,
138
+ server: Server,
139
+ mail: Mail,
140
+ lock: Lock,
141
+ hash: Hash,
142
+ download: Download,
143
+ upload: Upload,
144
+ rocket: Rocket,
145
+ // Was imported but never registered — `icon: "plus"` silently fell back.
146
+ plus: Plus,
147
+ languages: Languages,
148
+ trash: Trash2,
149
+ x: X,
150
+ check: Check,
151
+ "arrow-left": ArrowLeft,
152
+ "arrow-right": ArrowRight,
153
+ copy: Copy,
154
+ pencil: Pencil,
155
+ eye: Eye,
156
+ "eye-off": EyeOff,
157
+ filter: Filter,
158
+ refresh: RefreshCw,
159
+ "more-horizontal": MoreHorizontal,
160
+ "more-vertical": MoreVertical,
161
+ "external-link": ExternalLink,
162
+ "chevron-down": ChevronDown,
163
+ "chevron-right": ChevronRight,
164
+ save: Save,
165
+ undo: Undo2,
166
+ archive: Archive,
167
+ star: Star,
168
+ flag: Flag,
169
+ clock: Clock,
170
+ "map-pin": MapPin,
171
+ phone: Phone,
172
+ printer: Printer,
173
+ "alert-triangle": AlertTriangle,
174
+ info: Info,
175
+ "check-circle": CheckCircle2,
176
+ "x-circle": XCircle,
177
+ loader: Loader2,
178
+ } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
179
+
180
+ // Widened alias for runtime lookups against the plain `string` icon keys
181
+ // resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
182
+ // itself is typed against.
183
+ const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
184
+
185
+ export function Icon({
186
+ name,
187
+ className,
188
+ }: {
189
+ readonly name: NavIconKey;
190
+ readonly className?: string;
191
+ }): ReactNode {
192
+ const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
193
+ if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
194
+ return (
195
+ <span aria-hidden="true" className={cn("text-xs", className)}>
196
+ {name}
197
+ </span>
198
+ );
199
+ }
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
13
13
  readonly sidebar?: ReactNode;
14
14
  readonly children: ReactNode;
15
15
  readonly testId?: string;
16
- /** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe Viewport-Höhe),
17
- * Sidebar/Topbar bleiben stehen, der Main-Bereich scrollt INNEN
18
- * (`min-h-0` + `overflow-auto`). false (Default) → klassischer
19
- * `min-h-screen`-Flow, die ganze Seite scrollt. Dashboard-artige Apps
20
- * wollen `true`; eine öffentliche, lange Content-Seite eher `false`.
21
- * Clippt nie — der Content scrollt in `main` statt im Body. */
16
+ /** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
17
+ * Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
18
+ * scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
19
+ * `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
20
+ * eine öffentliche, lange Content-Seite die echten Body-Scroll
21
+ * braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
22
22
  readonly fill?: boolean;
23
23
  /** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
24
24
  * Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
@@ -32,7 +32,7 @@ export function AppLayout({
32
32
  sidebar,
33
33
  children,
34
34
  testId,
35
- fill,
35
+ fill = true,
36
36
  className,
37
37
  mainClassName,
38
38
  }: AppLayoutProps): ReactNode {
@@ -68,7 +68,8 @@ export type DefaultAppShellProps = {
68
68
  * Plan-Banner. */
69
69
  readonly sidebarFooter?: ReactNode;
70
70
  /** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
71
- * scrollt INNEN statt der ganzen Seite. Default false (Seiten-Scroll). */
71
+ * scrollt INNEN statt der ganzen Seite. Default true `fill={false}`
72
+ * schaltet zurück auf Seiten-Scroll. */
72
73
  readonly fill?: boolean;
73
74
  /** Screen-Content der im SidebarInset gerendert wird. */
74
75
  readonly children: ReactNode;
@@ -82,7 +83,7 @@ export function DefaultAppShell({
82
83
  headerActions,
83
84
  navBadges,
84
85
  sidebarFooter,
85
- fill,
86
+ fill = true,
86
87
  children,
87
88
  }: DefaultAppShellProps): ReactNode {
88
89
  const fillCls = fillClasses(fill);
@@ -17,7 +17,7 @@ import type {
17
17
  TreeAction,
18
18
  TreeNode,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { NavDefinition, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
21
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
22
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
23
23
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
@@ -28,58 +28,7 @@ import {
28
28
  useNav,
29
29
  useTranslation,
30
30
  } from "@cosmicdrift/kumiko-renderer";
31
- import {
32
- BarChart3,
33
- Bell,
34
- BookOpen,
35
- Building,
36
- Calculator,
37
- CalendarDays,
38
- ChevronDown,
39
- ChevronRight,
40
- ClipboardList,
41
- Coins,
42
- CreditCard,
43
- Download,
44
- FileText,
45
- Folder,
46
- FolderOpen,
47
- Gauge,
48
- Hash,
49
- Home,
50
- KeyRound,
51
- Languages,
52
- Layers,
53
- LayoutDashboard,
54
- LayoutGrid,
55
- LineChart,
56
- Link,
57
- List,
58
- Lock,
59
- Mail,
60
- Package,
61
- Palette,
62
- PiggyBank,
63
- Plus,
64
- Receipt,
65
- Rocket,
66
- Search,
67
- Send,
68
- Server,
69
- Settings,
70
- Share2,
71
- Shield,
72
- ShieldCheck,
73
- Sparkles,
74
- Table,
75
- Tag,
76
- TrendingUp,
77
- Upload,
78
- User,
79
- Users,
80
- Wallet,
81
- Wand2,
82
- } from "lucide-react";
31
+ import { ChevronDown, ChevronRight, type Folder, Plus } from "lucide-react";
83
32
  import {
84
33
  createContext,
85
34
  type ReactNode,
@@ -91,6 +40,7 @@ import {
91
40
  } from "react";
92
41
  import { KumikoLink } from "../app/nav";
93
42
  import { useNavEntities, useNavProviders } from "../app/nav-providers-context";
43
+ import { NAV_ICONS } from "../icons";
94
44
  import { cn } from "../lib/cn";
95
45
  import {
96
46
  SidebarGroup,
@@ -109,69 +59,6 @@ import {
109
59
  import { useDispatchTarget } from "./target-resolver-stub";
110
60
  import { parseTargetFromSearchParams } from "./target-url";
111
61
 
112
- // Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
113
- // the renderer maps the symbolic key to a lucide component. `NavIconKey` is
114
- // the closed vocabulary a feature author can write (packages/types/src/
115
- // nav-icon.ts); `satisfies` below makes this map a compile-time drift
116
- // guard — a key added to one without the other fails the build.
117
- //
118
- // `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
119
- // layer (dynamic/provider-supplied data isn't statically known), so the
120
- // runtime `Object.hasOwn` lookups below still see an unknown key on
121
- // occasion — that's the defense-in-depth fallback to the dot, not the
122
- // primary guard anymore.
123
- const NAV_ICONS = {
124
- dashboard: LayoutDashboard,
125
- "layout-grid": LayoutGrid,
126
- "book-open": BookOpen,
127
- "clipboard-list": ClipboardList,
128
- package: Package,
129
- gauge: Gauge,
130
- list: List,
131
- table: Table,
132
- layers: Layers,
133
- building: Building,
134
- calculator: Calculator,
135
- wallet: Wallet,
136
- coins: Coins,
137
- "credit-card": CreditCard,
138
- "piggy-bank": PiggyBank,
139
- receipt: Receipt,
140
- chart: LineChart,
141
- "bar-chart": BarChart3,
142
- trending: TrendingUp,
143
- sparkles: Sparkles,
144
- wand: Wand2,
145
- calendar: CalendarDays,
146
- file: FileText,
147
- folder: Folder,
148
- "folder-open": FolderOpen,
149
- home: Home,
150
- bell: Bell,
151
- shield: Shield,
152
- "shield-check": ShieldCheck,
153
- send: Send,
154
- settings: Settings,
155
- users: Users,
156
- user: User,
157
- search: Search,
158
- tag: Tag,
159
- key: KeyRound,
160
- link: Link,
161
- palette: Palette,
162
- share: Share2,
163
- server: Server,
164
- mail: Mail,
165
- lock: Lock,
166
- hash: Hash,
167
- download: Download,
168
- upload: Upload,
169
- rocket: Rocket,
170
- // Was imported but never registered — `icon: "plus"` silently fell back.
171
- plus: Plus,
172
- languages: Languages,
173
- } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
174
-
175
62
  // Widened alias for the two lookup sites below, which index by the plain
176
63
  // `string` icon key of the resolved NavNode/TreeAction tree — not the
177
64
  // closed NavIconKey union NAV_ICONS itself is typed against.
@@ -711,7 +598,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
711
598
  {...(s.active && { "aria-current": "page" })}
712
599
  >
713
600
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
714
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
601
+ <span
602
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
603
+ title={s.displayLabel}
604
+ >
715
605
  {s.displayLabel}
716
606
  </span>
717
607
  <NavBadge node={node} />
@@ -734,7 +624,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
734
624
  onClick={() => dispatch(target)}
735
625
  >
736
626
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
737
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
627
+ <span
628
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
629
+ title={s.displayLabel}
630
+ >
738
631
  {s.displayLabel}
739
632
  </span>
740
633
  <NavBadge node={node} />
@@ -755,7 +648,9 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
755
648
  {...(s.expandable && { "aria-expanded": s.isExpanded })}
756
649
  >
757
650
  <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} label={s.displayLabel} />
758
- <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
651
+ <span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
652
+ {s.displayLabel}
653
+ </span>
759
654
  {s.expandable &&
760
655
  (s.isExpanded ? (
761
656
  <ChevronDown className="ml-auto" />
@@ -823,7 +718,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
823
718
  {...(s.active && { "aria-current": "page" })}
824
719
  >
825
720
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
826
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
721
+ <span
722
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
723
+ title={s.displayLabel}
724
+ >
827
725
  {s.displayLabel}
828
726
  </span>
829
727
  <NavBadge node={node} />
@@ -843,7 +741,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
843
741
  <SidebarMenuSubButton asChild isActive={s.active}>
844
742
  <button type="button" onClick={() => dispatch(target)}>
845
743
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
846
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
744
+ <span
745
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
746
+ title={s.displayLabel}
747
+ >
847
748
  {s.displayLabel}
848
749
  </span>
849
750
  <NavBadge node={node} />
@@ -871,7 +772,9 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
871
772
  expanded={s.isExpanded}
872
773
  label={s.displayLabel}
873
774
  />
874
- <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
775
+ <span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
776
+ {s.displayLabel}
777
+ </span>
875
778
  </button>
876
779
  </SidebarMenuSubButton>
877
780
  <NodeActions node={node} />