@umituz/web-dashboard 6.5.0 → 6.6.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 +8 -2
- package/src/domains/layouts/components/DashboardHeader.tsx +132 -121
- package/src/domains/layouts/components/DashboardLayout.tsx +47 -11
- package/src/domains/layouts/components/DashboardSidebar.tsx +59 -19
- package/src/domains/layouts/components/FilterBar.test.tsx +30 -0
- package/src/domains/layouts/components/FilterBar.tsx +42 -0
- package/src/domains/layouts/components/index.ts +2 -0
- package/src/domains/layouts/types/layout.ts +31 -0
- package/src/domains/layouts/types/sidebar.ts +9 -0
- package/src/styles/chrome.css +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-dashboard",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.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",
|
|
@@ -60,8 +60,13 @@
|
|
|
60
60
|
"react-router-dom": "^6.0.0 || ^7.0.0",
|
|
61
61
|
"framer-motion": "^12.0.0"
|
|
62
62
|
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"framer-motion": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
63
68
|
"dependencies": {
|
|
64
|
-
"@umituz/web-design-system": "^4.
|
|
69
|
+
"@umituz/web-design-system": "^4.18.0",
|
|
65
70
|
"lucide-react": "^0.577.0",
|
|
66
71
|
"@umituz/web-localization": "^1.2.1"
|
|
67
72
|
},
|
|
@@ -72,6 +77,7 @@
|
|
|
72
77
|
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
|
73
78
|
"@typescript-eslint/parser": "^8.57.2",
|
|
74
79
|
"eslint": "^10.0.2",
|
|
80
|
+
"framer-motion": "^12.23.12",
|
|
75
81
|
"react-router-dom": "^7.13.1",
|
|
76
82
|
"typescript": "^5.9.3",
|
|
77
83
|
"vitest": "^5.0.1",
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
1
|
import {
|
|
3
2
|
Bell, X, Sun, Moon, Menu, User, Settings, LogOut,
|
|
4
|
-
ChevronDown
|
|
3
|
+
ChevronDown, Search
|
|
5
4
|
} from "lucide-react";
|
|
6
|
-
import { Button } from "@umituz/web-design-system/atoms";
|
|
5
|
+
import { Button, Kbd } from "@umituz/web-design-system/atoms";
|
|
6
|
+
import { Avatar, AvatarImage } from "@umituz/web-design-system/molecules";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem, DropdownMenuSeparator
|
|
10
|
+
} from "@umituz/web-design-system/organisms";
|
|
7
11
|
import { useNavigate } from "react-router-dom";
|
|
8
12
|
import { useT } from "@umituz/web-localization/react";
|
|
9
|
-
import type {
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
import type {
|
|
15
|
+
DashboardHeaderProps, HeaderProfileItem, HeaderSearchAction
|
|
16
|
+
} from "../types/layout";
|
|
10
17
|
import type { DashboardNotification } from "../types/notification";
|
|
11
18
|
import type { DashboardUser } from "../types/user";
|
|
12
19
|
import { formatNotificationTime } from "../utils/dashboard";
|
|
@@ -19,7 +26,17 @@ interface DashboardHeaderPropsExtended extends DashboardHeaderProps {
|
|
|
19
26
|
onDismissNotification?: (id: string) => void;
|
|
20
27
|
settingsRoute?: string;
|
|
21
28
|
profileRoute?: string;
|
|
29
|
+
/** Legacy alias of profileItems (kept for existing consumers). */
|
|
22
30
|
extraProfileItems?: HeaderProfileItem[];
|
|
31
|
+
/** Fully custom profile menu items. */
|
|
32
|
+
profileItems?: HeaderProfileItem[];
|
|
33
|
+
/** Hide the built-in profile/settings rows (defaults to true). */
|
|
34
|
+
showDefaultProfileItems?: boolean;
|
|
35
|
+
searchAction?: HeaderSearchAction;
|
|
36
|
+
headerExtra?: ReactNode;
|
|
37
|
+
notificationsViewAll?: { label: string; to: string };
|
|
38
|
+
/** Route the built-in logout navigates to after onLogout resolves. */
|
|
39
|
+
loginRoute?: string;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
export const DashboardHeader = ({
|
|
@@ -37,22 +54,23 @@ export const DashboardHeader = ({
|
|
|
37
54
|
settingsRoute = "/dashboard/settings",
|
|
38
55
|
profileRoute = "/dashboard/profile",
|
|
39
56
|
extraProfileItems = [],
|
|
57
|
+
profileItems,
|
|
58
|
+
showDefaultProfileItems = true,
|
|
59
|
+
searchAction,
|
|
60
|
+
headerExtra,
|
|
61
|
+
notificationsViewAll,
|
|
62
|
+
loginRoute = "/login",
|
|
40
63
|
}: DashboardHeaderPropsExtended) => {
|
|
41
64
|
const navigate = useNavigate();
|
|
42
65
|
const t = useT();
|
|
43
|
-
const [notifOpen, setNotifOpen] = useState(false);
|
|
44
|
-
const [profileOpen, setProfileOpen] = useState(false);
|
|
45
66
|
|
|
46
67
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
|
47
|
-
|
|
48
|
-
const markAllRead = () => {
|
|
49
|
-
onMarkAllRead?.();
|
|
50
|
-
};
|
|
68
|
+
const customItems = profileItems ?? extraProfileItems;
|
|
51
69
|
|
|
52
70
|
const handleLogout = async () => {
|
|
53
71
|
try {
|
|
54
72
|
await onLogout?.();
|
|
55
|
-
navigate(
|
|
73
|
+
navigate(loginRoute);
|
|
56
74
|
} catch {
|
|
57
75
|
}
|
|
58
76
|
};
|
|
@@ -74,6 +92,16 @@ export const DashboardHeader = ({
|
|
|
74
92
|
</div>
|
|
75
93
|
|
|
76
94
|
<div className="udb-header__actions">
|
|
95
|
+
{headerExtra && <div className="udb-header__extra">{headerExtra}</div>}
|
|
96
|
+
|
|
97
|
+
{searchAction && (
|
|
98
|
+
<button type="button" className="udb-header__search" onClick={searchAction.onActivate}>
|
|
99
|
+
<Search className="udb-header__search-icon" />
|
|
100
|
+
<span className="udb-header__search-text">{searchAction.placeholder}</span>
|
|
101
|
+
{searchAction.shortcut && <Kbd className="udb-header__search-kbd">{searchAction.shortcut}</Kbd>}
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
|
|
77
105
|
{onToggleTheme && (
|
|
78
106
|
<Button
|
|
79
107
|
variant="ghost"
|
|
@@ -87,138 +115,121 @@ export const DashboardHeader = ({
|
|
|
87
115
|
</Button>
|
|
88
116
|
)}
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<>
|
|
106
|
-
<div className="udb-overlay" onClick={() => setNotifOpen(false)} />
|
|
107
|
-
<div className="udb-menu udb-notif">
|
|
108
|
-
<div className="udb-menu__header">
|
|
109
|
-
<h3 className="udb-menu__heading">{t('dashboard.notifications.title')}</h3>
|
|
110
|
-
{unreadCount > 0 && (
|
|
111
|
-
<button type="button" onClick={markAllRead} className="udb-menu__action">{t('dashboard.notifications.markAllRead')}</button>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
118
|
+
{(notifications.length > 0 || onMarkAllRead) && (
|
|
119
|
+
<DropdownMenu>
|
|
120
|
+
<DropdownMenuTrigger asChild>
|
|
121
|
+
<Button variant="ghost" size="icon" className="udb-header__icon-btn" aria-label={t('dashboard.notifications.title')}>
|
|
122
|
+
<Bell className="udb-header__icon" />
|
|
123
|
+
{unreadCount > 0 && <span className="udb-header__unread"><span className="udb-header__unread-core" /></span>}
|
|
124
|
+
</Button>
|
|
125
|
+
</DropdownMenuTrigger>
|
|
126
|
+
<DropdownMenuContent align="end" className="udb-menu udb-menu--pop udb-notif">
|
|
127
|
+
<div className="udb-menu__header">
|
|
128
|
+
<h3 className="udb-menu__heading">{t('dashboard.notifications.title')}</h3>
|
|
129
|
+
{unreadCount > 0 && (
|
|
130
|
+
<button type="button" onClick={onMarkAllRead} className="udb-menu__action">{t('dashboard.notifications.markAllRead')}</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
114
133
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
</div>
|
|
125
|
-
<button
|
|
126
|
-
type="button"
|
|
127
|
-
onClick={() => onDismissNotification?.(n.id)}
|
|
128
|
-
className="udb-notif__dismiss"
|
|
129
|
-
>
|
|
130
|
-
<X className="udb-notif__dismiss-icon" />
|
|
131
|
-
</button>
|
|
134
|
+
<div className="udb-notif__list">
|
|
135
|
+
{notifications.map((n) => (
|
|
136
|
+
<div key={n.id} className={`udb-notif__item${!n.read ? " udb-notif__item--unread" : ""}`}>
|
|
137
|
+
<div className="udb-notif__body">
|
|
138
|
+
<p className="udb-notif__text">{n.text}</p>
|
|
139
|
+
<p className="udb-notif__meta">
|
|
140
|
+
<span className="udb-notif__dot" />
|
|
141
|
+
{formatNotificationTime(n.createdAt, t)}
|
|
142
|
+
</p>
|
|
132
143
|
</div>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => onDismissNotification?.(n.id)}
|
|
147
|
+
className="udb-notif__dismiss"
|
|
148
|
+
aria-label={t('dashboard.notifications.dismiss')}
|
|
149
|
+
>
|
|
150
|
+
<X className="udb-notif__dismiss-icon" />
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
))}
|
|
154
|
+
{notifications.length === 0 && (
|
|
155
|
+
<div className="udb-notif__empty">
|
|
156
|
+
<div className="udb-notif__empty-icon">
|
|
157
|
+
<Bell className="udb-notif__empty-bell" />
|
|
140
158
|
</div>
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
<p className="udb-notif__empty-text">{t('dashboard.notifications.none')}</p>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
143
162
|
</div>
|
|
144
|
-
</>
|
|
145
|
-
)}
|
|
146
|
-
</div>
|
|
147
163
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}}
|
|
157
|
-
className="udb-profile-btn"
|
|
158
|
-
>
|
|
159
|
-
<span className="udb-avatar">
|
|
160
|
-
{user?.avatar && <img src={user.avatar} alt="" className="udb-avatar__img" />}
|
|
161
|
-
</span>
|
|
162
|
-
<ChevronDown className={`udb-profile-btn__chevron${profileOpen ? " udb-profile-btn__chevron--open" : ""}`} />
|
|
163
|
-
</button>
|
|
164
|
+
{notificationsViewAll && (
|
|
165
|
+
<DropdownMenuItem className="udb-menu__item udb-notif__view-all" onSelect={() => navigate(notificationsViewAll.to)}>
|
|
166
|
+
{notificationsViewAll.label}
|
|
167
|
+
</DropdownMenuItem>
|
|
168
|
+
)}
|
|
169
|
+
</DropdownMenuContent>
|
|
170
|
+
</DropdownMenu>
|
|
171
|
+
)}
|
|
164
172
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
{(user || customItems.length > 0 || showDefaultProfileItems) && (
|
|
174
|
+
<>
|
|
175
|
+
<div className="udb-header__divider" />
|
|
176
|
+
|
|
177
|
+
<DropdownMenu>
|
|
178
|
+
<DropdownMenuTrigger asChild>
|
|
179
|
+
<button type="button" className="udb-profile-btn" aria-label={t("common.profile")}>
|
|
180
|
+
<Avatar name={user?.name} size="sm" className="udb-avatar">
|
|
181
|
+
{user?.avatar && <AvatarImage src={user.avatar} alt="" />}
|
|
182
|
+
</Avatar>
|
|
183
|
+
<ChevronDown className="udb-profile-btn__chevron" />
|
|
184
|
+
</button>
|
|
185
|
+
</DropdownMenuTrigger>
|
|
186
|
+
<DropdownMenuContent align="end" className="udb-menu udb-menu--pop udb-profile-menu">
|
|
169
187
|
<div className="udb-profile-menu__id">
|
|
170
188
|
<p className="udb-profile-menu__name">{user?.name || t("common.roles.user")}</p>
|
|
171
189
|
<p className="udb-profile-menu__email">{user?.email}</p>
|
|
172
190
|
</div>
|
|
173
191
|
|
|
174
192
|
<div className="udb-profile-menu__items">
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{extraProfileItems.map((item) => (
|
|
184
|
-
<button
|
|
193
|
+
{showDefaultProfileItems && (
|
|
194
|
+
<DropdownMenuItem className="udb-menu__item" onSelect={() => navigate(profileRoute)}>
|
|
195
|
+
<User className="udb-menu__item-icon" />
|
|
196
|
+
{t('common.profile')}
|
|
197
|
+
</DropdownMenuItem>
|
|
198
|
+
)}
|
|
199
|
+
{customItems.map((item) => (
|
|
200
|
+
<DropdownMenuItem
|
|
185
201
|
key={item.key}
|
|
186
|
-
|
|
187
|
-
|
|
202
|
+
className={item.danger ? "udb-menu__item udb-menu__item--danger" : "udb-menu__item"}
|
|
203
|
+
onSelect={() => {
|
|
188
204
|
if (item.to) navigate(item.to);
|
|
189
205
|
item.onSelect?.();
|
|
190
|
-
setProfileOpen(false);
|
|
191
206
|
}}
|
|
192
|
-
className="udb-menu__item"
|
|
193
207
|
>
|
|
194
208
|
{item.icon}
|
|
195
209
|
{item.label}
|
|
196
|
-
</
|
|
210
|
+
</DropdownMenuItem>
|
|
197
211
|
))}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{t('common.settings')}
|
|
205
|
-
</button>
|
|
212
|
+
{showDefaultProfileItems && (
|
|
213
|
+
<DropdownMenuItem className="udb-menu__item" onSelect={() => navigate(settingsRoute)}>
|
|
214
|
+
<Settings className="udb-menu__item-icon" />
|
|
215
|
+
{t('common.settings')}
|
|
216
|
+
</DropdownMenuItem>
|
|
217
|
+
)}
|
|
206
218
|
</div>
|
|
207
219
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
</div>
|
|
220
|
+
{onLogout && (
|
|
221
|
+
<>
|
|
222
|
+
<DropdownMenuSeparator className="udb-profile-menu__separator" />
|
|
223
|
+
<DropdownMenuItem className="udb-menu__item udb-menu__item--danger" onSelect={() => void handleLogout()}>
|
|
224
|
+
<LogOut className="udb-menu__item-icon" />
|
|
225
|
+
{t('common.logout')}
|
|
226
|
+
</DropdownMenuItem>
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
</DropdownMenuContent>
|
|
230
|
+
</DropdownMenu>
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
222
233
|
</div>
|
|
223
234
|
</header>
|
|
224
235
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
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";
|
|
@@ -21,8 +21,21 @@ interface DashboardLayoutProps {
|
|
|
21
21
|
theme?: "light" | "dark";
|
|
22
22
|
onToggleTheme?: () => void;
|
|
23
23
|
routeTransitionMs?: number;
|
|
24
|
+
/** Persists the sidebar collapse state under this localStorage key ('1'/'0'). */
|
|
25
|
+
collapsedStorageKey?: string;
|
|
26
|
+
/** Optional page footer rendered under the outlet column. */
|
|
27
|
+
footer?: ReactNode;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
const readCollapsed = (key?: string): boolean => {
|
|
31
|
+
if (!key || typeof window === "undefined") return false;
|
|
32
|
+
try {
|
|
33
|
+
return window.localStorage.getItem(key) === "1";
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
26
39
|
export const DashboardLayout = ({
|
|
27
40
|
config,
|
|
28
41
|
user,
|
|
@@ -36,13 +49,24 @@ export const DashboardLayout = ({
|
|
|
36
49
|
theme,
|
|
37
50
|
onToggleTheme,
|
|
38
51
|
routeTransitionMs = 0,
|
|
52
|
+
collapsedStorageKey,
|
|
53
|
+
footer,
|
|
39
54
|
}: DashboardLayoutProps) => {
|
|
40
55
|
const location = useLocation();
|
|
41
56
|
const t = useT();
|
|
42
|
-
const [collapsed, setCollapsed] = useState(
|
|
57
|
+
const [collapsed, setCollapsed] = useState(() => readCollapsed(collapsedStorageKey));
|
|
43
58
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
44
59
|
const [loading, setLoading] = useState(routeTransitionMs > 0);
|
|
45
60
|
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!collapsedStorageKey || typeof window === "undefined") return;
|
|
63
|
+
try {
|
|
64
|
+
window.localStorage.setItem(collapsedStorageKey, collapsed ? "1" : "0");
|
|
65
|
+
} catch {
|
|
66
|
+
/* storage unavailable — state stays in-memory only */
|
|
67
|
+
}
|
|
68
|
+
}, [collapsed, collapsedStorageKey]);
|
|
69
|
+
|
|
46
70
|
useEffect(() => {
|
|
47
71
|
if (routeTransitionMs <= 0) {
|
|
48
72
|
setLoading(false);
|
|
@@ -65,7 +89,7 @@ export const DashboardLayout = ({
|
|
|
65
89
|
.find((i) => i.path === location.pathname);
|
|
66
90
|
|
|
67
91
|
const getTitle = () => {
|
|
68
|
-
if (activeItem) return t(activeItem.label);
|
|
92
|
+
if (activeItem) return activeItem.raw ? activeItem.label : t(activeItem.label);
|
|
69
93
|
const extraTitle = config.extraTitleMap?.[location.pathname];
|
|
70
94
|
if (extraTitle) return t(extraTitle);
|
|
71
95
|
return config.defaultTitle ?? "Dashboard";
|
|
@@ -73,6 +97,16 @@ export const DashboardLayout = ({
|
|
|
73
97
|
|
|
74
98
|
const currentTitle = getTitle();
|
|
75
99
|
|
|
100
|
+
const sidebarProps = {
|
|
101
|
+
sidebarGroups: config.sidebarGroups,
|
|
102
|
+
brandName: config.brandName,
|
|
103
|
+
brandTagline: config.brandTagline,
|
|
104
|
+
brand: config.brand,
|
|
105
|
+
sidebarAction: config.sidebarAction,
|
|
106
|
+
sidebarFooter: config.sidebarFooter,
|
|
107
|
+
user,
|
|
108
|
+
};
|
|
109
|
+
|
|
76
110
|
return (
|
|
77
111
|
<div className="udb-root udb-layout">
|
|
78
112
|
<aside
|
|
@@ -81,10 +115,7 @@ export const DashboardLayout = ({
|
|
|
81
115
|
<DashboardSidebar
|
|
82
116
|
collapsed={collapsed}
|
|
83
117
|
setCollapsed={setCollapsed}
|
|
84
|
-
|
|
85
|
-
brandName={config.brandName}
|
|
86
|
-
brandTagline={config.brandTagline}
|
|
87
|
-
user={user}
|
|
118
|
+
{...sidebarProps}
|
|
88
119
|
/>
|
|
89
120
|
</aside>
|
|
90
121
|
|
|
@@ -95,10 +126,7 @@ export const DashboardLayout = ({
|
|
|
95
126
|
<DashboardSidebar
|
|
96
127
|
collapsed={false}
|
|
97
128
|
setCollapsed={() => setMobileOpen(false)}
|
|
98
|
-
|
|
99
|
-
brandName={config.brandName}
|
|
100
|
-
brandTagline={config.brandTagline}
|
|
101
|
-
user={user}
|
|
129
|
+
{...sidebarProps}
|
|
102
130
|
/>
|
|
103
131
|
</aside>
|
|
104
132
|
</div>
|
|
@@ -117,6 +145,12 @@ export const DashboardLayout = ({
|
|
|
117
145
|
onLogout={onLogout}
|
|
118
146
|
onMarkAllRead={onMarkAllRead}
|
|
119
147
|
onDismissNotification={onDismissNotification}
|
|
148
|
+
loginRoute={loginRoute}
|
|
149
|
+
searchAction={config.searchAction}
|
|
150
|
+
headerExtra={config.headerExtra}
|
|
151
|
+
profileItems={config.profileItems}
|
|
152
|
+
showDefaultProfileItems={config.showDefaultProfileItems}
|
|
153
|
+
notificationsViewAll={config.notificationsViewAll}
|
|
120
154
|
/>
|
|
121
155
|
|
|
122
156
|
<main className="udb-layout__content">
|
|
@@ -134,6 +168,8 @@ export const DashboardLayout = ({
|
|
|
134
168
|
<Outlet />
|
|
135
169
|
)}
|
|
136
170
|
</main>
|
|
171
|
+
|
|
172
|
+
{footer && <footer className="udb-layout__footer">{footer}</footer>}
|
|
137
173
|
</div>
|
|
138
174
|
</div>
|
|
139
175
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Link, useLocation } from "react-router-dom";
|
|
3
3
|
import { useT } from "@umituz/web-localization/react";
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ 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
8
|
import { PenTool, Menu, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react";
|
|
9
|
-
import type { DashboardSidebarProps } from "../types/layout";
|
|
9
|
+
import type { DashboardSidebarProps, SidebarAction } from "../types/layout";
|
|
10
10
|
import type { DashboardUser } from "../types/user";
|
|
11
11
|
import type { SidebarGroup } from "../types/sidebar";
|
|
12
12
|
import { filterSidebarItems } from "../utils/dashboard";
|
|
@@ -15,6 +15,12 @@ interface DashboardSidebarPropsExtended extends DashboardSidebarProps {
|
|
|
15
15
|
sidebarGroups: SidebarGroup[];
|
|
16
16
|
brandName?: string;
|
|
17
17
|
brandTagline?: string;
|
|
18
|
+
/** Custom brand mark — replaces the built-in logo (text block still follows brandName). */
|
|
19
|
+
brand?: ReactNode;
|
|
20
|
+
/** Generic primary action — replaces the legacy create-post button. */
|
|
21
|
+
sidebarAction?: SidebarAction;
|
|
22
|
+
/** Extra node above the collapse button in the footer (hidden while collapsed). */
|
|
23
|
+
sidebarFooter?: ReactNode;
|
|
18
24
|
createPostRoute?: string;
|
|
19
25
|
showCreatePost?: boolean;
|
|
20
26
|
user?: DashboardUser;
|
|
@@ -26,6 +32,9 @@ export const DashboardSidebar = ({
|
|
|
26
32
|
sidebarGroups,
|
|
27
33
|
brandName = "App",
|
|
28
34
|
brandTagline = "grow smarter",
|
|
35
|
+
brand,
|
|
36
|
+
sidebarAction,
|
|
37
|
+
sidebarFooter,
|
|
29
38
|
createPostRoute = "/dashboard/create",
|
|
30
39
|
showCreatePost = true,
|
|
31
40
|
user,
|
|
@@ -41,31 +50,51 @@ export const DashboardSidebar = ({
|
|
|
41
50
|
}));
|
|
42
51
|
};
|
|
43
52
|
|
|
53
|
+
const renderAction = (label: string, icon: ReactNode, linkProps?: { to: string }) => {
|
|
54
|
+
const button = (
|
|
55
|
+
<Button
|
|
56
|
+
variant="default"
|
|
57
|
+
className={cn("udb-sidebar__create-btn", collapsed && "udb-sidebar__create-btn--collapsed")}
|
|
58
|
+
title={collapsed ? label : undefined}
|
|
59
|
+
{...(linkProps ? {} : { onClick: () => sidebarAction?.onClick?.() })}
|
|
60
|
+
>
|
|
61
|
+
{icon}
|
|
62
|
+
{!collapsed && <span className="udb-sidebar__create-label">{label}</span>}
|
|
63
|
+
</Button>
|
|
64
|
+
);
|
|
65
|
+
return linkProps ? <Link to={linkProps.to}>{button}</Link> : button;
|
|
66
|
+
};
|
|
67
|
+
|
|
44
68
|
return (
|
|
45
69
|
<div className={cn("udb-sidebar", collapsed && "udb-sidebar--collapsed")}>
|
|
46
70
|
<div className="udb-sidebar__brand">
|
|
47
|
-
<BrandLogo size={32} />
|
|
48
|
-
{!collapsed && (
|
|
71
|
+
{brand ?? <BrandLogo size={32} />}
|
|
72
|
+
{!collapsed && brandName !== "" && (
|
|
49
73
|
<div className="udb-sidebar__brand-text">
|
|
50
|
-
<span className="udb-sidebar__brand-name">{brandName}</span>
|
|
74
|
+
<span className="udb-sidebar__brand-name">{brandName || "App"}</span>
|
|
51
75
|
<span className="udb-sidebar__brand-tagline">{brandTagline}</span>
|
|
52
76
|
</div>
|
|
53
77
|
)}
|
|
54
78
|
</div>
|
|
55
79
|
|
|
56
|
-
{
|
|
80
|
+
{sidebarAction ? (
|
|
57
81
|
<div className="udb-sidebar__create">
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
>
|
|
64
|
-
<PenTool className="udb-sidebar__create-icon" />
|
|
65
|
-
{!collapsed && <span className="udb-sidebar__create-label">{t('sidebar.createPost')}</span>}
|
|
66
|
-
</Button>
|
|
67
|
-
</Link>
|
|
82
|
+
{renderAction(
|
|
83
|
+
sidebarAction.label,
|
|
84
|
+
sidebarAction.icon ? <sidebarAction.icon className="udb-sidebar__create-icon" /> : null,
|
|
85
|
+
sidebarAction.to ? { to: sidebarAction.to } : undefined
|
|
86
|
+
)}
|
|
68
87
|
</div>
|
|
88
|
+
) : (
|
|
89
|
+
showCreatePost && createPostRoute && (
|
|
90
|
+
<div className="udb-sidebar__create">
|
|
91
|
+
{renderAction(
|
|
92
|
+
t('sidebar.createPost'),
|
|
93
|
+
<PenTool className="udb-sidebar__create-icon" />,
|
|
94
|
+
{ to: createPostRoute }
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
69
98
|
)}
|
|
70
99
|
|
|
71
100
|
<nav className="udb-sidebar__nav">
|
|
@@ -99,16 +128,26 @@ export const DashboardSidebar = ({
|
|
|
99
128
|
)}
|
|
100
129
|
|
|
101
130
|
{(!isGroupCollapsed || collapsed) && filteredItems.map((item) => {
|
|
102
|
-
const active =
|
|
131
|
+
const active =
|
|
132
|
+
location.pathname === item.path ||
|
|
133
|
+
(item.end === false && location.pathname.startsWith(item.path + "/"));
|
|
134
|
+
const labelText = item.raw ? item.label : t(item.label);
|
|
135
|
+
const collapsedTitle =
|
|
136
|
+
item.badge !== undefined && item.badge !== ""
|
|
137
|
+
? `${labelText} (${item.badge})`
|
|
138
|
+
: labelText;
|
|
103
139
|
return (
|
|
104
140
|
<Link
|
|
105
141
|
key={item.path}
|
|
106
142
|
to={item.path}
|
|
107
143
|
className={cn("udb-sidebar__item", active && "udb-sidebar__item--active")}
|
|
108
|
-
title={collapsed ?
|
|
144
|
+
title={collapsed ? collapsedTitle : undefined}
|
|
109
145
|
>
|
|
110
146
|
<item.icon className="udb-sidebar__item-icon" />
|
|
111
|
-
{!collapsed && <span>{
|
|
147
|
+
{!collapsed && <span>{labelText}</span>}
|
|
148
|
+
{!collapsed && item.badge !== undefined && item.badge !== "" && (
|
|
149
|
+
<span className="udb-sidebar__item-badge">{item.badge}</span>
|
|
150
|
+
)}
|
|
112
151
|
</Link>
|
|
113
152
|
);
|
|
114
153
|
})}
|
|
@@ -119,6 +158,7 @@ export const DashboardSidebar = ({
|
|
|
119
158
|
</nav>
|
|
120
159
|
|
|
121
160
|
<div className="udb-sidebar__footer">
|
|
161
|
+
{sidebarFooter && <div className="udb-sidebar__footer-slot">{sidebarFooter}</div>}
|
|
122
162
|
{!collapsed && (
|
|
123
163
|
<p className="udb-sidebar__footer-label">
|
|
124
164
|
{t('sidebar.system')}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { FilterBar } from "./FilterBar";
|
|
4
|
+
|
|
5
|
+
describe("FilterBar", () => {
|
|
6
|
+
it("renders search, children and actions in their slots", () => {
|
|
7
|
+
const html = renderToString(
|
|
8
|
+
<FilterBar
|
|
9
|
+
search={{ value: "", onChange: () => {}, placeholder: "Find…" }}
|
|
10
|
+
actions={<button type="button">Sort</button>}
|
|
11
|
+
>
|
|
12
|
+
<span>a chip</span>
|
|
13
|
+
</FilterBar>
|
|
14
|
+
);
|
|
15
|
+
expect(html).toContain("udb-filter-bar");
|
|
16
|
+
expect(html).toContain("udb-filter-bar__search");
|
|
17
|
+
expect(html).toContain("udb-filter-bar__fields");
|
|
18
|
+
expect(html).toContain("a chip");
|
|
19
|
+
expect(html).toContain("udb-filter-bar__actions");
|
|
20
|
+
expect(html).toContain("Sort");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("omits absent slots entirely", () => {
|
|
24
|
+
const html = renderToString(<FilterBar />);
|
|
25
|
+
expect(html).toContain("udb-filter-bar");
|
|
26
|
+
expect(html).not.toContain("udb-filter-bar__search");
|
|
27
|
+
expect(html).not.toContain("udb-filter-bar__fields");
|
|
28
|
+
expect(html).not.toContain("udb-filter-bar__actions");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterBar Component (Layout)
|
|
3
|
+
* @description Horizontal filter strip: search box + chip row + trailing actions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { SearchBox } from "@umituz/web-design-system/molecules";
|
|
8
|
+
import { cn } from "@umituz/web-design-system/utils/cn";
|
|
9
|
+
import type { BaseProps } from "@umituz/web-design-system/types";
|
|
10
|
+
|
|
11
|
+
export interface FilterBarSearch {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FilterBarProps extends BaseProps {
|
|
18
|
+
search?: FilterBarSearch;
|
|
19
|
+
/** Chip row / selects — anything that filters the page content. */
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
/** Trailing controls (sort, primary buttons…). */
|
|
22
|
+
actions?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const FilterBar = ({ search, children, actions, className, ...props }: FilterBarProps) => {
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn("udb-filter-bar", className)} {...props}>
|
|
28
|
+
{search && (
|
|
29
|
+
<SearchBox
|
|
30
|
+
value={search.value}
|
|
31
|
+
onChange={search.onChange}
|
|
32
|
+
placeholder={search.placeholder}
|
|
33
|
+
className="udb-filter-bar__search"
|
|
34
|
+
/>
|
|
35
|
+
)}
|
|
36
|
+
{children && <div className="udb-filter-bar__fields">{children}</div>}
|
|
37
|
+
{actions && <div className="udb-filter-bar__actions">{actions}</div>}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
FilterBar.displayName = "FilterBar";
|
|
@@ -2,6 +2,8 @@ export { DashboardLayout } from './DashboardLayout';
|
|
|
2
2
|
export { DashboardHeader } from './DashboardHeader';
|
|
3
3
|
export { DashboardSidebar } from './DashboardSidebar';
|
|
4
4
|
export { BrandLogo } from './BrandLogo';
|
|
5
|
+
export { FilterBar } from './FilterBar';
|
|
6
|
+
export type { FilterBarProps, FilterBarSearch } from './FilterBar';
|
|
5
7
|
export { PageHeader } from './PageHeader';
|
|
6
8
|
export type { PageHeaderProps, PageHeaderCrumb } from './PageHeader';
|
|
7
9
|
export { SectionHeader } from './SectionHeader';
|
|
@@ -13,6 +13,23 @@ export interface HeaderProfileItem {
|
|
|
13
13
|
icon?: React.ReactNode;
|
|
14
14
|
to?: string;
|
|
15
15
|
onSelect?: () => void;
|
|
16
|
+
/** Renders the item in destructive color (sign-out, delete…). */
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Optional primary action under the sidebar brand (replaces the legacy create-post button). */
|
|
21
|
+
export interface SidebarAction {
|
|
22
|
+
label: string;
|
|
23
|
+
icon?: import("lucide-react").LucideIcon;
|
|
24
|
+
to?: string;
|
|
25
|
+
onClick?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Header search affordance — a button styled as a search field, e.g. routing to a search page. */
|
|
29
|
+
export interface HeaderSearchAction {
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
shortcut?: string;
|
|
32
|
+
onActivate: () => void;
|
|
16
33
|
}
|
|
17
34
|
|
|
18
35
|
export interface DashboardSidebarProps {
|
|
@@ -26,4 +43,18 @@ export interface DashboardLayoutConfig {
|
|
|
26
43
|
defaultTitle?: string;
|
|
27
44
|
brandName?: string;
|
|
28
45
|
brandTagline?: string;
|
|
46
|
+
/** Custom brand mark for the sidebar — replaces the built-in logo; pair with brandName for the text block. */
|
|
47
|
+
brand?: React.ReactNode;
|
|
48
|
+
sidebarAction?: SidebarAction;
|
|
49
|
+
/** Extra node rendered in the sidebar footer (above the collapse button; hidden when collapsed). */
|
|
50
|
+
sidebarFooter?: React.ReactNode;
|
|
51
|
+
/** Extra node rendered in the header actions area (app chips, clocks…). */
|
|
52
|
+
headerExtra?: React.ReactNode;
|
|
53
|
+
searchAction?: HeaderSearchAction;
|
|
54
|
+
/** Fully custom profile menu items — shown in addition to (or, with showDefaultProfileItems=false, instead of) the defaults. */
|
|
55
|
+
profileItems?: HeaderProfileItem[];
|
|
56
|
+
/** Hide the built-in profile/settings items (defaults to true). */
|
|
57
|
+
showDefaultProfileItems?: boolean;
|
|
58
|
+
/** "View all" row appended to the notifications dropdown. */
|
|
59
|
+
notificationsViewAll?: { label: string; to: string };
|
|
29
60
|
}
|
|
@@ -6,6 +6,15 @@ export interface SidebarItem {
|
|
|
6
6
|
path: string;
|
|
7
7
|
requiredApp?: 'mobile' | 'web';
|
|
8
8
|
enabled?: boolean;
|
|
9
|
+
/** Count pill rendered next to the label (hidden while the sidebar is collapsed). */
|
|
10
|
+
badge?: string | number;
|
|
11
|
+
/**
|
|
12
|
+
* Active-route matching: true (default) matches the exact path only;
|
|
13
|
+
* false also marks prefix descendants active (e.g. /app on /app/vocab).
|
|
14
|
+
*/
|
|
15
|
+
end?: boolean;
|
|
16
|
+
/** Render the label verbatim — skip the localization humanizer (final display strings). */
|
|
17
|
+
raw?: boolean;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
export interface SidebarGroup {
|
package/src/styles/chrome.css
CHANGED
|
@@ -458,6 +458,99 @@
|
|
|
458
458
|
z-index: 50;
|
|
459
459
|
animation: udb-pop 0.18s ease;
|
|
460
460
|
}
|
|
461
|
+
/* Radix-portal variant: positioning/animation come from the dropdown primitive. */
|
|
462
|
+
.udb-menu--pop {
|
|
463
|
+
top: auto;
|
|
464
|
+
right: auto;
|
|
465
|
+
animation: none;
|
|
466
|
+
}
|
|
467
|
+
.udb-sidebar__item-badge {
|
|
468
|
+
margin-left: auto;
|
|
469
|
+
padding: 1px var(--udb-space-2);
|
|
470
|
+
border-radius: 999px;
|
|
471
|
+
font-size: 10px;
|
|
472
|
+
font-weight: var(--font-weight-bold);
|
|
473
|
+
line-height: 1.4;
|
|
474
|
+
color: var(--udb-sidebar-accent-text);
|
|
475
|
+
background: var(--udb-sidebar-accent);
|
|
476
|
+
}
|
|
477
|
+
.udb-sidebar__footer-slot {
|
|
478
|
+
flex: 1;
|
|
479
|
+
min-width: 0;
|
|
480
|
+
}
|
|
481
|
+
.udb-sidebar--collapsed .udb-sidebar__footer-slot {
|
|
482
|
+
display: none;
|
|
483
|
+
}
|
|
484
|
+
.udb-header__extra {
|
|
485
|
+
display: flex;
|
|
486
|
+
align-items: center;
|
|
487
|
+
gap: var(--udb-space-2);
|
|
488
|
+
}
|
|
489
|
+
.udb-header__search {
|
|
490
|
+
display: inline-flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: var(--udb-space-2);
|
|
493
|
+
height: 34px;
|
|
494
|
+
padding: 0 var(--udb-space-3);
|
|
495
|
+
border: 1px solid var(--color-border);
|
|
496
|
+
border-radius: var(--radius-md);
|
|
497
|
+
background: var(--color-background);
|
|
498
|
+
color: var(--color-muted-foreground);
|
|
499
|
+
font: inherit;
|
|
500
|
+
cursor: pointer;
|
|
501
|
+
transition: border-color 0.2s ease, color 0.2s ease;
|
|
502
|
+
}
|
|
503
|
+
.udb-header__search:hover {
|
|
504
|
+
border-color: var(--color-primary);
|
|
505
|
+
color: var(--color-foreground);
|
|
506
|
+
}
|
|
507
|
+
.udb-header__search-icon {
|
|
508
|
+
width: 15px;
|
|
509
|
+
height: 15px;
|
|
510
|
+
flex-shrink: 0;
|
|
511
|
+
}
|
|
512
|
+
.udb-header__search-text {
|
|
513
|
+
font-size: var(--udb-font-size-sm);
|
|
514
|
+
white-space: nowrap;
|
|
515
|
+
}
|
|
516
|
+
.udb-header__search-kbd {
|
|
517
|
+
margin-left: var(--udb-space-2);
|
|
518
|
+
}
|
|
519
|
+
.udb-profile-btn[data-state="open"] .udb-profile-btn__chevron {
|
|
520
|
+
transform: rotate(180deg);
|
|
521
|
+
}
|
|
522
|
+
.udb-profile-btn__chevron {
|
|
523
|
+
transition: transform 0.15s ease;
|
|
524
|
+
}
|
|
525
|
+
.udb-layout__footer {
|
|
526
|
+
flex-shrink: 0;
|
|
527
|
+
padding: var(--udb-space-4);
|
|
528
|
+
border-top: 1px solid var(--color-border);
|
|
529
|
+
color: var(--color-muted-foreground);
|
|
530
|
+
font-size: var(--udb-font-size-sm);
|
|
531
|
+
}
|
|
532
|
+
.udb-filter-bar {
|
|
533
|
+
display: flex;
|
|
534
|
+
flex-wrap: wrap;
|
|
535
|
+
align-items: flex-end;
|
|
536
|
+
gap: var(--udb-space-3);
|
|
537
|
+
}
|
|
538
|
+
.udb-filter-bar__search {
|
|
539
|
+
min-width: 200px;
|
|
540
|
+
flex: 0 1 240px;
|
|
541
|
+
}
|
|
542
|
+
.udb-filter-bar__fields {
|
|
543
|
+
display: flex;
|
|
544
|
+
flex-wrap: wrap;
|
|
545
|
+
align-items: center;
|
|
546
|
+
gap: var(--udb-space-2);
|
|
547
|
+
flex: 1;
|
|
548
|
+
}
|
|
549
|
+
.udb-filter-bar__actions {
|
|
550
|
+
display: flex;
|
|
551
|
+
align-items: center;
|
|
552
|
+
gap: var(--udb-space-2);
|
|
553
|
+
}
|
|
461
554
|
@keyframes udb-pop {
|
|
462
555
|
from {
|
|
463
556
|
opacity: 0;
|