@viniaraujo68/plinth 0.3.0 → 0.5.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 (103) hide show
  1. package/README.md +189 -19
  2. package/dist/components/ErrorDisplay.svelte +30 -16
  3. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  4. package/dist/components/JsonView.svelte +43 -0
  5. package/dist/components/JsonView.svelte.d.ts +4 -0
  6. package/dist/components/JsonViewFrame.svelte +654 -0
  7. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  8. package/dist/components/Paginator.svelte +34 -8
  9. package/dist/components/Paginator.svelte.d.ts +10 -0
  10. package/dist/components/RequestErrorDisplay.svelte +43 -0
  11. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  12. package/dist/components/Tabs.svelte +204 -0
  13. package/dist/components/Tabs.svelte.d.ts +76 -0
  14. package/dist/components/VirtualJsonView.svelte +67 -0
  15. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  16. package/dist/components/index.d.ts +8 -1
  17. package/dist/components/index.js +11 -5
  18. package/dist/components/json-view.d.ts +70 -0
  19. package/dist/components/json-view.js +302 -0
  20. package/dist/components/request-error.d.ts +8 -0
  21. package/dist/components/request-error.js +55 -0
  22. package/dist/components/tabs.d.ts +5 -0
  23. package/dist/components/tabs.js +1 -0
  24. package/dist/http.d.ts +10 -5
  25. package/dist/http.js +34 -6
  26. package/dist/i18n/LocaleToggle.svelte +45 -0
  27. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  28. package/dist/i18n/i18n.svelte.d.ts +56 -0
  29. package/dist/i18n/i18n.svelte.js +94 -0
  30. package/dist/i18n/index.d.ts +3 -0
  31. package/dist/i18n/index.js +7 -0
  32. package/dist/i18n/translate.d.ts +30 -0
  33. package/dist/i18n/translate.js +38 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/redirect.d.ts +40 -0
  37. package/dist/redirect.js +59 -0
  38. package/dist/routing.svelte.d.ts +1 -0
  39. package/dist/routing.svelte.js +3 -0
  40. package/dist/shell/AppShell.svelte +269 -95
  41. package/dist/shell/AppShell.svelte.d.ts +3 -2
  42. package/dist/shell/NavigationProgress.svelte +85 -0
  43. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  44. package/dist/shell/TopBar.svelte +191 -0
  45. package/dist/shell/TopBar.svelte.d.ts +41 -0
  46. package/dist/shell/UserAvatar.svelte +50 -0
  47. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  48. package/dist/shell/index.d.ts +2 -0
  49. package/dist/shell/index.js +7 -3
  50. package/dist/shell/initials.d.ts +6 -0
  51. package/dist/shell/initials.js +13 -0
  52. package/dist/table/DataTable.svelte +199 -9
  53. package/dist/table/DataTable.svelte.d.ts +28 -5
  54. package/dist/table/FilterBar.svelte +417 -0
  55. package/dist/table/FilterBar.svelte.d.ts +34 -0
  56. package/dist/table/FilterRuleEditor.svelte +290 -0
  57. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  58. package/dist/table/filter-labels.d.ts +23 -0
  59. package/dist/table/filter-labels.js +30 -0
  60. package/dist/table/filter.d.ts +27 -0
  61. package/dist/table/filter.js +279 -0
  62. package/dist/table/index.d.ts +4 -1
  63. package/dist/table/index.js +5 -2
  64. package/dist/table/types.d.ts +49 -0
  65. package/dist/toast/Toaster.svelte +7 -3
  66. package/dist/toast/index.d.ts +1 -1
  67. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  68. package/dist/toast/toast-manager.svelte.js +61 -1
  69. package/dist/url-state/codec.d.ts +15 -0
  70. package/dist/url-state/codec.js +1 -0
  71. package/dist/url-state/index.d.ts +5 -0
  72. package/dist/url-state/index.js +4 -0
  73. package/dist/url-state/params-codec.d.ts +73 -0
  74. package/dist/url-state/params-codec.js +116 -0
  75. package/dist/url-state/search-params.d.ts +4 -0
  76. package/dist/url-state/search-params.js +28 -0
  77. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  78. package/dist/url-state/synced-state.svelte.js +88 -0
  79. package/dist/url-state/url-store.svelte.d.ts +12 -0
  80. package/dist/url-state/url-store.svelte.js +56 -0
  81. package/dist/virtual/DataList.svelte +215 -0
  82. package/dist/virtual/DataList.svelte.d.ts +52 -0
  83. package/dist/virtual/VirtualList.svelte +150 -0
  84. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  85. package/dist/virtual/index.d.ts +7 -0
  86. package/dist/virtual/index.js +6 -0
  87. package/dist/virtual/keyed-cache.d.ts +7 -0
  88. package/dist/virtual/keyed-cache.js +20 -0
  89. package/dist/virtual/labels.d.ts +12 -0
  90. package/dist/virtual/labels.js +6 -0
  91. package/dist/virtual/paging.d.ts +5 -0
  92. package/dist/virtual/paging.js +11 -0
  93. package/dist/virtual/range.d.ts +9 -0
  94. package/dist/virtual/range.js +31 -0
  95. package/dist/virtual/size-tree.d.ts +10 -0
  96. package/dist/virtual/size-tree.js +56 -0
  97. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  98. package/dist/virtual/virtualizer.svelte.js +295 -0
  99. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  100. package/dist/virtual/windowed-data.svelte.js +139 -0
  101. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  102. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  103. package/package.json +13 -1
