@umituz/web-dashboard 6.6.1 → 6.8.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 +2 -2
- package/src/domains/layouts/components/DashboardLayout.test.tsx +101 -0
- package/src/domains/layouts/components/DashboardLayout.tsx +66 -6
- package/src/domains/layouts/components/DashboardSidebar.tsx +2 -3
- package/src/domains/layouts/hooks/dashboard.ts +20 -1
- package/src/domains/layouts/hooks/index.ts +1 -0
- package/src/domains/layouts/hooks/usePageTitle.test.tsx +32 -0
- package/src/domains/layouts/types/layout.ts +15 -0
- package/src/domains/layouts/utils/dashboard.test.ts +65 -0
- package/src/domains/layouts/utils/dashboard.ts +39 -10
- package/src/domains/layouts/utils/index.ts +1 -0
- package/src/styles/chrome.css +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-dashboard",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.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
|
},
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
|
4
|
+
import { Home } from "lucide-react";
|
|
5
|
+
import { DashboardLayout } from "./DashboardLayout";
|
|
6
|
+
import type { DashboardLayoutConfig } from "../types/layout";
|
|
7
|
+
|
|
8
|
+
const config: DashboardLayoutConfig = {
|
|
9
|
+
sidebarGroups: [
|
|
10
|
+
{
|
|
11
|
+
title: "Main",
|
|
12
|
+
items: [{ path: "/", label: "Home", icon: Home, end: false }],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
brandName: "Test",
|
|
16
|
+
defaultTitle: "Workspace",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function renderAt(path: string, mainId?: string, configOverride?: DashboardLayoutConfig) {
|
|
20
|
+
return render(
|
|
21
|
+
<MemoryRouter initialEntries={[path]}>
|
|
22
|
+
<Routes>
|
|
23
|
+
<Route
|
|
24
|
+
path="*"
|
|
25
|
+
element={
|
|
26
|
+
<DashboardLayout config={configOverride ?? config} mainId={mainId}>
|
|
27
|
+
{/* children are ignored — the layout renders an Outlet */}
|
|
28
|
+
</DashboardLayout>
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
<Route path="*" element={<div>outlet-content</div>} />
|
|
32
|
+
</Route>
|
|
33
|
+
</Routes>
|
|
34
|
+
</MemoryRouter>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("DashboardLayout (shell a11y)", () => {
|
|
39
|
+
it("renders a skip link targeting the main landmark", () => {
|
|
40
|
+
renderAt("/");
|
|
41
|
+
const skip = screen.getByRole("link", { name: /skip to content/i });
|
|
42
|
+
expect(skip.getAttribute("href")).toBe("#udb-main");
|
|
43
|
+
expect(document.getElementById("udb-main")).not.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("honours a custom mainId on both the link and the landmark", () => {
|
|
47
|
+
renderAt("/", "main-content");
|
|
48
|
+
expect(screen.getByRole("link", { name: /skip to content/i }).getAttribute("href")).toBe("#main-content");
|
|
49
|
+
expect(document.getElementById("main-content")).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders the routed outlet inside the main landmark", () => {
|
|
53
|
+
renderAt("/");
|
|
54
|
+
const main = document.getElementById("udb-main");
|
|
55
|
+
expect(main?.textContent).toContain("outlet-content");
|
|
56
|
+
});
|
|
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
|
+
});
|
|
@@ -2,8 +2,10 @@ import { useState, useEffect, type ReactNode } from "react";
|
|
|
2
2
|
import { useLocation, 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";
|
|
8
|
+
import { getActiveSidebarItem } from "../utils/dashboard";
|
|
7
9
|
import type { DashboardLayoutConfig } from "../types/layout";
|
|
8
10
|
import type { DashboardNotification } from "../types/notification";
|
|
9
11
|
import type { DashboardUser } from "../types/user";
|
|
@@ -25,6 +27,10 @@ interface DashboardLayoutProps {
|
|
|
25
27
|
collapsedStorageKey?: string;
|
|
26
28
|
/** Optional page footer rendered under the outlet column. */
|
|
27
29
|
footer?: ReactNode;
|
|
30
|
+
/** id of the main landmark — also the skip-link target. */
|
|
31
|
+
mainId?: string;
|
|
32
|
+
/** Skip-link label (a11y); defaults to the localized "Skip to content". */
|
|
33
|
+
skipToContentLabel?: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const readCollapsed = (key?: string): boolean => {
|
|
@@ -51,12 +57,16 @@ export const DashboardLayout = ({
|
|
|
51
57
|
routeTransitionMs = 0,
|
|
52
58
|
collapsedStorageKey,
|
|
53
59
|
footer,
|
|
60
|
+
mainId = "udb-main",
|
|
61
|
+
skipToContentLabel,
|
|
54
62
|
}: DashboardLayoutProps) => {
|
|
55
63
|
const location = useLocation();
|
|
56
64
|
const t = useT();
|
|
57
65
|
const [collapsed, setCollapsed] = useState(() => readCollapsed(collapsedStorageKey));
|
|
58
66
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
59
67
|
const [loading, setLoading] = useState(routeTransitionMs > 0);
|
|
68
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
69
|
+
const palette = config.commandPalette;
|
|
60
70
|
|
|
61
71
|
useEffect(() => {
|
|
62
72
|
if (!collapsedStorageKey || typeof window === "undefined") return;
|
|
@@ -81,12 +91,37 @@ export const DashboardLayout = ({
|
|
|
81
91
|
setMobileOpen(false);
|
|
82
92
|
}, [location.pathname]);
|
|
83
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!palette || palette.shortcut === false) return;
|
|
96
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
97
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
setPaletteOpen((v) => !v);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
window.addEventListener("keydown", onKeyDown);
|
|
103
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
104
|
+
}, [palette]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!mobileOpen) return;
|
|
108
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
109
|
+
if (e.key === "Escape") setMobileOpen(false);
|
|
110
|
+
};
|
|
111
|
+
window.addEventListener("keydown", onKeyDown);
|
|
112
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
113
|
+
}, [mobileOpen]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (typeof document === "undefined") return;
|
|
117
|
+
document.documentElement.classList.toggle("udb-scroll-lock", mobileOpen);
|
|
118
|
+
return () => document.documentElement.classList.remove("udb-scroll-lock");
|
|
119
|
+
}, [mobileOpen]);
|
|
120
|
+
|
|
84
121
|
if (authLoading) return null;
|
|
85
122
|
if (!isAuthenticated) return <Navigate to={loginRoute} replace />;
|
|
86
123
|
|
|
87
|
-
const activeItem = config.sidebarGroups
|
|
88
|
-
.flatMap((group) => group.items)
|
|
89
|
-
.find((i) => i.path === location.pathname);
|
|
124
|
+
const activeItem = getActiveSidebarItem(location.pathname, config.sidebarGroups);
|
|
90
125
|
|
|
91
126
|
const getTitle = () => {
|
|
92
127
|
if (activeItem) return activeItem.raw ? activeItem.label : t(activeItem.label);
|
|
@@ -109,6 +144,10 @@ export const DashboardLayout = ({
|
|
|
109
144
|
|
|
110
145
|
return (
|
|
111
146
|
<div className="udb-root udb-layout">
|
|
147
|
+
<a href={`#${mainId}`} className="udb-skip-link">
|
|
148
|
+
{skipToContentLabel ?? t("Skip to content")}
|
|
149
|
+
</a>
|
|
150
|
+
|
|
112
151
|
<aside
|
|
113
152
|
className={`udb-layout__sidebar ${collapsed ? "udb-layout__sidebar--collapsed" : ""}`}
|
|
114
153
|
>
|
|
@@ -122,7 +161,12 @@ export const DashboardLayout = ({
|
|
|
122
161
|
{mobileOpen && (
|
|
123
162
|
<div className="udb-layout__mobile">
|
|
124
163
|
<div className="udb-layout__backdrop" onClick={() => setMobileOpen(false)} />
|
|
125
|
-
<aside
|
|
164
|
+
<aside
|
|
165
|
+
className="udb-layout__mobile-sidebar"
|
|
166
|
+
role="dialog"
|
|
167
|
+
aria-modal="true"
|
|
168
|
+
aria-label={config.brandName ? `${config.brandName} — ${t("Navigation")}` : t("Navigation")}
|
|
169
|
+
>
|
|
126
170
|
<DashboardSidebar
|
|
127
171
|
collapsed={false}
|
|
128
172
|
setCollapsed={() => setMobileOpen(false)}
|
|
@@ -146,14 +190,19 @@ export const DashboardLayout = ({
|
|
|
146
190
|
onMarkAllRead={onMarkAllRead}
|
|
147
191
|
onDismissNotification={onDismissNotification}
|
|
148
192
|
loginRoute={loginRoute}
|
|
149
|
-
searchAction={
|
|
193
|
+
searchAction={
|
|
194
|
+
palette
|
|
195
|
+
? // The palette owns the header search unless the app gave an explicit action.
|
|
196
|
+
{ placeholder: palette.placeholder, shortcut: "⌘K", ...config.searchAction, onActivate: config.searchAction?.onActivate ?? (() => setPaletteOpen(true)) }
|
|
197
|
+
: config.searchAction
|
|
198
|
+
}
|
|
150
199
|
headerExtra={config.headerExtra}
|
|
151
200
|
profileItems={config.profileItems}
|
|
152
201
|
showDefaultProfileItems={config.showDefaultProfileItems}
|
|
153
202
|
notificationsViewAll={config.notificationsViewAll}
|
|
154
203
|
/>
|
|
155
204
|
|
|
156
|
-
<main className="udb-layout__content">
|
|
205
|
+
<main id={mainId} tabIndex={-1} className="udb-layout__content">
|
|
157
206
|
{loading ? (
|
|
158
207
|
<div className="udb-page-skeleton">
|
|
159
208
|
<Skeleton className="udb-skeleton--title" />
|
|
@@ -171,6 +220,17 @@ export const DashboardLayout = ({
|
|
|
171
220
|
|
|
172
221
|
{footer && <footer className="udb-layout__footer">{footer}</footer>}
|
|
173
222
|
</div>
|
|
223
|
+
|
|
224
|
+
{palette && (
|
|
225
|
+
<CommandPalette
|
|
226
|
+
open={paletteOpen}
|
|
227
|
+
onOpenChange={setPaletteOpen}
|
|
228
|
+
groups={palette.groups}
|
|
229
|
+
placeholder={palette.placeholder}
|
|
230
|
+
emptyLabel={palette.emptyLabel}
|
|
231
|
+
label={palette.label}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
174
234
|
</div>
|
|
175
235
|
);
|
|
176
236
|
};
|
|
@@ -5,6 +5,7 @@ import { useT } from "@umituz/web-localization/react";
|
|
|
5
5
|
import { Button } from "@umituz/web-design-system/atoms";
|
|
6
6
|
import { cn } from "@umituz/web-design-system/utils/cn";
|
|
7
7
|
import { BrandLogo } from "./BrandLogo";
|
|
8
|
+
import { isPathActive } from "../utils/dashboard";
|
|
8
9
|
import { PenTool, Menu, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react";
|
|
9
10
|
import type { DashboardSidebarProps, SidebarAction } from "../types/layout";
|
|
10
11
|
import type { DashboardUser } from "../types/user";
|
|
@@ -128,9 +129,7 @@ export const DashboardSidebar = ({
|
|
|
128
129
|
)}
|
|
129
130
|
|
|
130
131
|
{(!isGroupCollapsed || collapsed) && filteredItems.map((item) => {
|
|
131
|
-
const active =
|
|
132
|
-
location.pathname === item.path ||
|
|
133
|
-
(item.end === false && location.pathname.startsWith(item.path + "/"));
|
|
132
|
+
const active = isPathActive(location.pathname, item.path, item.end);
|
|
134
133
|
const labelText = item.raw ? item.label : t(item.label);
|
|
135
134
|
const collapsedTitle =
|
|
136
135
|
item.badge !== undefined && item.badge !== ""
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
2
|
import type { DashboardNotification } from "../types/notification";
|
|
3
3
|
|
|
4
|
+
export interface UsePageTitleOptions {
|
|
5
|
+
/** Appended as `title · suffix` (e.g. the product name). */
|
|
6
|
+
suffix?: string;
|
|
7
|
+
/** Used when the route passes no title (e.g. the home route). */
|
|
8
|
+
fallback?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Document title per route — `usePageTitle("Search", { suffix: "Lingora" })`
|
|
12
|
+
* → "Search · Lingora". Restores nothing on unmount (the next route sets it). */
|
|
13
|
+
export function usePageTitle(title?: string, options: UsePageTitleOptions = {}) {
|
|
14
|
+
const { suffix, fallback } = options;
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (title && suffix) document.title = `${title} · ${suffix}`;
|
|
18
|
+
else if (title) document.title = title;
|
|
19
|
+
else if (fallback) document.title = fallback;
|
|
20
|
+
}, [title, suffix, fallback]);
|
|
21
|
+
}
|
|
22
|
+
|
|
4
23
|
export function useNotifications(initialNotifications: DashboardNotification[] = []) {
|
|
5
24
|
const [notifications, setNotifications] = useState<DashboardNotification[]>(initialNotifications);
|
|
6
25
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { usePageTitle } from "./dashboard";
|
|
4
|
+
|
|
5
|
+
function Probe({ title, suffix, fallback }: { title?: string; suffix?: string; fallback?: string }) {
|
|
6
|
+
usePageTitle(title, { suffix, fallback });
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("usePageTitle", () => {
|
|
11
|
+
it("sets `title · suffix` when both are given", () => {
|
|
12
|
+
document.title = "before";
|
|
13
|
+
render(<Probe title="Search" suffix="Lingora" />);
|
|
14
|
+
expect(document.title).toBe("Search · Lingora");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("sets the bare title without a suffix", () => {
|
|
18
|
+
render(<Probe title="Search" />);
|
|
19
|
+
expect(document.title).toBe("Search");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("falls back when the route passes no title", () => {
|
|
23
|
+
render(<Probe fallback="Lingora — English, examined properly" />);
|
|
24
|
+
expect(document.title).toBe("Lingora — English, examined properly");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves the previous title untouched with neither title nor fallback", () => {
|
|
28
|
+
document.title = "kept";
|
|
29
|
+
render(<Probe />);
|
|
30
|
+
expect(document.title).toBe("kept");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -32,6 +32,18 @@ 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
|
+
}
|
|
46
|
+
|
|
35
47
|
export interface DashboardSidebarProps {
|
|
36
48
|
collapsed: boolean;
|
|
37
49
|
setCollapsed: (collapsed: boolean) => void;
|
|
@@ -51,6 +63,9 @@ export interface DashboardLayoutConfig {
|
|
|
51
63
|
/** Extra node rendered in the header actions area (app chips, clocks…). */
|
|
52
64
|
headerExtra?: React.ReactNode;
|
|
53
65
|
searchAction?: HeaderSearchAction;
|
|
66
|
+
/** ⌘K palette — when set, the header search opens it (an explicit
|
|
67
|
+
* searchAction.onActivate still wins). */
|
|
68
|
+
commandPalette?: CommandPaletteConfig;
|
|
54
69
|
/** Fully custom profile menu items — shown in addition to (or, with showDefaultProfileItems=false, instead of) the defaults. */
|
|
55
70
|
profileItems?: HeaderProfileItem[];
|
|
56
71
|
/** Hide the built-in profile/settings items (defaults to true). */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getActiveSidebarItem, getPageTitle, isPathActive } from "./dashboard";
|
|
3
|
+
|
|
4
|
+
describe("isPathActive", () => {
|
|
5
|
+
it("matches exact paths regardless of end", () => {
|
|
6
|
+
expect(isPathActive("/app/courses", "/app/courses")).toBe(true);
|
|
7
|
+
expect(isPathActive("/app/courses", "/app/courses", false)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("does not match siblings or parents by default (end defaults to true)", () => {
|
|
11
|
+
expect(isPathActive("/app/courses/lesson-1", "/app/courses")).toBe(false);
|
|
12
|
+
expect(isPathActive("/application", "/app")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("matches child routes when end is false", () => {
|
|
16
|
+
expect(isPathActive("/app/courses/lesson-1", "/app/courses", false)).toBe(true);
|
|
17
|
+
expect(isPathActive("/app", "/app", false)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("prefix match never crosses a path segment boundary", () => {
|
|
21
|
+
expect(isPathActive("/application", "/app", false)).toBe(false);
|
|
22
|
+
expect(isPathActive("/app/extra", "/app", false)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("getPageTitle", () => {
|
|
27
|
+
const groups = [
|
|
28
|
+
{
|
|
29
|
+
items: [
|
|
30
|
+
{ path: "/app", label: "Workspace", end: false },
|
|
31
|
+
{ path: "/app/courses", label: "Courses" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
it("resolves the exact item first", () => {
|
|
37
|
+
expect(getPageTitle("/app/courses", groups)).toBe("Courses");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("resolves prefix items on child routes (end: false)", () => {
|
|
41
|
+
expect(getPageTitle("/app/planner", groups)).toBe("Workspace");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to the extra title map, then null", () => {
|
|
45
|
+
expect(getPageTitle("/x", groups, { "/x": "Extra" })).toBe("Extra");
|
|
46
|
+
expect(getPageTitle("/x", groups)).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("prefers the longest prefix parent when several match", () => {
|
|
50
|
+
const nested = [
|
|
51
|
+
{
|
|
52
|
+
items: [
|
|
53
|
+
{ path: "/app", label: "Workspace", end: false },
|
|
54
|
+
{ path: "/app/courses", label: "Courses", end: false },
|
|
55
|
+
{ path: "/app/courses/lesson-1", label: "Lesson" },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
// An exact item always wins over prefix parents…
|
|
60
|
+
expect(getPageTitle("/app/courses/lesson-1", nested)).toBe("Lesson");
|
|
61
|
+
// …on deeper routes the longest prefix parent wins.
|
|
62
|
+
expect(getPageTitle("/app/courses/lesson-1/summary", nested)).toBe("Courses");
|
|
63
|
+
expect(getActiveSidebarItem("/nowhere", nested)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -11,21 +11,50 @@ export function formatNotificationTime(
|
|
|
11
11
|
return t("dashboard.activityFeed.times.daysAgo", { val: Math.floor(secs / 86400) });
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** Exact match by default; `end: false` also matches the path's children
|
|
15
|
+
* (`/app/courses` stays active on `/app/courses/lesson-1`). */
|
|
16
|
+
export function isPathActive(currentPath: string, targetPath: string, end = true): boolean {
|
|
17
|
+
return currentPath === targetPath || (!end && currentPath.startsWith(targetPath + "/"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TitledPath {
|
|
21
|
+
path: string;
|
|
22
|
+
label: string;
|
|
23
|
+
end?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Most specific active item: an exact match always wins, otherwise the
|
|
27
|
+
* longest `end: false` prefix parent (`/app/courses` beats `/app` on
|
|
28
|
+
* `/app/courses/lesson-1`). */
|
|
29
|
+
export function getActiveSidebarItem<T extends TitledPath>(
|
|
30
|
+
pathname: string,
|
|
31
|
+
sidebarGroups: Array<{ items: T[] }>
|
|
32
|
+
): T | null {
|
|
33
|
+
let best: T | null = null;
|
|
34
|
+
for (const group of sidebarGroups) {
|
|
35
|
+
for (const item of group.items) {
|
|
36
|
+
if (pathname === item.path) return item;
|
|
37
|
+
if (
|
|
38
|
+
item.end === false &&
|
|
39
|
+
pathname.startsWith(item.path + "/") &&
|
|
40
|
+
(!best || item.path.length > best.path.length)
|
|
41
|
+
) {
|
|
42
|
+
best = item;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best;
|
|
16
47
|
}
|
|
17
48
|
|
|
18
49
|
export function getPageTitle(
|
|
19
50
|
pathname: string,
|
|
20
|
-
sidebarGroups: Array<{ items:
|
|
51
|
+
sidebarGroups: Array<{ items: TitledPath[] }>,
|
|
21
52
|
extraTitleMap?: Record<string, string>
|
|
22
53
|
): string | null {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (item) return item.label;
|
|
26
|
-
}
|
|
54
|
+
const item = getActiveSidebarItem(pathname, sidebarGroups);
|
|
55
|
+
if (item) return item.label;
|
|
27
56
|
|
|
28
|
-
|
|
57
|
+
if (extraTitleMap?.[pathname]) {
|
|
29
58
|
return extraTitleMap[pathname];
|
|
30
59
|
}
|
|
31
60
|
|
|
@@ -37,9 +66,9 @@ export function filterSidebarItems<T extends { enabled?: boolean; requiredApp?:
|
|
|
37
66
|
user?: { hasMobileApp?: boolean; hasWebApp?: boolean }
|
|
38
67
|
): T[] {
|
|
39
68
|
return items.filter((item) => {
|
|
40
|
-
|
|
69
|
+
if (item.enabled === false) return false;
|
|
41
70
|
|
|
42
|
-
|
|
71
|
+
if (!item.requiredApp) return true;
|
|
43
72
|
if (item.requiredApp === "mobile") return user?.hasMobileApp ?? false;
|
|
44
73
|
if (item.requiredApp === "web") return user?.hasWebApp ?? false;
|
|
45
74
|
|
package/src/styles/chrome.css
CHANGED
|
@@ -529,6 +529,35 @@
|
|
|
529
529
|
color: var(--color-muted-foreground);
|
|
530
530
|
font-size: var(--udb-font-size-sm);
|
|
531
531
|
}
|
|
532
|
+
|
|
533
|
+
/* — Skip link: first focusable element, visible only when focused — */
|
|
534
|
+
.udb-skip-link {
|
|
535
|
+
position: fixed;
|
|
536
|
+
top: var(--udb-space-2);
|
|
537
|
+
left: var(--udb-space-2);
|
|
538
|
+
z-index: 100;
|
|
539
|
+
transform: translateY(-200%);
|
|
540
|
+
padding: var(--udb-space-2) var(--udb-space-4);
|
|
541
|
+
border-radius: var(--radius-md);
|
|
542
|
+
background: var(--color-primary);
|
|
543
|
+
color: var(--color-primary-foreground);
|
|
544
|
+
font-size: var(--udb-font-size-sm);
|
|
545
|
+
font-weight: 500;
|
|
546
|
+
text-decoration: none;
|
|
547
|
+
transition: transform 0.1s ease;
|
|
548
|
+
}
|
|
549
|
+
.udb-skip-link:focus {
|
|
550
|
+
transform: translateY(0);
|
|
551
|
+
}
|
|
552
|
+
/* Programmatic focus (skip target) shows no ring — the jump itself is the cue. */
|
|
553
|
+
.udb-layout__content:focus {
|
|
554
|
+
outline: none;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/* Scroll lock applied to <html> while the mobile drawer is open. */
|
|
558
|
+
.udb-scroll-lock {
|
|
559
|
+
overflow: hidden;
|
|
560
|
+
}
|
|
532
561
|
.udb-filter-bar {
|
|
533
562
|
display: flex;
|
|
534
563
|
flex-wrap: wrap;
|