@viniaraujo68/plinth 0.3.1 → 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.
- package/README.md +189 -19
- package/dist/components/ErrorDisplay.svelte +30 -16
- package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
- package/dist/components/JsonView.svelte +43 -0
- package/dist/components/JsonView.svelte.d.ts +4 -0
- package/dist/components/JsonViewFrame.svelte +654 -0
- package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
- package/dist/components/Paginator.svelte +34 -8
- package/dist/components/Paginator.svelte.d.ts +10 -0
- package/dist/components/RequestErrorDisplay.svelte +43 -0
- package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
- package/dist/components/Tabs.svelte +204 -0
- package/dist/components/Tabs.svelte.d.ts +76 -0
- package/dist/components/VirtualJsonView.svelte +67 -0
- package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
- package/dist/components/index.d.ts +8 -1
- package/dist/components/index.js +11 -5
- package/dist/components/json-view.d.ts +70 -0
- package/dist/components/json-view.js +302 -0
- package/dist/components/request-error.d.ts +8 -0
- package/dist/components/request-error.js +55 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +1 -0
- package/dist/http.d.ts +10 -5
- package/dist/http.js +34 -6
- package/dist/i18n/LocaleToggle.svelte +45 -0
- package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
- package/dist/i18n/i18n.svelte.d.ts +56 -0
- package/dist/i18n/i18n.svelte.js +94 -0
- package/dist/i18n/index.d.ts +3 -0
- package/dist/i18n/index.js +7 -0
- package/dist/i18n/translate.d.ts +30 -0
- package/dist/i18n/translate.js +38 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/redirect.d.ts +40 -0
- package/dist/redirect.js +59 -0
- package/dist/routing.svelte.d.ts +1 -0
- package/dist/routing.svelte.js +3 -0
- package/dist/shell/AppShell.svelte +265 -93
- package/dist/shell/AppShell.svelte.d.ts +3 -2
- package/dist/shell/NavigationProgress.svelte +85 -0
- package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
- package/dist/shell/TopBar.svelte +191 -0
- package/dist/shell/TopBar.svelte.d.ts +41 -0
- package/dist/shell/UserAvatar.svelte +50 -0
- package/dist/shell/UserAvatar.svelte.d.ts +14 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/index.js +7 -3
- package/dist/shell/initials.d.ts +6 -0
- package/dist/shell/initials.js +13 -0
- package/dist/table/DataTable.svelte +199 -9
- package/dist/table/DataTable.svelte.d.ts +28 -5
- package/dist/table/FilterBar.svelte +417 -0
- package/dist/table/FilterBar.svelte.d.ts +34 -0
- package/dist/table/FilterRuleEditor.svelte +290 -0
- package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
- package/dist/table/filter-labels.d.ts +23 -0
- package/dist/table/filter-labels.js +30 -0
- package/dist/table/filter.d.ts +27 -0
- package/dist/table/filter.js +279 -0
- package/dist/table/index.d.ts +4 -1
- package/dist/table/index.js +5 -2
- package/dist/table/types.d.ts +49 -0
- package/dist/toast/Toaster.svelte +7 -3
- package/dist/toast/index.d.ts +1 -1
- package/dist/toast/toast-manager.svelte.d.ts +29 -1
- package/dist/toast/toast-manager.svelte.js +61 -1
- package/dist/url-state/codec.d.ts +15 -0
- package/dist/url-state/codec.js +1 -0
- package/dist/url-state/index.d.ts +5 -0
- package/dist/url-state/index.js +4 -0
- package/dist/url-state/params-codec.d.ts +73 -0
- package/dist/url-state/params-codec.js +116 -0
- package/dist/url-state/search-params.d.ts +4 -0
- package/dist/url-state/search-params.js +28 -0
- package/dist/url-state/synced-state.svelte.d.ts +20 -0
- package/dist/url-state/synced-state.svelte.js +88 -0
- package/dist/url-state/url-store.svelte.d.ts +12 -0
- package/dist/url-state/url-store.svelte.js +56 -0
- package/dist/virtual/DataList.svelte +215 -0
- package/dist/virtual/DataList.svelte.d.ts +52 -0
- package/dist/virtual/VirtualList.svelte +150 -0
- package/dist/virtual/VirtualList.svelte.d.ts +42 -0
- package/dist/virtual/index.d.ts +7 -0
- package/dist/virtual/index.js +6 -0
- package/dist/virtual/keyed-cache.d.ts +7 -0
- package/dist/virtual/keyed-cache.js +20 -0
- package/dist/virtual/labels.d.ts +12 -0
- package/dist/virtual/labels.js +6 -0
- package/dist/virtual/paging.d.ts +5 -0
- package/dist/virtual/paging.js +11 -0
- package/dist/virtual/range.d.ts +9 -0
- package/dist/virtual/range.js +31 -0
- package/dist/virtual/size-tree.d.ts +10 -0
- package/dist/virtual/size-tree.js +56 -0
- package/dist/virtual/virtualizer.svelte.d.ts +38 -0
- package/dist/virtual/virtualizer.svelte.js +295 -0
- package/dist/virtual/windowed-data.svelte.d.ts +38 -0
- package/dist/virtual/windowed-data.svelte.js +139 -0
- package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
- package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
- 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
|
|
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
|
|
117
|
+
const entries: NavEntry[] = $derived(
|
|
106
118
|
routing
|
|
107
119
|
.children("/")
|
|
108
|
-
.filter((route) =>
|
|
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
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
346
|
+
{#each entries as entry, index (entry.route.routeId)}
|
|
347
|
+
{@const item = entry.route}
|
|
305
348
|
<li>
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
|
488
|
+
{#if sheetEntries.length > 0}
|
|
391
489
|
<ul class="sheet-list">
|
|
392
|
-
{#each
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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
|
|
558
|
+
{#snippet userBlock(data: UserData)}
|
|
424
559
|
<div class="user-card">
|
|
425
|
-
|
|
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>
|
|
@@ -638,6 +762,61 @@ shell rather than to the window, and the content area reserves its height plus
|
|
|
638
762
|
color: var(--color-primary);
|
|
639
763
|
}
|
|
640
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
|
+
|
|
641
820
|
.sidebar-footer {
|
|
642
821
|
border-top: 1px solid var(--plinth-shell-line);
|
|
643
822
|
}
|
|
@@ -656,37 +835,6 @@ shell rather than to the window, and the content area reserves its height plus
|
|
|
656
835
|
padding: 0.625rem;
|
|
657
836
|
}
|
|
658
837
|
|
|
659
|
-
/* Carries the definition only; the shape is painted by the span that references it. Zero-sized
|
|
660
|
-
rather than `display: none`, which is the one way of hiding it that some engines take as
|
|
661
|
-
licence to skip the subtree the reference needs. */
|
|
662
|
-
.avatar-clip {
|
|
663
|
-
position: absolute;
|
|
664
|
-
width: 0;
|
|
665
|
-
height: 0;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
.avatar-initials {
|
|
669
|
-
display: flex;
|
|
670
|
-
flex-shrink: 0;
|
|
671
|
-
align-items: center;
|
|
672
|
-
justify-content: center;
|
|
673
|
-
/* Wider than the circle it replaces: the outline is inset inside its own 32-unit square, so
|
|
674
|
-
the pick drawn in a 2rem box would have come out visibly smaller than the disc did. */
|
|
675
|
-
width: 2.5rem;
|
|
676
|
-
height: 2.5rem;
|
|
677
|
-
/* The silhouette tapers to a point at the top, which puts its mass below the middle of the
|
|
678
|
-
box. A few pixels of head room is what centres the initials in the shape rather than in the
|
|
679
|
-
box that contains it. */
|
|
680
|
-
padding-top: 0.25rem;
|
|
681
|
-
/* Solid, where the disc was a 15% wash: a clipped shape is only readable by its edge, and a
|
|
682
|
-
wash that faint leaves the edge to guesswork. `--color-primary-content` is the pair the
|
|
683
|
-
theme guarantees against it in both modes. */
|
|
684
|
-
background-color: var(--color-primary);
|
|
685
|
-
color: var(--color-primary-content);
|
|
686
|
-
font-size: 0.75rem;
|
|
687
|
-
font-weight: 500;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
838
|
.user-identity {
|
|
691
839
|
display: flex;
|
|
692
840
|
flex: 1;
|
|
@@ -854,6 +1002,28 @@ shell rather than to the window, and the content area reserves its height plus
|
|
|
854
1002
|
background-color: var(--color-base-300);
|
|
855
1003
|
}
|
|
856
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
|
+
|
|
857
1027
|
@container plinth-shell (min-width: 48rem) {
|
|
858
1028
|
.shell-sidebar {
|
|
859
1029
|
display: flex;
|
|
@@ -869,7 +1039,9 @@ shell rather than to the window, and the content area reserves its height plus
|
|
|
869
1039
|
}
|
|
870
1040
|
|
|
871
1041
|
@media (prefers-reduced-motion: reduce) {
|
|
872
|
-
.shell-sidebar
|
|
1042
|
+
.shell-sidebar,
|
|
1043
|
+
.nav-chevron,
|
|
1044
|
+
.nav-fold {
|
|
873
1045
|
transition: none;
|
|
874
1046
|
}
|
|
875
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;
|