@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.
|
|
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.
|
|
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={
|
|
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). */
|