@@ -6,10 +6,10 @@
6
6
  import { untrack, type Snippet } from "svelte";
7
7
  import type { ClassValue } from "svelte/elements";
8
8
  import Dialog from "../components/Dialog.svelte";
9
- import { PICK_CLIP_PATH } from "../components/pick.js";
10
9
  import { tooltip } from "../attachments/tooltip.js";
11
10
  import { getRoutingContext, resolvePathname, type ResolvedRoute } from "../routing.svelte.js";
12
11
  import { getUserContext, type UserData } from "../user/context.js";
12
+ import UserAvatar from "./UserAvatar.svelte";
13
13
 
14
14
  interface Props {
15
15
  /** The routed page. The shell owns the scroll container around it. */
@@ -51,6 +51,7 @@
51
51
  closeLabel?: string;
52
52
  /** Label of the sign-out control, which only appears when the user context can sign out. */
53
53
  logoutLabel?: string;
54
+ overviewLabel?: string;
54
55
  }
55
56
 
56
57
  /**
@@ -82,12 +83,23 @@
82
83
  moreLabel = "More",
83
84
  closeLabel = "Close",
84
85
  logoutLabel = "Sign out",
86
+ overviewLabel = "Overview",
85
87
  }: Props = $props();
86
88
 
89
+ const uid = $props.id();
90
+
87
91
  const routing = getRoutingContext();
88
92
  const user = getUserContext();
89
93
 
90
- const listed = (route: ResolvedRoute) => route.meta.title !== undefined && route.navigable;
94
+ const titled = (route: ResolvedRoute) => route.meta.title !== undefined;
95
+ const listed = (route: ResolvedRoute) => titled(route) && route.navigable;
96
+ const inScope = (route: ResolvedRoute) => user.hasAnyRole(route.meta.requiredRoles ?? []);
97
+ const isStatic = (route: ResolvedRoute) => route.pathname === route.routeId;
98
+
99
+ interface NavEntry {
100
+ route: ResolvedRoute;
101
+ children: ResolvedRoute[];
102
+ }
91
103
 
92
104
  /**
93
105
  * Home is the header entry, apart from the list — derived, never re-declared. The root is a
@@ -102,10 +114,17 @@
102
114
  * overflow. Wrapping each item in the component would leave the slot arithmetic counting entries
103
115
  * the user never sees, and "More" opening onto an empty sheet.
104
116
  */
