@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.3",
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,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={() => 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)}>
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={() => 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}
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="Collapse sidebar" aria-label="Collapse sidebar" onClick={() => { setSidebarCollapsed(true); setSidebarOpen(false); }}><Icon name="sidebar" /></IconButton>
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="Show sidebar" title="Show sidebar" onClick={() => { setSidebarCollapsed(false); setSidebarOpen(true); }}><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>}
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 ? (
@@ -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: rgb(15 23 42 / 0.36);
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: rgb(0 0 0 / 0.5);
1654
- backdrop-filter: blur(4px);
1656
+ background: var(--wapp-overlay-bg);
1657
+ backdrop-filter: var(--wapp-overlay-blur);
1655
1658
  }
1656
1659
 
1657
1660
  .wapp-modal {