@pablozaiden/webapp 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/web/WebAppRoot.tsx +91 -9
- package/src/web/styles.css +6 -3
package/package.json
CHANGED
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -70,6 +70,20 @@ type StoredSidebarPin = {
|
|
|
70
70
|
route: WebAppRoute;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
type SidebarCollapsedState = Record<string, boolean>;
|
|
74
|
+
|
|
75
|
+
function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
|
|
76
|
+
if (!(target instanceof HTMLElement)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (target.isContentEditable || target.closest("[contenteditable=''], [contenteditable='true']")) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return target instanceof HTMLInputElement
|
|
83
|
+
|| target instanceof HTMLTextAreaElement
|
|
84
|
+
|| target instanceof HTMLSelectElement;
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
function routeToHash(route: WebAppRoute): string {
|
|
74
88
|
const params = new URLSearchParams();
|
|
75
89
|
for (const [key, value] of Object.entries(route)) {
|
|
@@ -239,6 +253,17 @@ function pinStorageKey(appName: string, explicitKey?: string): string {
|
|
|
239
253
|
return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
|
|
240
254
|
}
|
|
241
255
|
|
|
256
|
+
function sidebarCollapsedStorageKey(appName: string): string {
|
|
257
|
+
return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
|
|
261
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
return Object.values(value).every((entry) => typeof entry === "boolean");
|
|
265
|
+
}
|
|
266
|
+
|
|
242
267
|
function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
|
|
243
268
|
if (!node.route) return undefined;
|
|
244
269
|
return {
|
|
@@ -279,6 +304,33 @@ function useSidebarPins(appName: string, storageKey?: string) {
|
|
|
279
304
|
return { pins, pinIds, pin, unpin };
|
|
280
305
|
}
|
|
281
306
|
|
|
307
|
+
function useSidebarCollapsedState(appName: string) {
|
|
308
|
+
const key = sidebarCollapsedStorageKey(appName);
|
|
309
|
+
const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
|
|
310
|
+
try {
|
|
311
|
+
const raw = localStorage.getItem(key);
|
|
312
|
+
if (!raw) return {};
|
|
313
|
+
const parsed: unknown = JSON.parse(raw);
|
|
314
|
+
return isSidebarCollapsedState(parsed) ? parsed : {};
|
|
315
|
+
} catch {
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
localStorage.setItem(key, JSON.stringify(collapsed));
|
|
322
|
+
}, [key, collapsed]);
|
|
323
|
+
|
|
324
|
+
const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
|
|
325
|
+
setCollapsed((current) => {
|
|
326
|
+
const currentIsCollapsed = current[id] ?? isCollapsed;
|
|
327
|
+
return { ...current, [id]: !currentIsCollapsed };
|
|
328
|
+
});
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
return { collapsed, toggleCollapsed };
|
|
332
|
+
}
|
|
333
|
+
|
|
282
334
|
function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
|
|
283
335
|
const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
|
|
284
336
|
if (name === "settings") return <svg {...common}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82V2a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.33.34.64.6 1 .26.36.61.6 1 .6H22a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-2.51.4Z" /></svg>;
|
|
@@ -488,8 +540,7 @@ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): {
|
|
|
488
540
|
return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
|
|
489
541
|
}
|
|
490
542
|
|
|
491
|
-
function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
|
|
492
|
-
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
543
|
+
function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; collapsed: SidebarCollapsedState; toggleCollapsed: (id: string, isCollapsed: boolean) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
|
|
493
544
|
const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
|
|
494
545
|
return (
|
|
495
546
|
<>
|
|
@@ -501,7 +552,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
|
|
|
501
552
|
<section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
|
|
502
553
|
<div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
|
|
503
554
|
{hasChildren ? (
|
|
504
|
-
<button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() =>
|
|
555
|
+
<button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>
|
|
505
556
|
<span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
|
|
506
557
|
</button>
|
|
507
558
|
) : (
|
|
@@ -509,7 +560,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
|
|
|
509
560
|
)}
|
|
510
561
|
{node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
|
|
511
562
|
</div>
|
|
512
|
-
{!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} level={level + 1} parentKind="section" /> : null}
|
|
563
|
+
{!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="section" /> : null}
|
|
513
564
|
{!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
|
|
514
565
|
</section>
|
|
515
566
|
);
|
|
@@ -517,7 +568,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
|
|
|
517
568
|
const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
|
|
518
569
|
return (
|
|
519
570
|
<div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
|
|
520
|
-
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() =>
|
|
571
|
+
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>{isCollapsed ? "▶" : "▼"}</button> : null}
|
|
521
572
|
<button
|
|
522
573
|
type="button"
|
|
523
574
|
className={`wapp-sidebar-item ${active ? "active" : ""}`}
|
|
@@ -534,7 +585,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
|
|
|
534
585
|
</span>
|
|
535
586
|
{node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
|
|
536
587
|
</button>
|
|
537
|
-
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} parentKind="item" /></div> : null}
|
|
588
|
+
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="item" /></div> : null}
|
|
538
589
|
</div>
|
|
539
590
|
);
|
|
540
591
|
})}
|
|
@@ -980,6 +1031,14 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
980
1031
|
const [search, setSearch] = useState("");
|
|
981
1032
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
982
1033
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
1034
|
+
const sidebarTreeState = useSidebarCollapsedState(appName);
|
|
1035
|
+
const toggleSidebarCollapsed = useCallback(() => {
|
|
1036
|
+
setSidebarCollapsed((current) => {
|
|
1037
|
+
const nextCollapsed = !current;
|
|
1038
|
+
setSidebarOpen(!nextCollapsed);
|
|
1039
|
+
return nextCollapsed;
|
|
1040
|
+
});
|
|
1041
|
+
}, []);
|
|
983
1042
|
const sidebarSearchEnabled = sidebar.search !== false;
|
|
984
1043
|
const pinningEnabled = sidebar.pinning !== false;
|
|
985
1044
|
const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
|
|
@@ -1041,6 +1100,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1041
1100
|
.catch(() => undefined);
|
|
1042
1101
|
}, [config?.currentUser?.id, setTheme]);
|
|
1043
1102
|
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
function handleSidebarShortcut(event: KeyboardEvent) {
|
|
1105
|
+
if (
|
|
1106
|
+
event.key.toLowerCase() !== "b"
|
|
1107
|
+
|| event.altKey
|
|
1108
|
+
|| event.shiftKey
|
|
1109
|
+
|| event.ctrlKey === event.metaKey
|
|
1110
|
+
|| event.isComposing
|
|
1111
|
+
|| event.repeat
|
|
1112
|
+
|| isSidebarShortcutEditableTarget(event.target)
|
|
1113
|
+
) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
event.preventDefault();
|
|
1118
|
+
toggleSidebarCollapsed();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
document.addEventListener("keydown", handleSidebarShortcut);
|
|
1122
|
+
return () => document.removeEventListener("keydown", handleSidebarShortcut);
|
|
1123
|
+
}, [toggleSidebarCollapsed]);
|
|
1124
|
+
|
|
1044
1125
|
useEffect(() => {
|
|
1045
1126
|
onRouteChange?.(route);
|
|
1046
1127
|
}, [onRouteChange, route]);
|
|
@@ -1084,6 +1165,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1084
1165
|
const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
|
|
1085
1166
|
const primaryHeaderActions = header?.renderActions?.(headerContext);
|
|
1086
1167
|
const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
|
|
1168
|
+
const sidebarToggleLabel = sidebarCollapsed ? "Show sidebar" : "Collapse sidebar";
|
|
1087
1169
|
const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
|
|
1088
1170
|
navigate(nextRoute);
|
|
1089
1171
|
setSidebarOpen(false);
|
|
@@ -1106,19 +1188,19 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1106
1188
|
<div className="wapp-sidebar-actions">
|
|
1107
1189
|
{topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={() => runSidebarHeaderAction(action)}><ActionIcon icon={action.icon} /></IconButton>)}
|
|
1108
1190
|
<IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
|
|
1109
|
-
<IconButton className="wapp-sidebar-top-button" title=
|
|
1191
|
+
<IconButton className="wapp-sidebar-top-button" title={sidebarToggleLabel} aria-label={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton>
|
|
1110
1192
|
</div>
|
|
1111
1193
|
</div>
|
|
1112
1194
|
<div className="wapp-sidebar-scroll">
|
|
1113
1195
|
{sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
|
|
1114
|
-
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
|
|
1196
|
+
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} />
|
|
1115
1197
|
<div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
|
|
1116
1198
|
</div>
|
|
1117
1199
|
</aside>
|
|
1118
1200
|
<section className="wapp-main">
|
|
1119
1201
|
<header className="wapp-main-header">
|
|
1120
1202
|
<div className="wapp-main-header-title">
|
|
1121
|
-
{sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label=
|
|
1203
|
+
{sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label={sidebarToggleLabel} title={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
|
|
1122
1204
|
<h1>{headerTitle}</h1>
|
|
1123
1205
|
</div>
|
|
1124
1206
|
{primaryHeaderActions || headerActions.length ? (
|
package/src/web/styles.css
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
--wapp-danger-bg: #991b1b;
|
|
14
14
|
--wapp-primary: #111827;
|
|
15
15
|
--wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
16
|
+
--wapp-overlay-bg: rgb(0 0 0 / 0.5);
|
|
17
|
+
--wapp-overlay-blur: blur(4px);
|
|
16
18
|
--wapp-safe-area-top: env(safe-area-inset-top, 0px);
|
|
17
19
|
--wapp-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
18
20
|
--wapp-safe-area-left: env(safe-area-inset-left, 0px);
|
|
@@ -1549,7 +1551,8 @@ textarea::placeholder {
|
|
|
1549
1551
|
.wapp-mobile-backdrop {
|
|
1550
1552
|
position: fixed;
|
|
1551
1553
|
inset: 0;
|
|
1552
|
-
background:
|
|
1554
|
+
background: var(--wapp-overlay-bg);
|
|
1555
|
+
backdrop-filter: var(--wapp-overlay-blur);
|
|
1553
1556
|
z-index: 50;
|
|
1554
1557
|
}
|
|
1555
1558
|
|
|
@@ -1650,8 +1653,8 @@ textarea::placeholder {
|
|
|
1650
1653
|
.wapp-modal-overlay {
|
|
1651
1654
|
position: absolute;
|
|
1652
1655
|
inset: 0;
|
|
1653
|
-
background:
|
|
1654
|
-
backdrop-filter: blur
|
|
1656
|
+
background: var(--wapp-overlay-bg);
|
|
1657
|
+
backdrop-filter: var(--wapp-overlay-blur);
|
|
1655
1658
|
}
|
|
1656
1659
|
|
|
1657
1660
|
.wapp-modal {
|