105
- const items = $derived(
117
+ const entries: NavEntry[] = $derived(
106
118
  routing
107
119
  .children("/")
108
- .filter((route) => listed(route) && user.hasAnyRole(route.meta.requiredRoles ?? [])),
120
+ .filter((route) => titled(route) && inScope(route))
121
+ .map((route) => ({
122
+ route,
123
+ children: routing
124
+ .children(route.routeId)
125
+ .filter((child) => listed(child) && isStatic(child) && inScope(child)),
126
+ }))
127
+ .filter((entry) => entry.route.navigable || entry.children.length > 0),
109
128
  );
110
129
 
111
130
  /**
@@ -122,6 +141,33 @@
122
141
  ? routing.routeId === "/"
123
142
  : routing.matched.some((match) => match.routeId === route.routeId);
124
143
 
144
+ const isOverviewCurrent = (entry: NavEntry) =>
145
+ isCurrent(entry.route) && !entry.children.some(isCurrent);
146
+
147
+ let openGroups = $state<Record<string, boolean>>({});
148
+ const isOpen = (route: ResolvedRoute) => openGroups[route.routeId] ?? isCurrent(route);
149
+
150
+ const enteredGroup = $derived(
151
+ entries.find((entry) => entry.children.length > 0 && isCurrent(entry.route))?.route.routeId,
152
+ );
153
+
154
+ $effect(() => {
155
+ const id = enteredGroup;
156
+ if (id === undefined) return;
157
+ untrack(() => {
158
+ openGroups[id] = true;
159
+ });
160
+ });
161
+
162
+ const toggleGroup = (route: ResolvedRoute) => {
163
+ if (collapsed) {
164
+ collapsed = false;
165
+ openGroups[route.routeId] = true;
166
+ return;
167
+ }
168
+ openGroups[route.routeId] = !isOpen(route);
169
+ };
170
+
125
171
  let content: HTMLElement | undefined = $state();
126
172
  const offsets: Record<string, number> = {};
127
173
  let returningTo: string | null = null;
@@ -152,7 +198,11 @@
152
198
  });
153
199
 
154
200
  // 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);
201
+ const navigableTopLevel = $derived(
202
+ entries.filter((entry) => entry.route.navigable).map((entry) => entry.route),
203
+ );
204
+ const barEntries = $derived(home ? [home, ...navigableTopLevel] : navigableTopLevel);
205
+ const hasGroups = $derived(entries.some((entry) => entry.children.length > 0));
156
206
  const slots = $derived(Math.max(1, bottomBarSlots));
157
207
 
158
208
  const signedInUser = $derived(user.status === "authenticated" ? user.data : null);
@@ -165,7 +215,7 @@
165
215
  const sheetChrome = $derived(
166
216
  brand !== undefined || footer !== undefined || signedInUser !== null,
167
217
  );
168
- const showMore = $derived(barEntries.length > slots || sheetChrome);
218
+ const showMore = $derived(barEntries.length > slots || sheetChrome || hasGroups);
169
219
 
170
220
  /*
171
221
  * Slot arithmetic. `bottomBarSlots` caps what the bar renders, "More" counted among it, so the
@@ -184,22 +234,14 @@
184
234
  const navSlots = $derived(showMore ? slots - 1 : slots);
185
235
  const barItems = $derived(barEntries.slice(0, navSlots));
186
236
  const overflowItems = $derived(barEntries.slice(navSlots));
237
+ const overflowed = (route: ResolvedRoute) =>
238
+ overflowItems.some((item) => item.routeId === route.routeId);
187
239
 
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`;
240
+ const sheetEntries: NavEntry[] = $derived(
241
+ [...(home ? [{ route: home, children: [] }] : []), ...entries].filter(
242
+ (entry) => entry.children.length > 0 || overflowed(entry.route),
243
+ ),
244
+ );
203
245
 
204
246
  let sheet = $state<Dialog>();
205
247
 
@@ -225,14 +267,14 @@ and because the root is its own container, the shell can be dropped into a narro
225
267
  mobile form on a desktop, which is exactly what the showcase does.
226
268
 
227
269
  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
270
+ of its own to link to (unless it heads a group), and minus anything the user context gates out. Home, when declared, is derived off the trail and rendered as the sidebar's
229
271
  header entry and as the bottom bar's first item. It is current on the exact root and nowhere else —
230
272
  every route is below it — while every other entry is current for its whole subtree, so exactly one
231
273
  entry is ever marked, in either form.
232
274
 
233
275
  The bar form has no sidebar, and so no brand, no footer and no user block. All three move into the
234
276
  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
277
+ overflowing entries, a group's children, a `brand` snippet, a `footer` snippet or a signed-in user — and not only on an
236
278
  overflow. The sheet reads brand, then the entries that did not fit, then `footer`, then the user
237
279
  block with its sign-out control. Choosing an entry dismisses it; using a footer control does not.
238
280
 
@@ -301,17 +343,73 @@ shell rather than to the window, and the content area reserves its height plus
301
343
 
302
344
  <nav class="sidebar-nav" aria-label={navLabel}>
303
345
  <ul>
304
- {#each items as item (item.routeId)}
346
+ {#each entries as entry, index (entry.route.routeId)}
347
+ {@const item = entry.route}
305
348
  <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>
349
+ {#if entry.children.length > 0}
350
+ {@const foldId = `${uid}-group-${index}`}
351
+ <button
352
+ type="button"
353
+ class={["nav-link nav-group", isCurrent(item) && "has-current"]}
354
+ aria-expanded={!collapsed && isOpen(item)}
355
+ aria-controls={foldId}
356
+ onclick={() => toggleGroup(item)}
357
+ {@attach tooltip(item.label, { disabled: !collapsed })}
358
+ >
359
+ <span class="nav-icon">{@render routeIcon(item)}</span>
360
+ <span class="nav-label collapsible">{item.label}</span>
361
+ <svg
362
+ class="nav-chevron collapsible"
363
+ viewBox="0 0 24 24"
364
+ fill="none"
365
+ stroke="currentColor"
366
+ stroke-width="2"
367
+ stroke-linecap="round"
368
+ stroke-linejoin="round"
369
+ aria-hidden="true"
370
+ >
371
+ <path d="m9 18 6-6-6-6" />
372
+ </svg>
373
+ </button>
374
+ <div id={foldId} class={["nav-fold", isOpen(item) && "open"]} inert={!isOpen(item)}>
375
+ <div>
376
+ <ul>
377
+ {#if item.navigable}
378
+ <li>
379
+ <a
380
+ class="nav-link nav-sublink"
381
+ href={resolvePathname(item.pathname)}
382
+ aria-current={isOverviewCurrent(entry) ? "page" : undefined}
383
+ >
384
+ <span class="nav-label">{overviewLabel}</span>
385
+ </a>
386
+ </li>
387
+ {/if}
388
+ {#each entry.children as child (child.routeId)}
389
+ <li>
390
+ <a
391
+ class="nav-link nav-sublink"
392
+ href={resolvePathname(child.pathname)}
393
+ aria-current={isCurrent(child) ? "page" : undefined}
394
+ >
395
+ <span class="nav-label">{child.label}</span>
396
+ </a>
397
+ </li>
398
+ {/each}
399
+ </ul>
400
+ </div>
401
+ </div>
402
+ {:else}
403
+ <a
404
+ class="nav-link"
405
+ href={resolvePathname(item.pathname)}
406
+ aria-current={isCurrent(item) ? "page" : undefined}
407
+ {@attach tooltip(item.label, { disabled: !collapsed })}
408
+ >
409
+ <span class="nav-icon">{@render routeIcon(item)}</span>
410
+ <span class="nav-label collapsible">{item.label}</span>
411
+ </a>
412
+ {/if}
315
413
  </li>
316
414
  {/each}
317
415
  </ul>
@@ -323,7 +421,7 @@ shell rather than to the window, and the content area reserves its height plus
323
421
  {/if}
324
422
 
325
423
  {#if signedInUser}
326
- {@render userBlock(signedInUser, avatarClip)}
424
+ {@render userBlock(signedInUser)}
327
425
  {/if}
328
426
  </div>
329
427
  </aside>
@@ -387,20 +485,57 @@ shell rather than to the window, and the content area reserves its height plus
387
485
  </button>
388
486
  </div>
389
487
  <div class="sheet-body">
390
- {#if overflowItems.length > 0}
488
+ {#if sheetEntries.length > 0}
391
489
  <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>
490
+ {#each sheetEntries as entry, index (entry.route.routeId)}
491
+ {@const item = entry.route}
492
+ {#if entry.children.length > 0}
493
+ {@const headingId = `${uid}-sheet-group-${index}`}
494
+ <li class="sheet-group">
495
+ <p class="sheet-heading" id={headingId}>
496
+ <span class="nav-icon">{@render routeIcon(item)}</span>
497
+ <span>{item.label}</span>
498
+ </p>
499
+ <ul aria-labelledby={headingId}>
500
+ {#if item.navigable && overflowed(item)}
501
+ <li>
502
+ <a
503
+ class="sheet-link sheet-sublink"
504
+ href={resolvePathname(item.pathname)}
505
+ aria-current={isOverviewCurrent(entry) ? "page" : undefined}
506
+ onclick={() => sheet?.close()}
507
+ >
508
+ {overviewLabel}
509
+ </a>
510
+ </li>
511
+ {/if}
512
+ {#each entry.children as child (child.routeId)}
513
+ <li>
514
+ <a
515
+ class="sheet-link sheet-sublink"
516
+ href={resolvePathname(child.pathname)}
517
+ aria-current={isCurrent(child) ? "page" : undefined}
518
+ onclick={() => sheet?.close()}
519
+ >
520
+ {child.label}
521
+ </a>
522
+ </li>
523
+ {/each}
524
+ </ul>
525
+ </li>
526
+ {:else}
527
+ <li>
528
+ <a
529
+ class="sheet-link"
530
+ href={resolvePathname(item.pathname)}
531
+ aria-current={isCurrent(item) ? "page" : undefined}
532
+ onclick={() => sheet?.close()}
533
+ >
534
+ <span class="nav-icon">{@render routeIcon(item)}</span>
535
+ <span>{item.label}</span>
536
+ </a>
537
+ </li>
538
+ {/if}
404
539
  {/each}
405
540
  </ul>
406
541
  {/if}
@@ -414,26 +549,15 @@ shell rather than to the window, and the content area reserves its height plus
414
549
  {/if}
415
550
 
416
551
  {#if signedInUser}
417
- <div class="sheet-user">{@render userBlock(signedInUser, sheetAvatarClip)}</div>
552
+ <div class="sheet-user">{@render userBlock(signedInUser)}</div>
418
553
  {/if}
419
554
  </div>
420
555
  </Dialog>
421
556
  </div>
422
557
 
423
- {#snippet userBlock(data: UserData, clipId: string)}
558
+ {#snippet userBlock(data: UserData)}
424
559
  <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>
560
+ <UserAvatar name={data.name} />
437
561
  <span class="user-identity collapsible">
438
562
  <span class="user-name">{data.name}</span>
439
563
  <span class="user-email">{data.email}</span>
@@ -610,8 +734,10 @@ shell rather than to the window, and the content area reserves its height plus
610
734
  flex-shrink: 0;
611
735
  align-items: center;
612
736
  justify-content: center;
613
- /* The icon column is exactly the collapsed width, so collapsing moves nothing. */
614
- width: var(--plinth-sidebar-collapsed-width);
737
+ /* The icon column is exactly what the collapsed rail has inside its 1px border, so collapsing
738
+ moves nothing. The full collapsed width would overhang that border by a pixel, which the
739
+ nav, scrolling vertically, turns into a sideways scrollbar under the icons. */
740
+ width: calc(var(--plinth-sidebar-collapsed-width) - 1px);
615
741
  font-size: 1.25rem;
616
742
  }
