@pablozaiden/webapp 0.3.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -70,6 +70,8 @@ type StoredSidebarPin = {
70
70
  route: WebAppRoute;
71
71
  };
72
72
 
73
+ type SidebarCollapsedState = Record<string, boolean>;
74
+
73
75
  function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
74
76
  if (!(target instanceof HTMLElement)) {
75
77
  return false;
@@ -251,6 +253,17 @@ function pinStorageKey(appName: string, explicitKey?: string): string {
251
253
  return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
252
254
  }
253
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
+
254
267
  function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
255
268
  if (!node.route) return undefined;
256
269
  return {
@@ -291,6 +304,33 @@ function useSidebarPins(appName: string, storageKey?: string) {
291
304
  return { pins, pinIds, pin, unpin };
292
305
  }
293
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
+
294
334
  function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
295
335
  const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
296
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>;
@@ -500,8 +540,7 @@ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): {
500
540
  return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
501
541
  }
502
542
 
503
- function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
504
- 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 }) {
505
544
  const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
506
545
  return (
507
546
  <>
@@ -513,7 +552,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
513
552
  <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
514
553
  <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
515
554
  {hasChildren ? (
516
- <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>
555
+ <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>
517
556
  <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
518
557
  </button>
519
558
  ) : (
@@ -521,7 +560,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
521
560
  )}
522
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}
523
562
  </div>
524
- {!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}
525
564
  {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
526
565
  </section>
527
566
  );
@@ -529,7 +568,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
529
568
  const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
530
569
  return (
531
570
  <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
532
- {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>{isCollapsed ? "▶" : "▼"}</button> : null}
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}
533
572
  <button
534
573
  type="button"
535
574
  className={`wapp-sidebar-item ${active ? "active" : ""}`}
@@ -546,7 +585,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
546
585
  </span>
547
586
  {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
548
587
  </button>
549
- {!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}
550
589
  </div>
551
590
  );
552
591
  })}
@@ -992,6 +1031,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
992
1031
  const [search, setSearch] = useState("");
993
1032
  const [sidebarOpen, setSidebarOpen] = useState(false);
994
1033
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1034
+ const sidebarTreeState = useSidebarCollapsedState(appName);
995
1035
  const toggleSidebarCollapsed = useCallback(() => {
996
1036
  setSidebarCollapsed((current) => {
997
1037
  const nextCollapsed = !current;
@@ -1153,7 +1193,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1153
1193
  </div>
1154
1194
  <div className="wapp-sidebar-scroll">
1155
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}
1156
- <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} />
1157
1197
  <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
1158
1198
  </div>
1159
1199
  </aside>