@umituz/web-dashboard 6.6.0 → 6.7.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 +1 -1
- package/src/domains/layouts/components/DashboardLayout.test.tsx +57 -0
- package/src/domains/layouts/components/DashboardLayout.tsx +34 -5
- 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/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.7.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",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } 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) {
|
|
20
|
+
return render(
|
|
21
|
+
<MemoryRouter initialEntries={[path]}>
|
|
22
|
+
<Routes>
|
|
23
|
+
<Route
|
|
24
|
+
path="*"
|
|
25
|
+
element={
|
|
26
|
+
<DashboardLayout config={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
|
+
});
|
|
@@ -4,6 +4,7 @@ import { useT } from "@umituz/web-localization/react";
|
|
|
4
4
|
import { Skeleton } from "@umituz/web-design-system/atoms";
|
|
5
5
|
import { DashboardSidebar } from "./DashboardSidebar";
|
|
6
6
|
import { DashboardHeader } from "./DashboardHeader";
|
|
7
|
+
import { getActiveSidebarItem } from "../utils/dashboard";
|
|
7
8
|
import type { DashboardLayoutConfig } from "../types/layout";
|
|
8
9
|
import type { DashboardNotification } from "../types/notification";
|
|
9
10
|
import type { DashboardUser } from "../types/user";
|
|
@@ -25,6 +26,10 @@ interface DashboardLayoutProps {
|
|
|
25
26
|
collapsedStorageKey?: string;
|
|
26
27
|
/** Optional page footer rendered under the outlet column. */
|
|
27
28
|
footer?: ReactNode;
|
|
29
|
+
/** id of the main landmark — also the skip-link target. */
|
|
30
|
+
mainId?: string;
|
|
31
|
+
/** Skip-link label (a11y); defaults to the localized "Skip to content". */
|
|
32
|
+
skipToContentLabel?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const readCollapsed = (key?: string): boolean => {
|
|
@@ -51,6 +56,8 @@ export const DashboardLayout = ({
|
|
|
51
56
|
routeTransitionMs = 0,
|
|
52
57
|
collapsedStorageKey,
|
|
53
58
|
footer,
|
|
59
|
+
mainId = "udb-main",
|
|
60
|
+
skipToContentLabel,
|
|
54
61
|
}: DashboardLayoutProps) => {
|
|
55
62
|
const location = useLocation();
|
|
56
63
|
const t = useT();
|
|
@@ -81,12 +88,25 @@ export const DashboardLayout = ({
|
|
|
81
88
|
setMobileOpen(false);
|
|
82
89
|
}, [location.pathname]);
|
|
83
90
|
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!mobileOpen) return;
|
|
93
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
94
|
+
if (e.key === "Escape") setMobileOpen(false);
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener("keydown", onKeyDown);
|
|
97
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
98
|
+
}, [mobileOpen]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (typeof document === "undefined") return;
|
|
102
|
+
document.documentElement.classList.toggle("udb-scroll-lock", mobileOpen);
|
|
103
|
+
return () => document.documentElement.classList.remove("udb-scroll-lock");
|
|
104
|
+
}, [mobileOpen]);
|
|
105
|
+
|
|
84
106
|
if (authLoading) return null;
|
|
85
107
|
if (!isAuthenticated) return <Navigate to={loginRoute} replace />;
|
|
86
108
|
|
|
87
|
-
const activeItem = config.sidebarGroups
|
|
88
|
-
.flatMap((group) => group.items)
|
|
89
|
-
.find((i) => i.path === location.pathname);
|
|
109
|
+
const activeItem = getActiveSidebarItem(location.pathname, config.sidebarGroups);
|
|
90
110
|
|
|
91
111
|
const getTitle = () => {
|
|
92
112
|
if (activeItem) return activeItem.raw ? activeItem.label : t(activeItem.label);
|
|
@@ -109,6 +129,10 @@ export const DashboardLayout = ({
|
|
|
109
129
|
|
|
110
130
|
return (
|
|
111
131
|
<div className="udb-root udb-layout">
|
|
132
|
+
<a href={`#${mainId}`} className="udb-skip-link">
|
|
133
|
+
{skipToContentLabel ?? t("Skip to content")}
|
|
134
|
+
</a>
|
|
135
|
+
|
|
112
136
|
<aside
|
|
113
137
|
className={`udb-layout__sidebar ${collapsed ? "udb-layout__sidebar--collapsed" : ""}`}
|
|
114
138
|
>
|
|
@@ -122,7 +146,12 @@ export const DashboardLayout = ({
|
|
|
122
146
|
{mobileOpen && (
|
|
123
147
|
<div className="udb-layout__mobile">
|
|
124
148
|
<div className="udb-layout__backdrop" onClick={() => setMobileOpen(false)} />
|
|
125
|
-
<aside
|
|
149
|
+
<aside
|
|
150
|
+
className="udb-layout__mobile-sidebar"
|
|
151
|
+
role="dialog"
|
|
152
|
+
aria-modal="true"
|
|
153
|
+
aria-label={config.brandName ? `${config.brandName} — ${t("Navigation")}` : t("Navigation")}
|
|
154
|
+
>
|
|
126
155
|
<DashboardSidebar
|
|
127
156
|
collapsed={false}
|
|
128
157
|
setCollapsed={() => setMobileOpen(false)}
|
|
@@ -153,7 +182,7 @@ export const DashboardLayout = ({
|
|
|
153
182
|
notificationsViewAll={config.notificationsViewAll}
|
|
154
183
|
/>
|
|
155
184
|
|
|
156
|
-
<main className="udb-layout__content">
|
|
185
|
+
<main id={mainId} tabIndex={-1} className="udb-layout__content">
|
|
157
186
|
{loading ? (
|
|
158
187
|
<div className="udb-page-skeleton">
|
|
159
188
|
<Skeleton className="udb-skeleton--title" />
|
|
@@ -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
|
+
});
|
|
@@ -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;
|