617
743
 
@@ -636,6 +762,61 @@ shell rather than to the window, and the content area reserves its height plus
636
762
  color: var(--color-primary);
637
763
  }
638
764
 
765
+ .nav-group {
766
+ width: 100%;
767
+ margin: 0;
768
+ padding: 0;
769
+ border: 0;
770
+ background: none;
771
+ font: inherit;
772
+ text-align: start;
773
+ cursor: pointer;
774
+ }
775
+
776
+ .nav-group.has-current {
777
+ color: var(--color-base-content);
778
+ }
779
+
780
+ .nav-group.has-current .nav-icon {
781
+ color: var(--color-primary);
782
+ }
783
+
784
+ .nav-chevron {
785
+ flex-shrink: 0;
786
+ width: 1rem;
787
+ height: 1rem;
788
+ margin-inline: auto 0.75rem;
789
+ transition: transform 200ms ease;
790
+ }
791
+
792
+ .nav-group[aria-expanded="true"] .nav-chevron {
793
+ transform: rotate(90deg);
794
+ }
795
+
796
+ .nav-fold {
797
+ display: grid;
798
+ grid-template-rows: 0fr;
799
+ transition: grid-template-rows 200ms ease;
800
+ }
801
+
802
+ .nav-fold.open {
803
+ grid-template-rows: 1fr;
804
+ }
805
+
806
+ .nav-fold > div {
807
+ min-height: 0;
808
+ overflow: hidden;
809
+ }
810
+
811
+ .plinth-shell.collapsed .shell-sidebar .nav-fold {
812
+ display: none;
813
+ }
814
+
815
+ .nav-sublink {
816
+ height: 2.25rem;
817
+ padding-inline-start: calc(var(--plinth-sidebar-collapsed-width) - 1px);
818
+ }
819
+
639
820
  .sidebar-footer {
640
821
  border-top: 1px solid var(--plinth-shell-line);
641
822
  }
