@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-dashboard",
3
- "version": "6.5.0",
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.9.0",
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 { DashboardHeaderProps, HeaderProfileItem } from "../types/layout";
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("/login");
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
- <div className="udb-header__anchor">
91
- <Button
92
- variant="ghost"
93
- size="icon"
94
- className="udb-header__icon-btn"
95
- onClick={() => {
96
- setNotifOpen(!notifOpen);
97
- setProfileOpen(false);
98
- }}
99
- >
100
- <Bell className="udb-header__icon" />
101
- {unreadCount > 0 && <span className="udb-header__unread"><span className="udb-header__unread-core" /></span>}
102
- </Button>
103
-
104
- {notifOpen && (
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
- <div className="udb-notif__list">
116
- {notifications.map((n) => (
117
- <div key={n.id} className={`udb-notif__item${!n.read ? " udb-notif__item--unread" : ""}`}>
118
- <div className="udb-notif__body">
119
- <p className="udb-notif__text">{n.text}</p>
120
- <p className="udb-notif__meta">
121
- <span className="udb-notif__dot" />
122
- {formatNotificationTime(n.createdAt, t)}
123
- </p>
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
- {notifications.length === 0 && (
135
- <div className="udb-notif__empty">
136
- <div className="udb-notif__empty-icon">
137
- <Bell className="udb-notif__empty-bell" />
138
- </div>
139
- <p className="udb-notif__empty-text">{t('dashboard.notifications.none')}</p>
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
- </div>
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
- <div className="udb-header__divider" />
149
-
150
- <div className="udb-header__anchor">
151
- <button
152
- type="button"
153
- onClick={() => {
154
- setProfileOpen(!profileOpen);
155
- setNotifOpen(false);
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
- {profileOpen && (
166
- <>
167
- <div className="udb-overlay" onClick={() => setProfileOpen(false)} />
168
- <div className="udb-menu udb-profile-menu">
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
- <button
176
- type="button"
177
- onClick={() => { navigate(profileRoute); setProfileOpen(false); }}
178
- className="udb-menu__item"
179
- >
180
- <User className="udb-menu__item-icon" />
181
- {t('common.profile')}
182
- </button>
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
- type="button"
187
- onClick={() => {
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
- </button>
210
+ </DropdownMenuItem>
197
211
  ))}
198
- <button
199
- type="button"
200
- onClick={() => { navigate(settingsRoute); setProfileOpen(false); }}
201
- className="udb-menu__item"
202
- >
203
- <Settings className="udb-menu__item-icon" />
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
- <div className="udb-profile-menu__separator" />
209
-
210
- <button
211
- type="button"
212
- onClick={handleLogout}
213
- className="udb-menu__item udb-menu__item--danger"
214
- >
215
- <LogOut className="udb-menu__item-icon" />
216
- {t('common.logout')}
217
- </button>
218
- </div>
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(false);
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
- sidebarGroups={config.sidebarGroups}
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
- sidebarGroups={config.sidebarGroups}
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
- {showCreatePost && createPostRoute && (
80
+ {sidebarAction ? (
57
81
  <div className="udb-sidebar__create">
58
- <Link to={createPostRoute}>
59
- <Button
60
- variant="default"
61
- className={cn("udb-sidebar__create-btn", collapsed && "udb-sidebar__create-btn--collapsed")}
62
- title={collapsed ? t('sidebar.createPost') : undefined}
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 = location.pathname === item.path;
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 ? t(item.label) : undefined}
144
+ title={collapsed ? collapsedTitle : undefined}
109
145
  >
110
146
  <item.icon className="udb-sidebar__item-icon" />
111
- {!collapsed && <span>{t(item.label)}</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 {
@@ -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;