@umituz/web-dashboard 6.7.0 → 6.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-dashboard",
3
- "version": "6.7.0",
3
+ "version": "6.9.0",
4
4
  "description": "Installable app skeleton for the fleet — layouts, analytics (dependency-free SVG charts), calendar, onboarding flow, i18n via @umituz/web-localization, permissions/module registry; udb-* stylesheet on design-system tokens",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -66,7 +66,7 @@
66
66
  }
67
67
  },
68
68
  "dependencies": {
69
- "@umituz/web-design-system": "^4.18.0",
69
+ "@umituz/web-design-system": "^4.19.0",
70
70
  "lucide-react": "^0.577.0",
71
71
  "@umituz/web-localization": "^1.2.1"
72
72
  },
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
3
  import { MemoryRouter, Routes, Route } from "react-router-dom";
4
4
  import { Home } from "lucide-react";
5
5
  import { DashboardLayout } from "./DashboardLayout";
@@ -16,14 +16,14 @@ const config: DashboardLayoutConfig = {
16
16
  defaultTitle: "Workspace",
17
17
  };
18
18
 
19
- function renderAt(path: string, mainId?: string) {
19
+ function renderAt(path: string, mainId?: string, configOverride?: DashboardLayoutConfig) {
20
20
  return render(
21
21
  <MemoryRouter initialEntries={[path]}>
22
22
  <Routes>
23
23
  <Route
24
24
  path="*"
25
25
  element={
26
- <DashboardLayout config={config} mainId={mainId}>
26
+ <DashboardLayout config={configOverride ?? config} mainId={mainId}>
27
27
  {/* children are ignored — the layout renders an Outlet */}
28
28
  </DashboardLayout>
29
29
  }
@@ -55,3 +55,71 @@ describe("DashboardLayout (shell a11y)", () => {
55
55
  expect(main?.textContent).toContain("outlet-content");
56
56
  });
57
57
  });
58
+
59
+ describe("DashboardLayout (command palette)", () => {
60
+ const paletteConfig = (extra?: Partial<DashboardLayoutConfig>): DashboardLayoutConfig => ({
61
+ ...config,
62
+ commandPalette: {
63
+ groups: [{ heading: "Go to", items: [{ id: "home", label: "Home", onSelect: () => {} }] }],
64
+ placeholder: "Search commands…",
65
+ },
66
+ ...extra,
67
+ });
68
+
69
+ it("renders the palette's search button in the header and opens it on click", async () => {
70
+ renderAt("/", undefined, paletteConfig());
71
+ fireEvent.click(screen.getByRole("button", { name: /search commands/i }));
72
+ expect(await screen.findByRole("dialog")).toBeTruthy();
73
+ expect(await screen.findByRole("option", { name: "Home" })).toBeTruthy();
74
+ });
75
+
76
+ it("toggles the palette with ⌘K / Ctrl+K", async () => {
77
+ renderAt("/", undefined, paletteConfig());
78
+ expect(screen.queryByRole("dialog")).toBeNull();
79
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
80
+ expect(await screen.findByRole("dialog")).toBeTruthy();
81
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
82
+ expect(screen.queryByRole("dialog")).toBeNull();
83
+ });
84
+
85
+ it("does not bind the shortcut when shortcut: false", () => {
86
+ renderAt("/", undefined, {
87
+ ...config,
88
+ commandPalette: { groups: [], shortcut: false },
89
+ });
90
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
91
+ expect(screen.queryByRole("dialog")).toBeNull();
92
+ });
93
+
94
+ it("keeps an explicit searchAction.onActivate over the palette takeover", () => {
95
+ const onActivate = vi.fn();
96
+ renderAt("/", undefined, paletteConfig({ searchAction: { placeholder: "Go search", onActivate } }));
97
+ fireEvent.click(screen.getByRole("button", { name: /go search/i }));
98
+ expect(onActivate).toHaveBeenCalledTimes(1);
99
+ expect(screen.queryByRole("dialog")).toBeNull();
100
+ });
101
+
102
+ it("derives navigation groups from the sidebar when includeSidebar is set", async () => {
103
+ renderAt("/", undefined, {
104
+ ...config,
105
+ commandPalette: {
106
+ includeSidebar: true,
107
+ groups: [{ heading: "Quick", items: [{ id: "q", label: "Quick action", onSelect: () => {} }] }],
108
+ },
109
+ });
110
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
111
+ expect(await screen.findByRole("option", { name: "Quick action" })).toBeTruthy();
112
+ // The custom group has no "Home" — the one present is derived from the sidebar,
113
+ // carrying the path as its hint (accessible name "Home /").
114
+ const derived = screen.getByRole("option", { name: /home/i });
115
+ expect(derived.textContent).toContain("/");
116
+ });
117
+
118
+ it("omits sidebar-derived items from the palette by default", async () => {
119
+ renderAt("/", undefined, paletteConfig());
120
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
121
+ // Only the palette's own "Home" (no path hint) — nothing derived from the sidebar.
122
+ const own = await screen.findByRole("option", { name: "Home" });
123
+ expect(own.textContent).toBe("Home");
124
+ });
125
+ });
@@ -1,7 +1,8 @@
1
1
  import { useState, useEffect, type ReactNode } from "react";
2
- import { useLocation, Outlet, Navigate } from "react-router-dom";
2
+ import { useLocation, useNavigate, Outlet, Navigate } from "react-router-dom";
3
3
  import { useT } from "@umituz/web-localization/react";
4
4
  import { Skeleton } from "@umituz/web-design-system/atoms";
5
+ import { CommandPalette } from "@umituz/web-design-system/organisms";
5
6
  import { DashboardSidebar } from "./DashboardSidebar";
6
7
  import { DashboardHeader } from "./DashboardHeader";
7
8
  import { getActiveSidebarItem } from "../utils/dashboard";
@@ -60,10 +61,13 @@ export const DashboardLayout = ({
60
61
  skipToContentLabel,
61
62
  }: DashboardLayoutProps) => {
62
63
  const location = useLocation();
64
+ const navigate = useNavigate();
63
65
  const t = useT();
64
66
  const [collapsed, setCollapsed] = useState(() => readCollapsed(collapsedStorageKey));
65
67
  const [mobileOpen, setMobileOpen] = useState(false);
66
68
  const [loading, setLoading] = useState(routeTransitionMs > 0);
69
+ const [paletteOpen, setPaletteOpen] = useState(false);
70
+ const palette = config.commandPalette;
67
71
 
68
72
  useEffect(() => {
69
73
  if (!collapsedStorageKey || typeof window === "undefined") return;
@@ -88,6 +92,18 @@ export const DashboardLayout = ({
88
92
  setMobileOpen(false);
89
93
  }, [location.pathname]);
90
94
 
95
+ useEffect(() => {
96
+ if (!palette || palette.shortcut === false) return;
97
+ const onKeyDown = (e: KeyboardEvent) => {
98
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
99
+ e.preventDefault();
100
+ setPaletteOpen((v) => !v);
101
+ }
102
+ };
103
+ window.addEventListener("keydown", onKeyDown);
104
+ return () => window.removeEventListener("keydown", onKeyDown);
105
+ }, [palette]);
106
+
91
107
  useEffect(() => {
92
108
  if (!mobileOpen) return;
93
109
  const onKeyDown = (e: KeyboardEvent) => {
@@ -117,6 +133,23 @@ export const DashboardLayout = ({
117
133
 
118
134
  const currentTitle = getTitle();
119
135
 
136
+ // Sidebar-derived navigation groups — one nav definition feeds both chrome surfaces.
137
+ const paletteGroups = palette?.includeSidebar
138
+ ? [
139
+ ...palette.groups,
140
+ ...config.sidebarGroups.map((group) => ({
141
+ heading: group.title,
142
+ items: group.items.map((item) => ({
143
+ id: `nav-${item.path}`,
144
+ label: item.raw ? item.label : t(item.label),
145
+ icon: <item.icon size={16} aria-hidden="true" />,
146
+ hint: item.path,
147
+ onSelect: () => navigate(item.path),
148
+ })),
149
+ })),
150
+ ]
151
+ : undefined;
152
+
120
153
  const sidebarProps = {
121
154
  sidebarGroups: config.sidebarGroups,
122
155
  brandName: config.brandName,
@@ -175,7 +208,12 @@ export const DashboardLayout = ({
175
208
  onMarkAllRead={onMarkAllRead}
176
209
  onDismissNotification={onDismissNotification}
177
210
  loginRoute={loginRoute}
178
- searchAction={config.searchAction}
211
+ searchAction={
212
+ palette
213
+ ? // The palette owns the header search unless the app gave an explicit action.
214
+ { placeholder: palette.placeholder, shortcut: "⌘K", ...config.searchAction, onActivate: config.searchAction?.onActivate ?? (() => setPaletteOpen(true)) }
215
+ : config.searchAction
216
+ }
179
217
  headerExtra={config.headerExtra}
180
218
  profileItems={config.profileItems}
181
219
  showDefaultProfileItems={config.showDefaultProfileItems}
@@ -200,6 +238,17 @@ export const DashboardLayout = ({
200
238
 
201
239
  {footer && <footer className="udb-layout__footer">{footer}</footer>}
202
240
  </div>
241
+
242
+ {palette && (
243
+ <CommandPalette
244
+ open={paletteOpen}
245
+ onOpenChange={setPaletteOpen}
246
+ groups={paletteGroups ?? palette.groups}
247
+ placeholder={palette.placeholder}
248
+ emptyLabel={palette.emptyLabel}
249
+ label={palette.label}
250
+ />
251
+ )}
203
252
  </div>
204
253
  );
205
254
  };
@@ -32,6 +32,21 @@ export interface HeaderSearchAction {
32
32
  onActivate: () => void;
33
33
  }
34
34
 
35
+ /** ⌘K command palette wired into the shell: renders the DS CommandPalette,
36
+ * binds ⌘K/Ctrl+K, and (unless searchAction overrides it) makes the header
37
+ * search button open the palette. */
38
+ export interface CommandPaletteConfig {
39
+ groups: import("@umituz/web-design-system/organisms").CommandGroup[];
40
+ placeholder?: string;
41
+ emptyLabel?: string;
42
+ label?: string;
43
+ /** Bind ⌘K/Ctrl+K to open the palette (default true). */
44
+ shortcut?: boolean;
45
+ /** Append the sidebar groups as navigation groups — derived from
46
+ * sidebarGroups and routed internally, so apps never map their nav twice. */
47
+ includeSidebar?: boolean;
48
+ }
49
+
35
50
  export interface DashboardSidebarProps {
36
51
  collapsed: boolean;
37
52
  setCollapsed: (collapsed: boolean) => void;
@@ -51,6 +66,9 @@ export interface DashboardLayoutConfig {
51
66
  /** Extra node rendered in the header actions area (app chips, clocks…). */
52
67
  headerExtra?: React.ReactNode;
53
68
  searchAction?: HeaderSearchAction;
69
+ /** ⌘K palette — when set, the header search opens it (an explicit
70
+ * searchAction.onActivate still wins). */
71
+ commandPalette?: CommandPaletteConfig;
54
72
  /** Fully custom profile menu items — shown in addition to (or, with showDefaultProfileItems=false, instead of) the defaults. */
55
73
  profileItems?: HeaderProfileItem[];
56
74
  /** Hide the built-in profile/settings items (defaults to true). */