@@ -654,37 +835,6 @@ shell rather than to the window, and the content area reserves its height plus
654
835
  padding: 0.625rem;
655
836
  }
656
837
 
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
838
  .user-identity {
689
839
  display: flex;
690
840
  flex: 1;
@@ -852,6 +1002,28 @@ shell rather than to the window, and the content area reserves its height plus
852
1002
  background-color: var(--color-base-300);
853
1003
  }
854
1004
 
1005
+ .sheet-group ul {
1006
+ margin: 0;
1007
+ padding: 0;
1008
+ list-style: none;
1009
+ }
1010
+
1011
+ .sheet-heading {
1012
+ display: flex;
1013
+ align-items: center;
1014
+ min-height: 2.75rem;
1015
+ margin: 0;
1016
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
1017
+ font-size: 0.75rem;
1018
+ font-weight: 600;
1019
+ letter-spacing: 0.04em;
1020
+ text-transform: uppercase;
1021
+ }
1022
+
1023
+ .sheet-sublink {
1024
+ padding-inline-start: calc(var(--plinth-sidebar-collapsed-width) - 1px);
1025
+ }
1026
+
855
1027
  @container plinth-shell (min-width: 48rem) {
856
1028
  .shell-sidebar {
857
1029
  display: flex;
@@ -867,7 +1039,9 @@ shell rather than to the window, and the content area reserves its height plus
867
1039
  }
868
1040
 
869
1041
  @media (prefers-reduced-motion: reduce) {
870
- .shell-sidebar {
1042
+ .shell-sidebar,
1043
+ .nav-chevron,
1044
+ .nav-fold {
871
1045
  transition: none;
872
1046
  }
873
1047
  }
@@ -43,6 +43,7 @@ interface Props {
43
43
  closeLabel?: string;
44
44
  /** Label of the sign-out control, which only appears when the user context can sign out. */
45
45
  logoutLabel?: string;
46
+ overviewLabel?: string;
46
47
  }
47
48
  /**
48
49
  * The application shell: a collapsible sidebar on wide viewports, a fixed bottom bar on narrow ones,
@@ -55,14 +56,14 @@ interface Props {
55
56
  * mobile form on a desktop, which is exactly what the showcase does.
56
57
  *
57
58
  * Entries come from `routing.children("/")`, minus plumbing (no `title`), minus any route with no page
58
- * 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
59
+ * of its own to link to (unless it heads a group), and minus anything the user context gates out. Home, when declared, is derived off the trail and rendered as the sidebar's
59
60
  * header entry and as the bottom bar's first item. It is current on the exact root and nowhere else —
60
61
  * every route is below it — while every other entry is current for its whole subtree, so exactly one
61
62
  * entry is ever marked, in either form.
62
63
  *
63
64
  * The bar form has no sidebar, and so no brand, no footer and no user block. All three move into the
64
65
  * sheet behind the "More" item, which is therefore shown whenever there is anything to put there —
65
- * overflowing entries, a `brand` snippet, a `footer` snippet or a signed-in user — and not only on an
66
+ * overflowing entries, a group's children, a `brand` snippet, a `footer` snippet or a signed-in user — and not only on an
66
67
  * overflow. The sheet reads brand, then the entries that did not fit, then `footer`, then the user
67
68
  * block with its sign-out control. Choosing an entry dismisses it; using a footer control does not.
68
69
  *
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** Whether a navigation is under way — `navigating.to !== null` from `$app/state`, typically. */
4
+ active: boolean;
5
+ /**
6
+ * How long a navigation has to take before the line shows, in milliseconds. Most client-side
7
+ * navigations finish well inside it, and a line that flashes for every one of them reads as
8
+ * flicker, not as progress.
9
+ */
10
+ delay?: number;
11
+ }
12
+
13
+ let { active, delay = 150 }: Props = $props();
14
+
15
+ let shown = $state(false);
16
+
17
+ $effect(() => {
18
+ if (!active) {
19
+ shown = false;
20
+ return;
21
+ }
22
+ const timer = setTimeout(() => (shown = true), delay);
23
+ return () => clearTimeout(timer);
24
+ });
25
+ </script>
26
+
27
+ <!--
28
+ @component
29
+ A thin line sweeping across the very top of the window while a navigation is taking a while — the
30
+ only feedback a client-side navigation gives that something is happening between the click and the
31
+ next page. It shows only once the navigation has taken longer than `delay`.
32
+
33
+ The library imports nothing from SvelteKit, so the app says when a navigation is under way:
34
+
35
+ ```svelte
36
+ <script>
37
+ import { navigating } from "$app/state";
38
+ </script>
39
+
40
+ <NavigationProgress active={navigating.to !== null} />
41
+ ```
42
+
43
+ It is decoration — the page that is loading says so where it matters — so it stays hidden from
44
+ assistive technology, and stops sweeping for a reader who asked for less motion.
45
+ -->
46
+ {#if shown}
47
+ <div class="plinth-navigation-progress" aria-hidden="true"><span></span></div>
48
+ {/if}
49
+
50
+ <style>
51
+ .plinth-navigation-progress {
52
+ position: fixed;
53
+ inset: 0 0 auto;
54
+ z-index: 60;
55
+ height: 2px;
56
+ overflow: hidden;
57
+ pointer-events: none;
58
+ }
59
+
60
+ .plinth-navigation-progress span {
61
+ display: block;
62
+ width: 40%;
63
+ height: 100%;
64
+ border-radius: 0 2px 2px 0;
65
+ background: linear-gradient(90deg, transparent, var(--color-primary));
66
+ animation: plinth-navigation-sweep 1.1s ease-in-out infinite;
67
+ }
68
+
69
+ @keyframes plinth-navigation-sweep {
70
+ from {
71
+ transform: translateX(-100%);
72
+ }
73
+ to {
74
+ transform: translateX(250%);
75
+ }
76
+ }
77
+
78
+ @media (prefers-reduced-motion: reduce) {
79
+ .plinth-navigation-progress span {
80
+ width: 100%;
81
+ background: var(--color-primary);
82
+ animation: none;
83
+ }
84
+ }
85
+ </style>
@@ -0,0 +1,31 @@
1
+ interface Props {
2
+ /** Whether a navigation is under way — `navigating.to !== null` from `$app/state`, typically. */
3
+ active: boolean;
4
+ /**
5
+ * How long a navigation has to take before the line shows, in milliseconds. Most client-side
6
+ * navigations finish well inside it, and a line that flashes for every one of them reads as
7
+ * flicker, not as progress.
8
+ */
9
+ delay?: number;
10
+ }
11
+ /**
12
+ * A thin line sweeping across the very top of the window while a navigation is taking a while — the
13
+ * only feedback a client-side navigation gives that something is happening between the click and the
14
+ * next page. It shows only once the navigation has taken longer than `delay`.
15
+ *
16
+ * The library imports nothing from SvelteKit, so the app says when a navigation is under way:
17
+ *
18
+ * ```svelte
19
+ * <script>
20
+ * import { navigating } from "$app/state";
21
+ * </script>
22
+ *
23
+ * <NavigationProgress active={navigating.to !== null} />
24
+ * ```
25
+ *
26
+ * It is decoration — the page that is loading says so where it matters — so it stays hidden from
27
+ * assistive technology, and stops sweeping for a reader who asked for less motion.
28
+ */
29
+ declare const NavigationProgress: import("svelte").Component<Props, {}, "">;
30
+ type NavigationProgress = ReturnType<typeof NavigationProgress>;
31
+ export default NavigationProgress;