@viniaraujo68/plinth 0.0.0-stage → 0.3.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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -2
  3. package/dist/anchoring.d.ts +61 -0
  4. package/dist/anchoring.js +151 -0
  5. package/dist/async-action.svelte.d.ts +37 -0
  6. package/dist/async-action.svelte.js +64 -0
  7. package/dist/attachments/index.d.ts +2 -0
  8. package/dist/attachments/index.js +2 -0
  9. package/dist/attachments/sortable.d.ts +6 -0
  10. package/dist/attachments/sortable.js +227 -0
  11. package/dist/attachments/tooltip.d.ts +37 -0
  12. package/dist/attachments/tooltip.js +166 -0
  13. package/dist/components/AsyncButton.svelte +49 -0
  14. package/dist/components/AsyncButton.svelte.d.ts +32 -0
  15. package/dist/components/Combobox.svelte +380 -0
  16. package/dist/components/Combobox.svelte.d.ts +74 -0
  17. package/dist/components/Copyable.svelte +116 -0
  18. package/dist/components/Copyable.svelte.d.ts +37 -0
  19. package/dist/components/DateRangePicker.svelte +266 -0
  20. package/dist/components/DateRangePicker.svelte.d.ts +66 -0
  21. package/dist/components/Dialog.svelte +102 -0
  22. package/dist/components/Dialog.svelte.d.ts +38 -0
  23. package/dist/components/DragHandle.svelte +26 -0
  24. package/dist/components/DragHandle.svelte.d.ts +7 -0
  25. package/dist/components/Dropdown.svelte +204 -0
  26. package/dist/components/Dropdown.svelte.d.ts +49 -0
  27. package/dist/components/ErrorDisplay.svelte +59 -0
  28. package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
  29. package/dist/components/ErrorPage.svelte +119 -0
  30. package/dist/components/ErrorPage.svelte.d.ts +52 -0
  31. package/dist/components/JsonInput.svelte +106 -0
  32. package/dist/components/JsonInput.svelte.d.ts +40 -0
  33. package/dist/components/Listbox.svelte +257 -0
  34. package/dist/components/Listbox.svelte.d.ts +78 -0
  35. package/dist/components/LoadingButton.svelte +36 -0
  36. package/dist/components/LoadingButton.svelte.d.ts +22 -0
  37. package/dist/components/Logo.svelte +112 -0
  38. package/dist/components/Logo.svelte.d.ts +29 -0
  39. package/dist/components/Modal.svelte +187 -0
  40. package/dist/components/Modal.svelte.d.ts +62 -0
  41. package/dist/components/MultiSelect.svelte +256 -0
  42. package/dist/components/MultiSelect.svelte.d.ts +89 -0
  43. package/dist/components/OptionPanel.svelte +241 -0
  44. package/dist/components/OptionPanel.svelte.d.ts +51 -0
  45. package/dist/components/Paginator.svelte +205 -0
  46. package/dist/components/Paginator.svelte.d.ts +48 -0
  47. package/dist/components/RefreshButton.svelte +246 -0
  48. package/dist/components/RefreshButton.svelte.d.ts +46 -0
  49. package/dist/components/SegmentedControl.svelte +147 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +71 -0
  51. package/dist/components/Select.svelte +312 -0
  52. package/dist/components/Select.svelte.d.ts +68 -0
  53. package/dist/components/Skeleton.svelte +97 -0
  54. package/dist/components/Skeleton.svelte.d.ts +32 -0
  55. package/dist/components/Tooltip.svelte +60 -0
  56. package/dist/components/Tooltip.svelte.d.ts +58 -0
  57. package/dist/components/date-range.d.ts +101 -0
  58. package/dist/components/date-range.js +144 -0
  59. package/dist/components/index.d.ts +26 -0
  60. package/dist/components/index.js +38 -0
  61. package/dist/components/json-text.d.ts +22 -0
  62. package/dist/components/json-text.js +22 -0
  63. package/dist/components/pick.d.ts +18 -0
  64. package/dist/components/pick.js +18 -0
  65. package/dist/components/segmented.d.ts +7 -0
  66. package/dist/components/segmented.js +1 -0
  67. package/dist/components/select.d.ts +66 -0
  68. package/dist/components/select.js +90 -0
  69. package/dist/confirm/Confirmer.svelte +194 -0
  70. package/dist/confirm/Confirmer.svelte.d.ts +45 -0
  71. package/dist/confirm/challenge.d.ts +16 -0
  72. package/dist/confirm/challenge.js +16 -0
  73. package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
  74. package/dist/confirm/confirm-manager.svelte.js +149 -0
  75. package/dist/confirm/index.d.ts +3 -0
  76. package/dist/confirm/index.js +3 -0
  77. package/dist/error.d.ts +25 -0
  78. package/dist/error.js +39 -0
  79. package/dist/formatters.d.ts +95 -0
  80. package/dist/formatters.js +146 -0
  81. package/dist/http.d.ts +68 -0
  82. package/dist/http.js +138 -0
  83. package/dist/index.d.ts +10 -0
  84. package/dist/index.js +14 -0
  85. package/dist/routing.svelte.d.ts +169 -0
  86. package/dist/routing.svelte.js +208 -0
  87. package/dist/shell/AppShell.svelte +874 -0
  88. package/dist/shell/AppShell.svelte.d.ts +93 -0
  89. package/dist/shell/Breadcrumbs.svelte +78 -0
  90. package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
  91. package/dist/shell/crumbs.d.ts +23 -0
  92. package/dist/shell/crumbs.js +1 -0
  93. package/dist/shell/index.d.ts +3 -0
  94. package/dist/shell/index.js +6 -0
  95. package/dist/table/ColumnMenu.svelte +249 -0
  96. package/dist/table/ColumnMenu.svelte.d.ts +47 -0
  97. package/dist/table/DataTable.svelte +643 -0
  98. package/dist/table/DataTable.svelte.d.ts +153 -0
  99. package/dist/table/columns.d.ts +55 -0
  100. package/dist/table/columns.js +153 -0
  101. package/dist/table/index.d.ts +4 -0
  102. package/dist/table/index.js +8 -0
  103. package/dist/table/sort.d.ts +48 -0
  104. package/dist/table/sort.js +139 -0
  105. package/dist/table/types.d.ts +93 -0
  106. package/dist/table/types.js +1 -0
  107. package/dist/theme/ThemeController.svelte +54 -0
  108. package/dist/theme/ThemeController.svelte.d.ts +27 -0
  109. package/dist/theme/ThemeToggle.svelte +119 -0
  110. package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
  111. package/dist/theme/css-color.d.ts +23 -0
  112. package/dist/theme/css-color.js +31 -0
  113. package/dist/theme/index.d.ts +4 -0
  114. package/dist/theme/index.js +5 -0
  115. package/dist/theme/theme.css +410 -0
  116. package/dist/theme/theme.svelte.d.ts +54 -0
  117. package/dist/theme/theme.svelte.js +58 -0
  118. package/dist/toast/Toaster.svelte +257 -0
  119. package/dist/toast/Toaster.svelte.d.ts +35 -0
  120. package/dist/toast/index.d.ts +2 -0
  121. package/dist/toast/index.js +2 -0
  122. package/dist/toast/toast-manager.svelte.d.ts +99 -0
  123. package/dist/toast/toast-manager.svelte.js +176 -0
  124. package/dist/user/ScopedComponent.svelte +47 -0
  125. package/dist/user/ScopedComponent.svelte.d.ts +29 -0
  126. package/dist/user/anonymous.d.ts +21 -0
  127. package/dist/user/anonymous.js +26 -0
  128. package/dist/user/context.d.ts +55 -0
  129. package/dist/user/context.js +13 -0
  130. package/dist/user/index.d.ts +3 -0
  131. package/dist/user/index.js +6 -0
  132. package/package.json +133 -4
