@nubitio/admin 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,362 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useLocation, useNavigate } from "react-router-dom";
3
+ import { Badge, IconButton, useFloatingPanel } from "@nubitio/ui";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region packages/admin/AdminHeader.tsx
6
+ function ActionPopover({ action }) {
7
+ const { open, toggle, setOpen, containerRef } = useFloatingPanel();
8
+ if (action.onClick) return /* @__PURE__ */ jsxs("div", {
9
+ className: "nb-admin-messages",
10
+ children: [/* @__PURE__ */ jsx(IconButton, {
11
+ icon: action.icon,
12
+ label: action.label,
13
+ onClick: action.onClick
14
+ }), !!action.badge && action.badge > 0 && /* @__PURE__ */ jsx(Badge, {
15
+ variant: "danger",
16
+ size: "sm",
17
+ pill: true,
18
+ "aria-label": `${action.badge} ${action.label}`,
19
+ children: action.badge > 99 ? "99+" : action.badge
20
+ })]
21
+ });
22
+ return /* @__PURE__ */ jsxs("div", {
23
+ className: "nb-admin-header-popover",
24
+ ref: containerRef,
25
+ children: [/* @__PURE__ */ jsxs("div", {
26
+ className: "nb-admin-messages",
27
+ children: [/* @__PURE__ */ jsx(IconButton, {
28
+ icon: action.icon,
29
+ label: action.label,
30
+ "aria-expanded": open,
31
+ onClick: toggle
32
+ }), !!action.badge && action.badge > 0 && /* @__PURE__ */ jsx(Badge, {
33
+ variant: "danger",
34
+ size: "sm",
35
+ pill: true,
36
+ "aria-label": `${action.badge} ${action.label}`,
37
+ children: action.badge > 99 ? "99+" : action.badge
38
+ })]
39
+ }), open && action.renderPanel && /* @__PURE__ */ jsx("div", {
40
+ className: "nb-admin-header-popover__panel",
41
+ role: "dialog",
42
+ "aria-label": action.label,
43
+ children: action.renderPanel({ close: () => setOpen(false) })
44
+ })]
45
+ });
46
+ }
47
+ function UserMenuPopover({ renderUserMenu }) {
48
+ const { open, toggle, setOpen, containerRef } = useFloatingPanel();
49
+ return /* @__PURE__ */ jsxs("div", {
50
+ className: "nb-admin-header-popover",
51
+ ref: containerRef,
52
+ children: [/* @__PURE__ */ jsx(IconButton, {
53
+ icon: "ph ph-user-circle",
54
+ label: "User menu",
55
+ "aria-expanded": open,
56
+ onClick: toggle
57
+ }), open && /* @__PURE__ */ jsx("div", {
58
+ className: "nb-admin-header-popover__panel",
59
+ role: "dialog",
60
+ "aria-label": "User menu",
61
+ children: renderUserMenu({ close: () => setOpen(false) })
62
+ })]
63
+ });
64
+ }
65
+ const AdminHeader = ({ title, menuToggleEnabled, toggleMenu, className, actions = [], renderUserMenu, renderThemeSwitcher }) => /* @__PURE__ */ jsx("header", {
66
+ className: ["nb-admin-header-component", className].filter(Boolean).join(" "),
67
+ children: /* @__PURE__ */ jsxs("div", {
68
+ className: "nb-admin-header-toolbar",
69
+ role: "toolbar",
70
+ "aria-label": "Main toolbar",
71
+ children: [/* @__PURE__ */ jsxs("div", {
72
+ className: "nb-admin-header-toolbar__before",
73
+ children: [menuToggleEnabled && /* @__PURE__ */ jsx(IconButton, {
74
+ className: "nb-admin-menu-button",
75
+ icon: "ph ph-list",
76
+ label: "Toggle menu",
77
+ onClick: toggleMenu
78
+ }), title && /* @__PURE__ */ jsx("div", {
79
+ className: "nb-admin-header-title",
80
+ children: title
81
+ })]
82
+ }), /* @__PURE__ */ jsxs("div", {
83
+ className: "nb-admin-header-toolbar__after",
84
+ children: [
85
+ renderThemeSwitcher?.(),
86
+ actions.map((action) => /* @__PURE__ */ jsx(ActionPopover, { action }, action.id)),
87
+ renderUserMenu && /* @__PURE__ */ jsx(UserMenuPopover, { renderUserMenu })
88
+ ]
89
+ })]
90
+ })
91
+ });
92
+ //#endregion
93
+ //#region packages/admin/useScreenSize.ts
94
+ let handlers = [];
95
+ const xSmallMedia = window.matchMedia("(max-width: 575.98px)");
96
+ const smallMedia = window.matchMedia("(min-width: 576px) and (max-width: 991.98px)");
97
+ const mediumMedia = window.matchMedia("(min-width: 992px) and (max-width: 1199.98px)");
98
+ const largeMedia = window.matchMedia("(min-width: 1200px)");
99
+ let debounceTimer = null;
100
+ const notifyHandlers = () => {
101
+ if (debounceTimer !== null) clearTimeout(debounceTimer);
102
+ debounceTimer = setTimeout(() => {
103
+ handlers.forEach((handler) => handler());
104
+ }, 50);
105
+ };
106
+ [
107
+ xSmallMedia,
108
+ smallMedia,
109
+ mediumMedia,
110
+ largeMedia
111
+ ].forEach((media) => {
112
+ media.addEventListener("change", (e) => {
113
+ if (e.matches) notifyHandlers();
114
+ });
115
+ });
116
+ const subscribe = (handler) => handlers.push(handler);
117
+ const unsubscribe = (handler) => {
118
+ handlers = handlers.filter((item) => item !== handler);
119
+ };
120
+ function getScreenSize() {
121
+ return {
122
+ isXSmall: xSmallMedia.matches,
123
+ isSmall: smallMedia.matches,
124
+ isMedium: mediumMedia.matches,
125
+ isLarge: largeMedia.matches
126
+ };
127
+ }
128
+ const useScreenSize = () => {
129
+ const [screenSize, setScreenSize] = useState(getScreenSize());
130
+ const onSizeChanged = useCallback(() => setScreenSize(getScreenSize()), []);
131
+ useEffect(() => {
132
+ subscribe(onSizeChanged);
133
+ return () => unsubscribe(onSizeChanged);
134
+ }, [onSizeChanged]);
135
+ return screenSize;
136
+ };
137
+ const useScreenSizeClass = () => {
138
+ const { isLarge, isMedium, isSmall } = useScreenSize();
139
+ if (isLarge) return "screen-large";
140
+ if (isMedium) return "screen-medium";
141
+ if (isSmall) return "screen-small";
142
+ return "screen-x-small";
143
+ };
144
+ //#endregion
145
+ //#region packages/admin/AdminSidebarMenu.tsx
146
+ const normalizeRoute = (path) => {
147
+ if (!path) return "";
148
+ const normalized = path.startsWith("/") ? path : `/${path}`;
149
+ return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized;
150
+ };
151
+ const getIconClassName = (icon) => {
152
+ if (!icon) return "";
153
+ return icon.includes(" ") ? icon : `ph ph-${icon}`;
154
+ };
155
+ const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, openMenu, footer }) => {
156
+ const [expandedItemKeys, setExpandedItemKeys] = useState(/* @__PURE__ */ new Set());
157
+ const { isLarge } = useScreenSize();
158
+ const location = useLocation();
159
+ useEffect(() => {
160
+ setExpandedItemKeys(/* @__PURE__ */ new Set());
161
+ }, [isLarge, items]);
162
+ const activePath = useMemo(() => {
163
+ const flattenPaths = (menuItems) => menuItems.flatMap((item) => [normalizeRoute(item.path), ...item.items?.map((sub) => normalizeRoute(sub.path)) ?? []]).filter(Boolean);
164
+ const requestedPath = normalizeRoute(location.pathname);
165
+ const allPaths = flattenPaths(items);
166
+ return allPaths.find((path) => path === requestedPath) ?? allPaths.filter((path) => requestedPath.startsWith(path) && path !== "/").sort((a, b) => b.length - a.length)[0];
167
+ }, [location.pathname, items]);
168
+ const isSelected = useCallback((path) => normalizeRoute(path) === activePath, [activePath]);
169
+ const parentContainsSelected = useCallback((item) => item.items?.some((sub) => isSelected(sub.path)) ?? false, [isSelected]);
170
+ useEffect(() => {
171
+ const activeParent = items.find((item) => parentContainsSelected(item));
172
+ if (!activeParent || compactMode) return;
173
+ const activeParentKey = activeParent.path || activeParent.text;
174
+ setExpandedItemKeys((prev) => {
175
+ if (prev.has(activeParentKey)) return prev;
176
+ const next = new Set(prev);
177
+ next.add(activeParentKey);
178
+ return next;
179
+ });
180
+ }, [
181
+ compactMode,
182
+ items,
183
+ parentContainsSelected
184
+ ]);
185
+ const toggleExpanded = useCallback((key) => {
186
+ setExpandedItemKeys((prev) => {
187
+ const next = new Set(prev);
188
+ if (next.has(key)) next.delete(key);
189
+ else next.add(key);
190
+ return next;
191
+ });
192
+ }, []);
193
+ const handleSelect = useCallback((path, selected, event) => {
194
+ selectedItemChanged({
195
+ path,
196
+ selected,
197
+ event
198
+ });
199
+ }, [selectedItemChanged]);
200
+ return /* @__PURE__ */ jsxs("div", {
201
+ className: `nb-admin-menu${compactMode ? " compact" : ""}`,
202
+ onPointerDown: openMenu,
203
+ children: [/* @__PURE__ */ jsx("div", {
204
+ className: "nb-admin-menu-container theme-dependent",
205
+ children: /* @__PURE__ */ jsx("nav", {
206
+ className: "nb-admin-menu__nav",
207
+ "aria-label": "Main navigation",
208
+ children: items.map((item) => {
209
+ const selected = isSelected(item.path);
210
+ const containsSelected = parentContainsSelected(item);
211
+ const itemKey = item.path || item.text;
212
+ const hasChildren = (item.items?.length ?? 0) > 0;
213
+ const expanded = !compactMode && hasChildren && expandedItemKeys.has(itemKey);
214
+ return /* @__PURE__ */ jsxs("section", {
215
+ className: `nb-admin-menu__section${containsSelected ? " has-selected-child" : ""}`,
216
+ children: [/* @__PURE__ */ jsxs("button", {
217
+ className: `nb-admin-menu__item nb-admin-menu__item--parent${selected ? " is-selected" : ""}`,
218
+ type: "button",
219
+ "aria-current": selected ? "page" : void 0,
220
+ "aria-expanded": hasChildren ? expanded : void 0,
221
+ onClick: (event) => {
222
+ if (hasChildren) {
223
+ toggleExpanded(itemKey);
224
+ return;
225
+ }
226
+ handleSelect(item.path, selected, event);
227
+ },
228
+ children: [
229
+ /* @__PURE__ */ jsx("span", {
230
+ className: `nb-admin-menu__icon ${getIconClassName(item.icon)}`,
231
+ "aria-hidden": "true"
232
+ }),
233
+ /* @__PURE__ */ jsx("span", {
234
+ className: "nb-admin-menu__text",
235
+ children: item.text
236
+ }),
237
+ hasChildren && /* @__PURE__ */ jsx("span", {
238
+ className: `nb-admin-menu__chevron${expanded ? " is-expanded" : ""}`,
239
+ "aria-hidden": "true"
240
+ })
241
+ ]
242
+ }), expanded && (item.items?.length ?? 0) > 0 && /* @__PURE__ */ jsx("div", {
243
+ className: "nb-admin-menu__children",
244
+ children: item.items.map((subItem) => {
245
+ const childSelected = isSelected(subItem.path);
246
+ return /* @__PURE__ */ jsx("button", {
247
+ className: `nb-admin-menu__item nb-admin-menu__item--child${childSelected ? " is-selected" : ""}`,
248
+ type: "button",
249
+ "aria-current": childSelected ? "page" : void 0,
250
+ onClick: (event) => handleSelect(subItem.path, childSelected, event),
251
+ children: /* @__PURE__ */ jsx("span", {
252
+ className: "nb-admin-menu__text",
253
+ children: subItem.text
254
+ })
255
+ }, subItem.path || subItem.text);
256
+ })
257
+ })]
258
+ }, itemKey);
259
+ })
260
+ })
261
+ }), footer && /* @__PURE__ */ jsx("footer", {
262
+ className: "nb-admin-footer",
263
+ children: footer
264
+ })]
265
+ });
266
+ };
267
+ //#endregion
268
+ //#region packages/admin/AdminShell.tsx
269
+ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThemeSwitcher, footer, children }) => {
270
+ const navigate = useNavigate();
271
+ const { isXSmall, isLarge } = useScreenSize();
272
+ const [menuStatus, setMenuStatus] = useState(null);
273
+ const getDefaultMenuOpenState = useCallback(() => isLarge ? 2 : 1, [isLarge]);
274
+ const getMenuOpenState = useCallback((status) => status === null ? getDefaultMenuOpenState() : status, [getDefaultMenuOpenState]);
275
+ const getMenuStatus = useCallback((status) => status === getDefaultMenuOpenState() ? null : status, [getDefaultMenuOpenState]);
276
+ const changeMenuStatus = useCallback((reducerFn) => {
277
+ setMenuStatus((prev) => getMenuStatus(reducerFn(getMenuOpenState(prev)) ?? prev));
278
+ }, [getMenuOpenState, getMenuStatus]);
279
+ const toggleMenu = useCallback((event) => {
280
+ changeMenuStatus((prev) => prev === 1 ? 2 : 1);
281
+ event.stopPropagation();
282
+ }, [changeMenuStatus]);
283
+ const temporaryOpenMenu = useCallback(() => {
284
+ changeMenuStatus((prev) => prev === 1 ? 3 : null);
285
+ }, [changeMenuStatus]);
286
+ const closeMenuFromOverlay = useCallback(() => {
287
+ changeMenuStatus((prev) => prev !== 1 && !isLarge ? 1 : null);
288
+ }, [isLarge, changeMenuStatus]);
289
+ const onNavigationChanged = useCallback(({ path, event, selected }) => {
290
+ if (getMenuOpenState(menuStatus) === 1 || !path || selected) {
291
+ event?.preventDefault();
292
+ return;
293
+ }
294
+ navigate(path);
295
+ if (!isLarge || menuStatus === 3) {
296
+ setMenuStatus(getMenuStatus(1));
297
+ event?.stopPropagation();
298
+ }
299
+ }, [
300
+ navigate,
301
+ menuStatus,
302
+ isLarge,
303
+ getMenuOpenState,
304
+ getMenuStatus
305
+ ]);
306
+ useEffect(() => {
307
+ changeMenuStatus(() => menuStatus);
308
+ }, [
309
+ isLarge,
310
+ changeMenuStatus,
311
+ menuStatus
312
+ ]);
313
+ const menuOpenState = getMenuOpenState(menuStatus);
314
+ const isMenuOpen = menuOpenState !== 1;
315
+ const isCompact = menuOpenState === 1;
316
+ const bodyClassName = [
317
+ "nb-admin-shell__body nb-admin-layout-body",
318
+ isMenuOpen ? "is-open" : "is-closed",
319
+ isCompact ? "is-compact" : "",
320
+ isLarge ? "is-large" : "is-overlay",
321
+ isXSmall ? "is-xsmall" : ""
322
+ ].filter(Boolean).join(" ");
323
+ return /* @__PURE__ */ jsxs("div", {
324
+ className: "nb-admin-shell",
325
+ children: [/* @__PURE__ */ jsx(AdminHeader, {
326
+ className: "nb-admin-layout-header",
327
+ menuToggleEnabled: true,
328
+ toggleMenu,
329
+ title,
330
+ actions: headerActions,
331
+ renderUserMenu,
332
+ renderThemeSwitcher
333
+ }), /* @__PURE__ */ jsxs("div", {
334
+ className: bodyClassName,
335
+ children: [
336
+ /* @__PURE__ */ jsx("aside", {
337
+ className: "nb-admin-shell__panel",
338
+ "aria-label": "Main menu",
339
+ children: /* @__PURE__ */ jsx(AdminSidebarMenu, {
340
+ items: menuItems,
341
+ compactMode: isCompact,
342
+ selectedItemChanged: onNavigationChanged,
343
+ openMenu: temporaryOpenMenu,
344
+ footer
345
+ })
346
+ }),
347
+ !isLarge && isMenuOpen && /* @__PURE__ */ jsx("button", {
348
+ className: "nb-admin-shell__scrim",
349
+ type: "button",
350
+ "aria-label": "Close menu",
351
+ onClick: closeMenuFromOverlay
352
+ }),
353
+ /* @__PURE__ */ jsx("main", {
354
+ className: "nb-admin-shell__content content",
355
+ children
356
+ })
357
+ ]
358
+ })]
359
+ });
360
+ };
361
+ //#endregion
362
+ export { AdminHeader, AdminShell, AdminSidebarMenu, useScreenSize, useScreenSizeClass };