@umituz/web-dashboard 6.6.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-dashboard",
3
- "version": "6.6.1",
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 className="udb-layout__mobile-sidebar">
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
 
@@ -1,4 +1,5 @@
1
1
  export {
2
+ usePageTitle,
2
3
  useNotifications,
3
4
  useSidebar,
4
5
  } from './dashboard';
@@ -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
- export function isPathActive(currentPath: string, targetPath: string): boolean {
15
- return currentPath === targetPath;
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: Array<{ path: string; label: string }> }>,
51
+ sidebarGroups: Array<{ items: TitledPath[] }>,
21
52
  extraTitleMap?: Record<string, string>
22
53
  ): string | null {
23
- for (const group of sidebarGroups) {
24
- const item = group.items.find((i) => i.path === pathname);
25
- if (item) return item.label;
26
- }
54
+ const item = getActiveSidebarItem(pathname, sidebarGroups);
55
+ if (item) return item.label;
27
56
 
28
- if (extraTitleMap?.[pathname]) {
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
- if (item.enabled === false) return false;
69
+ if (item.enabled === false) return false;
41
70
 
42
- if (!item.requiredApp) return true;
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
 
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  formatNotificationTime,
3
3
  isPathActive,
4
+ getActiveSidebarItem,
4
5
  getPageTitle,
5
6
  filterSidebarItems,
6
7
  generateNotificationId,
@@ -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;