@@ -0,0 +1,874 @@
1
+ <script lang="ts">
2
+ // `svelte/no-navigation-without-resolve` only recognises a literal `resolve()` call, and a
3
+ // pathname assembled at runtime has no literal route to hand it. `resolvePathname` IS that call,
4
+ // wrapped once so the unavoidable cast lives in a single place.
5
+ /* eslint-disable svelte/no-navigation-without-resolve */
6
+ import { untrack, type Snippet } from "svelte";
7
+ import type { ClassValue } from "svelte/elements";
8
+ import Dialog from "../components/Dialog.svelte";
9
+ import { PICK_CLIP_PATH } from "../components/pick.js";
10
+ import { tooltip } from "../attachments/tooltip.js";
11
+ import { getRoutingContext, resolvePathname, type ResolvedRoute } from "../routing.svelte.js";
12
+ import { getUserContext, type UserData } from "../user/context.js";
13
+
14
+ interface Props {
15
+ /** The routed page. The shell owns the scroll container around it. */
16
+ children: Snippet;
17
+ /**
18
+ * The sidebar header, and the bottom sheet's header on narrow screens. Receives the collapse
19
+ * state so a caller can swap a wordmark for a mark instead of letting it be clipped.
20
+ */
21
+ brand?: Snippet<[{ collapsed: boolean }]>;
22
+ /** Extra sidebar footer content, above the user block. A theme picker usually goes here. */
23
+ footer?: Snippet;
24
+ /**
25
+ * Renders one entry's icon. Without it the shell emits `<span class={meta.icon}>`, which is
26
+ * what an icon font or an Iconify/Tailwind class wants; supply it to render SVG components.
27
+ */
28
+ icon?: Snippet<[ResolvedRoute]>;
29
+ /** Extra classes for the shell root — sizing, mostly. */
30
+ class?: ClassValue;
31
+ /**
32
+ * How many items the bottom bar renders, the trailing "More" item counted among them. Entries
33
+ * that do not fit collapse into the sheet "More" opens — and so does everything the bar has no
34
+ * room for at all: the brand, the footer snippet and the user block. "More" therefore appears
35
+ * with nothing overflowing whenever any of those exist, and costs a nav slot only once the bar
36
+ * is genuinely full: three entries in five slots render three plus "More", five render four
37
+ * plus "More" and put the fifth in the sheet.
38
+ */
39
+ bottomBarSlots?: number;
40
+ /** Where the collapse state is remembered. `null` turns persistence off. */
41
+ storageKey?: string | null;
42
+ /** Whether the sidebar is collapsed to icons. Bindable, so a page can drive it too. */
43
+ collapsed?: boolean;
44
+ /** Accessible name of both navigations. A prop because the library ships no translations. */
45
+ navLabel?: string;
46
+ /** Accessible name of the collapse button. */
47
+ collapseLabel?: string;
48
+ /** Label of the bottom bar's overflow item, and heading of the sheet it opens. */
49
+ moreLabel?: string;
50
+ /** Accessible name of the sheet's close button. */
51
+ closeLabel?: string;
52
+ /** Label of the sign-out control, which only appears when the user context can sign out. */
53
+ logoutLabel?: string;
54
+ }
55
+
56
+ /**
57
+ * Reading the stored flag at init rather than in an effect is what keeps the first paint from
58
+ * flashing an expanded sidebar shut. On the server there is no storage and no flash to avoid,
59
+ * so it answers "expanded" and hydration corrects it.
60
+ */
61
+ const storedCollapsed = (key: string | null): boolean => {
62
+ if (key === null || typeof localStorage === "undefined") return false;
63
+ try {
64
+ return localStorage.getItem(key) === "true";
65
+ } catch {
66
+ // Storage access throws outright in a sandboxed frame or with site data blocked.
67
+ return false;
68
+ }
69
+ };
70
+
71
+ let {
72
+ children,
73
+ brand,
74
+ footer,
75
+ icon,
76
+ class: className,
77
+ bottomBarSlots = 5,
78
+ storageKey = "plinth:sidebar-collapsed",
79
+ collapsed = $bindable(storedCollapsed(storageKey)),
80
+ navLabel = "Main navigation",
81
+ collapseLabel = "Toggle sidebar",
82
+ moreLabel = "More",
83
+ closeLabel = "Close",
84
+ logoutLabel = "Sign out",
85
+ }: Props = $props();
86
+
87
+ const routing = getRoutingContext();
88
+ const user = getUserContext();
89
+
90
+ const listed = (route: ResolvedRoute) => route.meta.title !== undefined && route.navigable;
91
+
92
+ /**
93
+ * Home is the header entry, apart from the list — derived, never re-declared. The root is a
94
+ * prefix of every route id, so it rides the trail whenever anything matched at all; on a 404
95
+ * there is no trail and the header falls back to the brand alone.
96
+ */
97
+ const home = $derived(routing.matched.find((route) => route.routeId === "/" && listed(route)));
98
+
99
+ /**
100
+ * The role gate `ScopedComponent` applies, applied here instead — one step earlier, because the
101
+ * bottom bar has to count the entries it will actually render before it can decide which ones
102
+ * overflow. Wrapping each item in the component would leave the slot arithmetic counting entries
103
+ * the user never sees, and "More" opening onto an empty sheet.
104
+ */
105
+ const items = $derived(
106
+ routing
107
+ .children("/")
108
+ .filter((route) => listed(route) && user.hasAnyRole(route.meta.requiredRoles ?? [])),
109
+ );
110
+
111
+ /**
112
+ * An entry is current while the match sits anywhere on its subtree; among top-level siblings at
113
+ * most one can. Home is the exception — everything is below the root, so trail membership would
114
+ * light it up on every route and leave two entries current at once; it takes the exact root
115
+ * instead, and an undeclared route lights nothing at all.
116
+ *
117
+ * One test, read by the sidebar, the bar and the sheet alike. Answering it in two places is
118
+ * precisely how the bar came to mark Home current everywhere while the sidebar did not.
119
+ */
120
+ const isCurrent = (route: ResolvedRoute) =>
121
+ route.routeId === "/"
122
+ ? routing.routeId === "/"
123
+ : routing.matched.some((match) => match.routeId === route.routeId);
124
+
125
+ let content: HTMLElement | undefined = $state();
126
+ const offsets: Record<string, number> = {};
127
+ let returningTo: string | null = null;
128
+ const pathname = $derived(routing.url.pathname);
129
+
130
+ const rememberOffset = () => {
131
+ if (content) offsets[pathname] = content.scrollTop;
132
+ };
133
+
134
+ $effect(() => {
135
+ const onPopState = () => {
136
+ returningTo = window.location.pathname;
137
+ };
138
+ window.addEventListener("popstate", onPopState);
139
+ return () => window.removeEventListener("popstate", onPopState);
140
+ });
141
+
142
+ $effect(() => {
143
+ const current = pathname;
144
+ const element = content;
145
+ if (!element) return;
146
+ untrack(() => {
147
+ const returning = returningTo === current;
148
+ returningTo = null;
149
+ if (returning) element.scrollTop = offsets[current] ?? 0;
150
+ else if (!routing.url.hash) element.scrollTop = 0;
151
+ });
152
+ });
153
+
154
+ // Home leads the bottom bar rather than heading it: a bar has no header to put it in.
155
+ const barEntries = $derived(home ? [home, ...items] : items);
156
+ const slots = $derived(Math.max(1, bottomBarSlots));
157
+
158
+ const signedInUser = $derived(user.status === "authenticated" ? user.data : null);
159
+
160
+ /**
161
+ * The bar form hides the sidebar outright, and with it the brand, the footer controls and the
162
+ * user block. The sheet is the only place left to put them, so their mere existence is reason
163
+ * enough for "More" — without that, a phone has no way to reach a language picker or to sign out.
164
+ */
165
+ const sheetChrome = $derived(
166
+ brand !== undefined || footer !== undefined || signedInUser !== null,
167
+ );
168
+ const showMore = $derived(barEntries.length > slots || sheetChrome);
169
+
170
+ /*
171
+ * Slot arithmetic. `bottomBarSlots` caps what the bar renders, "More" counted among it, so the
172
+ * nav gets `slots - 1` items when "More" is there and all `slots` when it is not:
173
+ *
174
+ * 8 entries, 5 slots -> 4 entries + More, 4 in the sheet (the overflow buys the slot)
175
+ * 8 entries, 8 slots, no chrome -> 8 entries, no More at all
176
+ * 3 entries, 5 slots, chrome -> 3 entries + More, and the sheet lists no entries
177
+ * 5 entries, 5 slots, chrome -> 4 entries + More, the fifth entry moves into the sheet
178
+ *
179
+ * The third line is the one a chrome-only "More" is judged on: it takes a slot the nav had no
180
+ * use for, and `slice` starts moving entries into the sheet only once they outgrow what is left.
181
+ * The fourth is the cap being a cap — on a bar already full the last slot has to be bought from
182
+ * the nav, and the entry that pays is still there, one tap deeper.
183
+ */
184
+ const navSlots = $derived(showMore ? slots - 1 : slots);
185
+ const barItems = $derived(barEntries.slice(0, navSlots));
186
+ const overflowItems = $derived(barEntries.slice(navSlots));
187
+
188
+ const initials = (name: string) => {
189
+ const words = name.split(/\s+/).filter(Boolean);
190
+ if (words.length === 0) return "?";
191
+ const first = words[0][0];
192
+ const last = words.length > 1 ? words[words.length - 1][0] : "";
193
+ return `${first}${last}`.toUpperCase();
194
+ };
195
+
196
+ /* Per instance, not a constant: a page can mount two shells against one routing context — the
197
+ showcase mounts exactly two — and a duplicated id would point both avatars at whichever
198
+ `<clipPath>` the parser saw first. */
199
+ const avatarClip = $props.id();
200
+ // The sidebar and the sheet render the same user block, and while the sheet is open both are in
201
+ // the document: two `<clipPath>` definitions, so two ids.
202
+ const sheetAvatarClip = `${avatarClip}-sheet`;
203
+
204
+ let sheet = $state<Dialog>();
205
+
206
+ $effect(() => {
207
+ if (storageKey === null) return;
208
+ try {
209
+ localStorage.setItem(storageKey, String(collapsed));
210
+ } catch {
211
+ // Same sandbox as above; a sidebar that forgets its state is not worth an exception.
212
+ }
213
+ });
214
+ </script>
215
+
216
+ <!--
217
+ @component
218
+ The application shell: a collapsible sidebar on wide viewports, a fixed bottom bar on narrow ones,
219
+ both fed by the same `RoutingContext`. The route tree is declared once, by the filesystem and the
220
+ routing config; nothing here re-states it.
221
+
222
+ Which form shows is decided by a **container query on the shell root**, not by JavaScript. There is
223
+ no resize listener and no `matchMedia`, so nothing flashes the wrong layout on the first paint —
224
+ and because the root is its own container, the shell can be dropped into a narrow box to render its
225
+ mobile form on a desktop, which is exactly what the showcase does.
226
+
227
+ Entries come from `routing.children("/")`, minus plumbing (no `title`), minus any route with no page
228
+ of its own to link to, and minus anything the user context gates out. Home, when declared, is derived off the trail and rendered as the sidebar's
229
+ header entry and as the bottom bar's first item. It is current on the exact root and nowhere else —
230
+ every route is below it — while every other entry is current for its whole subtree, so exactly one
231
+ entry is ever marked, in either form.
232
+
233
+ The bar form has no sidebar, and so no brand, no footer and no user block. All three move into the
234
+ sheet behind the "More" item, which is therefore shown whenever there is anything to put there —
235
+ overflowing entries, a `brand` snippet, a `footer` snippet or a signed-in user — and not only on an
236
+ overflow. The sheet reads brand, then the entries that did not fit, then `footer`, then the user
237
+ block with its sign-out control. Choosing an entry dismisses it; using a footer control does not.
238
+
239
+ Icons: `meta.icon` is an uninterpreted string, so by default it is handed straight to markup as
240
+ `<span class={meta.icon}>` — the shape an icon font or an Iconify/Tailwind class wants. Pass the
241
+ `icon` snippet to render something else, an SVG component for instance, and `meta.icon` becomes
242
+ whatever the app wants it to mean.
243
+
244
+ The shell fills its parent, so give it one with a height (`<div class="h-dvh">`, or `html, body {
245
+ height: 100% }`). It owns the page's scroll container — the bar is pinned to the bottom of the
246
+ shell rather than to the window, and the content area reserves its height plus
247
+ `env(safe-area-inset-bottom)` so nothing ends up underneath it.
248
+
249
+ ```svelte
250
+ <AppShell>
251
+ {#snippet brand({ collapsed })}
252
+ <a href="/">{collapsed ? "A" : "Acme"}</a>
253
+ {/snippet}
254
+ {#snippet footer()}
255
+ <ThemeToggle />
256
+ {/snippet}
257
+ {@render page()}
258
+ </AppShell>
259
+ ```
260
+ -->
261
+
262
+ <div class={["plinth-shell", className]} class:collapsed>
263
+ <aside class="shell-sidebar">
264
+ <div class="sidebar-header">
265
+ {#if brand}
266
+ <div class="brand-slot">{@render brand({ collapsed })}</div>
267
+ {/if}
268
+ <button
269
+ type="button"
270
+ class="btn btn-square btn-ghost btn-sm"
271
+ aria-label={collapseLabel}
272
+ aria-pressed={collapsed}
273
+ onclick={() => (collapsed = !collapsed)}
274
+ >
275
+ <svg
276
+ class="size-5"
277
+ viewBox="0 0 24 24"
278
+ fill="none"
279
+ stroke="currentColor"
280
+ stroke-width="1.75"
281
+ stroke-linecap="round"
282
+ aria-hidden="true"
283
+ >
284
+ <path d="M4 6h16M4 12h16M4 18h16" />
285
+ </svg>
286
+ </button>
287
+ </div>
288
+
289
+ {#if home}
290
+ <a
291
+ class="nav-link"
292
+ href={resolvePathname(home.pathname)}
293
+ aria-current={isCurrent(home) ? "page" : undefined}
294
+ {@attach tooltip(home.label, { disabled: !collapsed })}
295
+ >
296
+ <span class="nav-icon">{@render routeIcon(home)}</span>
297
+ <span class="nav-label collapsible">{home.label}</span>
298
+ </a>
299
+ <hr class="nav-separator" />
300
+ {/if}
301
+
302
+ <nav class="sidebar-nav" aria-label={navLabel}>
303
+ <ul>
304
+ {#each items as item (item.routeId)}
305
+ <li>
306
+ <a
307
+ class="nav-link"
308
+ href={resolvePathname(item.pathname)}
309
+ aria-current={isCurrent(item) ? "page" : undefined}
310
+ {@attach tooltip(item.label, { disabled: !collapsed })}
311
+ >
312
+ <span class="nav-icon">{@render routeIcon(item)}</span>
313
+ <span class="nav-label collapsible">{item.label}</span>
314
+ </a>
315
+ </li>
316
+ {/each}
317
+ </ul>
318
+ </nav>
319
+
320
+ <div class="sidebar-footer">
321
+ {#if footer}
322
+ <div class="footer-slot">{@render footer()}</div>
323
+ {/if}
324
+
325
+ {#if signedInUser}
326
+ {@render userBlock(signedInUser, avatarClip)}
327
+ {/if}
328
+ </div>
329
+ </aside>
330
+
331
+ <main class="shell-content" bind:this={content} onscroll={rememberOffset}>
332
+ {@render children()}
333
+ </main>
334
+
335
+ <nav class="shell-bottom-bar" aria-label={navLabel}>
336
+ {#each barItems as item (item.routeId)}
337
+ <a
338
+ class="bar-link"
339
+ href={resolvePathname(item.pathname)}
340
+ aria-current={isCurrent(item) ? "page" : undefined}
341
+ >
342
+ <span class="bar-icon">{@render routeIcon(item)}</span>
343
+ <span class="bar-text">{item.label}</span>
344
+ </a>
345
+ {/each}
346
+
347
+ {#if showMore}
348
+ <button type="button" class="bar-link" aria-haspopup="dialog" onclick={() => sheet?.show()}>
349
+ <span class="bar-icon" aria-hidden="true">
350
+ <svg
351
+ class="size-5"
352
+ viewBox="0 0 24 24"
353
+ fill="none"
354
+ stroke="currentColor"
355
+ stroke-width="2"
356
+ stroke-linecap="round"
357
+ >
358
+ <circle cx="5" cy="12" r="1" />
359
+ <circle cx="12" cy="12" r="1" />
360
+ <circle cx="19" cy="12" r="1" />
361
+ </svg>
362
+ </span>
363
+ <span class="bar-text">{moreLabel}</span>
364
+ </button>
365
+ {/if}
366
+ </nav>
367
+
368
+ <!-- A native modal dialog rather than a hand-rolled panel: modality, the focus trap, Escape and
369
+ returning focus to the "More" button are the browser's, and the top layer settles the
370
+ stacking against the fixed bar without a z-index. The trade is that the top layer is anchored
371
+ to the viewport, not to the shell — visible only when the shell is embedded in a box smaller
372
+ than the window, as in the showcase. -->
373
+ <Dialog bind:this={sheet} class="plinth-sheet" aria-label={moreLabel}>
374
+ <div class="sheet-header">
375
+ {#if brand}
376
+ <div class="brand-slot">{@render brand({ collapsed: false })}</div>
377
+ {:else}
378
+ <span class="sheet-title">{moreLabel}</span>
379
+ {/if}
380
+ <button
381
+ type="button"
382
+ class="btn size-11 btn-ghost"
383
+ aria-label={closeLabel}
384
+ onclick={() => sheet?.close()}
385
+ >
386
+ ✕
387
+ </button>
388
+ </div>
389
+ <div class="sheet-body">
390
+ {#if overflowItems.length > 0}
391
+ <ul class="sheet-list">
392
+ {#each overflowItems as item (item.routeId)}
393
+ <li>
394
+ <a
395
+ class="sheet-link"
396
+ href={resolvePathname(item.pathname)}
397
+ aria-current={isCurrent(item) ? "page" : undefined}
398
+ onclick={() => sheet?.close()}
399
+ >
400
+ <span class="nav-icon">{@render routeIcon(item)}</span>
401
+ <span>{item.label}</span>
402
+ </a>
403
+ </li>
404
+ {/each}
405
+ </ul>
406
+ {/if}
407
+
408
+ {#if footer}
409
+ <!-- No dismissal wired here, deliberately. A footer control is a setting, not a
410
+ destination: a language or theme picker changes the sheet the reader is looking at and
411
+ is routinely used twice in a row, so closing on it would hide the result of the tap
412
+ that produced it. Only choosing an entry — an actual departure — dismisses the sheet. -->
413
+ <div class="sheet-footer">{@render footer()}</div>
414
+ {/if}
415
+
416
+ {#if signedInUser}
417
+ <div class="sheet-user">{@render userBlock(signedInUser, sheetAvatarClip)}</div>
418
+ {/if}
419
+ </div>
420
+ </Dialog>
421
+ </div>
422
+
423
+ {#snippet userBlock(data: UserData, clipId: string)}
424
+ <div class="user-card">
425
+ <!-- A `<clipPath>` rather than CSS `clip-path: path()`, because `path()` takes user units and
426
+ never scales: it would fit one avatar size and no other.
427
+ `clipPathUnits="objectBoundingBox"` reads the same outline in 0..1 and follows whatever
428
+ box the span turns out to be. -->
429
+ <svg class="avatar-clip" aria-hidden="true" focusable="false">
430
+ <clipPath id={clipId} clipPathUnits="objectBoundingBox">
431
+ <path d={PICK_CLIP_PATH} />
432
+ </clipPath>
433
+ </svg>
434
+ <span class="avatar-initials" aria-hidden="true" style:clip-path="url(#{clipId})">
435
+ {initials(data.name)}
436
+ </span>
437
+ <span class="user-identity collapsible">
438
+ <span class="user-name">{data.name}</span>
439
+ <span class="user-email">{data.email}</span>
440
+ </span>
441
+ {#if user.logout}
442
+ <!-- Only rendered when the context can actually sign out; a button that does nothing is
443
+ worse than no button. Signing out from the sheet dismisses it, unlike a footer control:
444
+ this one IS a departure — the app is about to be somewhere else, and the block the
445
+ button sits in stops existing the moment it runs. In the sidebar the call is a no-op. -->
446
+ <button
447
+ type="button"
448
+ class="collapsible btn btn-square btn-ghost btn-sm"
449
+ aria-label={logoutLabel}
450
+ onclick={() => {
451
+ sheet?.close();
452
+ void user.logout?.();
453
+ }}
454
+ >
455
+ <svg
456
+ class="size-4"
457
+ viewBox="0 0 24 24"
458
+ fill="none"
459
+ stroke="currentColor"
460
+ stroke-width="1.75"
461
+ stroke-linecap="round"
462
+ stroke-linejoin="round"
463
+ aria-hidden="true"
464
+ >
465
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
466
+ </svg>
467
+ </button>
468
+ {/if}
469
+ </div>
470
+ {/snippet}
471
+
472
+ {#snippet routeIcon(route: ResolvedRoute)}
473
+ {#if icon}
474
+ {@render icon(route)}
475
+ {:else if route.meta.icon}
476
+ <span class={route.meta.icon} aria-hidden="true"></span>
477
+ {/if}
478
+ {/snippet}
479
+
480
+ <style>
481
+ .plinth-shell {
482
+ --plinth-sidebar-width: 16rem;
483
+ --plinth-sidebar-collapsed-width: 4rem;
484
+ --plinth-bottom-bar-height: 3.5rem;
485
+ /* Behind a custom property rather than inlined at each use: `env()` resolves to zero on
486
+ everything without a notch, which makes the inset impossible to see in a browser and
487
+ impossible to assert in a test. One name gives an app — and a spec — somewhere to override
488
+ it, and keeps the bar and the content reserve reading the same number. */
489
+ --plinth-safe-area-bottom: env(safe-area-inset-bottom, 0px);
490
+ --plinth-shell-line: color-mix(in oklch, var(--color-base-content) 12%, transparent);
491
+
492
+ /* The whole breakpoint. Querying the container rather than the viewport is also what lets the
493
+ shell be dropped into a narrow box and render its phone form on a desktop, which is how the
494
+ showcase demonstrates both at once and how a test reaches either. */
495
+ container: plinth-shell / inline-size;
496
+
497
+ /* The bar is anchored to this box, not to the window. `position: fixed` would have been the
498
+ obvious spelling and is wrong here twice over: browsers do not treat a query container as a
499
+ containing block for fixed descendants, so the bar would escape an embedded shell entirely,
500
+ and it does not need to -- the root never scrolls, only `.shell-content` inside it does. */
501
+ position: relative;
502
+ display: flex;
503
+ height: 100%;
504
+ min-height: 0;
505
+ background-color: var(--color-base-200);
506
+ color: var(--color-base-content);
507
+ }
508
+
509
+ .shell-content {
510
+ flex: 1;
511
+ min-width: 0;
512
+ overflow-y: auto;
513
+ overscroll-behavior: contain;
514
+ background-color: var(--color-base-100);
515
+ /* The bar is out of flow, so the room under it has to be reserved here or the last line of
516
+ every page hides behind it. */
517
+ padding-bottom: calc(var(--plinth-bottom-bar-height) + var(--plinth-safe-area-bottom));
518
+ }
519
+
520
+ .shell-sidebar {
521
+ display: none;
522
+ flex-direction: column;
523
+ flex-shrink: 0;
524
+ width: var(--plinth-sidebar-width);
525
+ overflow-x: hidden;
526
+ border-right: 1px solid var(--plinth-shell-line);
527
+ background-color: var(--color-base-100);
528
+ transition: width 200ms ease;
529
+ }
530
+
531
+ .plinth-shell.collapsed .shell-sidebar {
532
+ width: var(--plinth-sidebar-collapsed-width);
533
+ }
534
+
535
+ /* Scoped to the sidebar, because collapsing is the sidebar's state alone: the sheet renders the
536
+ same user block, with the same classes, on a viewport where nothing is collapsed — and a
537
+ remembered collapsed flag would otherwise strip the name and the sign-out button out of it. */
538
+ .plinth-shell.collapsed .shell-sidebar .collapsible {
539
+ display: none;
540
+ }
541
+
542
+ .sidebar-header {
543
+ display: flex;
544
+ align-items: center;
545
+ justify-content: space-between;
546
+ gap: 0.5rem;
547
+ /* Matched to the icon column so the toggle lines up with the icons below it. */
548
+ min-height: var(--plinth-sidebar-collapsed-width);
549
+ padding: 0 0.625rem;
550
+ }
551
+
552
+ /* A brand and a toggle do not both fit across a 4rem rail, and squeezing them is how the mark
553
+ ends up a sliver. Stacking keeps the brand slot at full rail width and leaves the toggle
554
+ reachable, which is the one control that must never collapse out of existence. */
555
+ .plinth-shell.collapsed .sidebar-header {
556
+ flex-direction: column;
557
+ justify-content: center;
558
+ gap: 0.25rem;
559
+ min-height: 0;
560
+ padding: 0.5rem 0;
561
+ }
562
+
563
+ .brand-slot {
564
+ min-width: 0;
565
+ overflow: hidden;
566
+ white-space: nowrap;
567
+ }
568
+
569
+ /* Scoped to the sidebar for the same reason the `collapsible` rule is: `.brand-slot` is also the
570
+ sheet's header, where there is no rail to centre a mark on and nothing is collapsed. */
571
+ .plinth-shell.collapsed .shell-sidebar .brand-slot {
572
+ display: flex;
573
+ justify-content: center;
574
+ width: 100%;
575
+ }
576
+
577
+ .sidebar-nav {
578
+ flex: 1;
579
+ min-height: 0;
580
+ overflow-y: auto;
581
+ }
582
+
583
+ .sidebar-nav ul {
584
+ display: flex;
585
+ flex-direction: column;
586
+ gap: 0.125rem;
587
+ margin: 0;
588
+ padding: 0;
589
+ list-style: none;
590
+ }
591
+
592
+ .nav-separator {
593
+ height: 1px;
594
+ margin: 0.75rem 0.875rem;
595
+ border: 0;
596
+ background-color: var(--plinth-shell-line);
597
+ }
598
+
599
+ .nav-link {
600
+ display: flex;
601
+ align-items: center;
602
+ height: 2.5rem;
603
+ white-space: nowrap;
604
+ color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
605
+ transition: background-color 120ms ease;
606
+ }
607
+
608
+ .nav-icon {
609
+ display: flex;
610
+ flex-shrink: 0;
611
+ align-items: center;
612
+ justify-content: center;
613
+ /* The icon column is exactly the collapsed width, so collapsing moves nothing. */
614
+ width: var(--plinth-sidebar-collapsed-width);
615
+ font-size: 1.25rem;
616
+ }
617
+
618
+ .nav-label {
619
+ min-width: 0;
620
+ overflow: hidden;
621
+ font-size: 0.875rem;
622
+ text-overflow: ellipsis;
623
+ }
624
+
625
+ .nav-link:hover:not([aria-current="page"]) {
626
+ background-color: var(--color-base-200);
627
+ color: var(--color-base-content);
628
+ }
629
+
630
+ .nav-link[aria-current="page"] {
631
+ background-color: var(--color-base-300);
632
+ color: var(--color-base-content);
633
+ }
634
+
635
+ .nav-link[aria-current="page"] .nav-icon {
636
+ color: var(--color-primary);
637
+ }
638
+
639
+ .sidebar-footer {
640
+ border-top: 1px solid var(--plinth-shell-line);
641
+ }
642
+
643
+ .footer-slot {
644
+ display: flex;
645
+ align-items: center;
646
+ justify-content: center;
647
+ padding: 0.5rem;
648
+ }
649
+
650
+ .user-card {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 0.625rem;
654
+ padding: 0.625rem;
655
+ }
656
+
657
+ /* Carries the definition only; the shape is painted by the span that references it. Zero-sized
658
+ rather than `display: none`, which is the one way of hiding it that some engines take as
659
+ licence to skip the subtree the reference needs. */
660
+ .avatar-clip {
661
+ position: absolute;
662
+ width: 0;
663
+ height: 0;
664
+ }
665
+
666
+ .avatar-initials {
667
+ display: flex;
668
+ flex-shrink: 0;
669
+ align-items: center;
670
+ justify-content: center;
671
+ /* Wider than the circle it replaces: the outline is inset inside its own 32-unit square, so
672
+ the pick drawn in a 2rem box would have come out visibly smaller than the disc did. */
673
+ width: 2.5rem;
674
+ height: 2.5rem;
675
+ /* The silhouette tapers to a point at the top, which puts its mass below the middle of the
676
+ box. A few pixels of head room is what centres the initials in the shape rather than in the
677
+ box that contains it. */
678
+ padding-top: 0.25rem;
679
+ /* Solid, where the disc was a 15% wash: a clipped shape is only readable by its edge, and a
680
+ wash that faint leaves the edge to guesswork. `--color-primary-content` is the pair the
681
+ theme guarantees against it in both modes. */
682
+ background-color: var(--color-primary);
683
+ color: var(--color-primary-content);
684
+ font-size: 0.75rem;
685
+ font-weight: 500;
686
+ }
687
+
688
+ .user-identity {
689
+ display: flex;
690
+ flex: 1;
691
+ min-width: 0;
692
+ flex-direction: column;
693
+ }
694
+
695
+ .user-name,
696
+ .user-email {
697
+ overflow: hidden;
698
+ text-overflow: ellipsis;
699
+ white-space: nowrap;
700
+ }
701
+
702
+ .user-name {
703
+ font-size: 0.875rem;
704
+ }
705
+
706
+ .user-email {
707
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
708
+ font-size: 0.75rem;
709
+ }
710
+
711
+ .shell-bottom-bar {
712
+ position: absolute;
713
+ inset-inline: 0;
714
+ bottom: 0;
715
+ z-index: 30;
716
+ display: flex;
717
+ align-items: stretch;
718
+ border-top: 1px solid var(--plinth-shell-line);
719
+ background-color: var(--color-base-100);
720
+ padding-bottom: var(--plinth-safe-area-bottom);
721
+ }
722
+
723
+ .bar-link {
724
+ display: flex;
725
+ flex: 1 1 0;
726
+ flex-direction: column;
727
+ align-items: center;
728
+ justify-content: center;
729
+ gap: 0.125rem;
730
+ /* 44px is the floor for a touch target; the bar is taller than that on purpose, so the label
731
+ under the icon has somewhere to sit. */
732
+ min-width: 2.75rem;
733
+ min-height: var(--plinth-bottom-bar-height);
734
+ padding: 0 0.25rem;
735
+ overflow: hidden;
736
+ color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
737
+ }
738
+
739
+ .bar-icon {
740
+ display: flex;
741
+ align-items: center;
742
+ font-size: 1.125rem;
743
+ line-height: 1;
744
+ }
745
+
746
+ .bar-text {
747
+ max-width: 100%;
748
+ overflow: hidden;
749
+ font-size: 0.6875rem;
750
+ text-overflow: ellipsis;
751
+ white-space: nowrap;
752
+ }
753
+
754
+ .bar-link[aria-current="page"] {
755
+ color: var(--color-primary);
756
+ }
757
+
758
+ /* The sheet is rendered by `Dialog`, so its class never appears in this component's markup and
759
+ the compiler would prune every rule below as unused. */
760
+ :global(dialog.plinth-sheet) {
761
+ /* Bottom-anchored and full-bleed: a sheet, not a centred box. */
762
+ margin: auto auto 0;
763
+ width: 100%;
764
+ max-width: 100%;
765
+ max-height: 80dvh;
766
+ padding: 0 0 var(--plinth-safe-area-bottom);
767
+ border: 0;
768
+ border-radius: var(--radius-box, 1rem) var(--radius-box, 1rem) 0 0;
769
+ background-color: var(--color-base-100);
770
+ color: var(--color-base-content);
771
+ }
772
+
773
+ /* Only while open. A bare `display: flex` on the element would override the UA's `display: none`
774
+ for a closed `<dialog>` and leave it on the page; `[open]` is set by `showModal()` itself. */
775
+ :global(dialog.plinth-sheet[open]) {
776
+ display: flex;
777
+ flex-direction: column;
778
+ }
779
+
780
+ :global(dialog.plinth-sheet::backdrop) {
781
+ background-color: rgb(0 0 0 / 0.4);
782
+ }
783
+
784
+ .sheet-header {
785
+ display: flex;
786
+ flex-shrink: 0;
787
+ align-items: center;
788
+ justify-content: space-between;
789
+ gap: 0.75rem;
790
+ padding: 0.5rem 0.5rem 0.5rem 1rem;
791
+ border-bottom: 1px solid var(--plinth-shell-line);
792
+ }
793
+
794
+ /* The header stays put and everything under it scrolls: the sheet now carries the footer slot
795
+ and the user block as well as the entries, which is more than a phone is guaranteed to fit. */
796
+ .sheet-body {
797
+ min-height: 0;
798
+ overflow-y: auto;
799
+ }
800
+
801
+ .sheet-title {
802
+ font-size: 1rem;
803
+ font-weight: 600;
804
+ }
805
+
806
+ .sheet-list {
807
+ margin: 0;
808
+ padding: 0.5rem 0;
809
+ list-style: none;
810
+ }
811
+
812
+ .sheet-footer,
813
+ .sheet-user {
814
+ /* Separated from whatever precedes it, and from nothing when it is first: the sheet's sections
815
+ are all optional, so the rule has to come from the section itself. */
816
+ border-top: 1px solid var(--plinth-shell-line);
817
+ }
818
+
819
+ .sheet-body > :first-child {
820
+ border-top: 0;
821
+ }
822
+
823
+ .sheet-footer {
824
+ display: flex;
825
+ align-items: center;
826
+ gap: 0.5rem;
827
+ /* A touch row's worth of height for whatever the app puts here; the controls themselves are
828
+ the app's to size, so the sheet gives them the room rather than overriding them. */
829
+ min-height: 2.75rem;
830
+ padding: 0.5rem 1rem;
831
+ }
832
+
833
+ .sheet-user {
834
+ padding: 0.25rem 0.5rem;
835
+ }
836
+
837
+ /* The same control is a 32px icon button on a pointer-driven sidebar and a touch target here. */
838
+ .sheet-user .btn {
839
+ min-width: 2.75rem;
840
+ min-height: 2.75rem;
841
+ }
842
+
843
+ .sheet-link {
844
+ display: flex;
845
+ align-items: center;
846
+ /* Same 44px floor as the bar it overflows from. */
847
+ min-height: 2.75rem;
848
+ font-size: 0.9375rem;
849
+ }
850
+
851
+ .sheet-link[aria-current="page"] {
852
+ background-color: var(--color-base-300);
853
+ }
854
+
855
+ @container plinth-shell (min-width: 48rem) {
856
+ .shell-sidebar {
857
+ display: flex;
858
+ }
859
+
860
+ .shell-bottom-bar {
861
+ display: none;
862
+ }
863
+
864
+ .shell-content {
865
+ padding-bottom: 0;
866
+ }
867
+ }
868
+
869
+ @media (prefers-reduced-motion: reduce) {
870
+ .shell-sidebar {
871
+ transition: none;
872
+ }
873
+ }
874
+ </style>