@dorsk/tsumikit 0.71.0 → 0.73.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 +54 -5
- package/dist/autoresize.js +13 -0
- package/dist/clipboard.js +12 -0
- package/dist/components/atoms/Artwork.svelte +4 -2
- package/dist/components/atoms/Avatar.svelte +4 -3
- package/dist/components/atoms/Badge.svelte +13 -0
- package/dist/components/atoms/Button.svelte +25 -9
- package/dist/components/atoms/Card.svelte +2 -0
- package/dist/components/atoms/Dot.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +7 -0
- package/dist/components/atoms/Icon.svelte.d.ts +7 -0
- package/dist/components/atoms/Kbd.svelte +28 -16
- package/dist/components/atoms/Link.svelte +6 -4
- package/dist/components/atoms/Scrim.svelte +18 -1
- package/dist/components/atoms/Slider.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +12 -0
- package/dist/components/atoms/TimeDisplay.svelte +98 -0
- package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
- package/dist/components/layouts/AppShell.svelte +72 -40
- package/dist/components/layouts/Container.svelte +25 -3
- package/dist/components/layouts/MasterDetail.svelte +59 -33
- package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
- package/dist/components/layouts/NavItem.svelte +8 -4
- package/dist/components/layouts/NavSection.svelte +8 -2
- package/dist/components/layouts/ResizablePanel.svelte +19 -4
- package/dist/components/layouts/TabBar.svelte +217 -0
- package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
- package/dist/components/layouts/Toolbar.svelte +5 -0
- package/dist/components/layouts/toolbar-roving.d.ts +16 -0
- package/dist/components/layouts/toolbar-roving.js +47 -1
- package/dist/components/molecules/Accordion.svelte +2 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -0
- package/dist/components/molecules/Breadcrumb.svelte +7 -6
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
- package/dist/components/molecules/CapBar.svelte +3 -1
- package/dist/components/molecules/Composer.svelte +27 -5
- package/dist/components/molecules/Drawer.svelte +10 -1
- package/dist/components/molecules/Dropzone.svelte +2 -1
- package/dist/components/molecules/FilterInput.svelte +17 -1
- package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
- package/dist/components/molecules/Metric.svelte +28 -3
- package/dist/components/molecules/Modal.svelte +20 -4
- package/dist/components/molecules/Popover.svelte +7 -3
- package/dist/components/molecules/PullToRefresh.svelte +205 -0
- package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
- package/dist/components/molecules/SectionHeader.svelte +29 -14
- package/dist/components/molecules/SeekSlider.svelte +370 -0
- package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
- package/dist/components/molecules/SegmentedControl.svelte +10 -6
- package/dist/components/molecules/SelectButton.svelte +33 -22
- package/dist/components/molecules/SwipeRow.svelte +349 -0
- package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
- package/dist/components/molecules/Tabs.svelte +11 -5
- package/dist/components/molecules/Timestamp.svelte +1 -1
- package/dist/components/molecules/Toaster.svelte +70 -13
- package/dist/components/molecules/Tooltip.svelte +19 -10
- package/dist/components/molecules/VolumeSlider.svelte +367 -0
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
- package/dist/components/molecules/combobox-keyboard.js +2 -1
- package/dist/components/organisms/ConversationFeed.svelte +22 -8
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
- package/dist/components/organisms/DataTable.svelte +22 -5
- package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
- package/dist/components/organisms/FilterSearchBar.svelte +4 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/markdown.js +15 -7
- package/dist/media-time.d.ts +6 -0
- package/dist/media-time.js +25 -0
- package/dist/query/edit.js +1 -16
- package/dist/query/parser.js +68 -28
- package/dist/query/query.js +9 -5
- package/dist/query/quotes.d.ts +24 -0
- package/dist/query/quotes.js +78 -0
- package/dist/query/single.js +1 -3
- package/dist/query/suggest.js +25 -32
- package/dist/stores/fontscale.svelte.js +3 -2
- package/dist/stores/storage.d.ts +2 -0
- package/dist/stores/storage.js +16 -0
- package/dist/stores/theme.svelte.js +4 -7
- package/dist/styles/reset.css +30 -0
- package/dist/styles/syntax.css +0 -29
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* "1:02 / 23:45" readout with tabular numbers. Clicking it shows the
|
|
4
|
+
* remaining time instead ("-22:43 / 23:45"). Live or unknown durations
|
|
5
|
+
* render the position only and cannot be toggled.
|
|
6
|
+
*/
|
|
7
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
8
|
+
import { formatMediaTime, isLiveDuration } from '../../media-time';
|
|
9
|
+
|
|
10
|
+
type Own = {
|
|
11
|
+
/** Position in seconds. */
|
|
12
|
+
current?: number;
|
|
13
|
+
/** Duration in seconds; `null`/`Infinity` = live or unknown. */
|
|
14
|
+
duration?: number | null;
|
|
15
|
+
/** Show time left instead of the position. */
|
|
16
|
+
remaining?: boolean;
|
|
17
|
+
/** Allow clicking to toggle `remaining`. */
|
|
18
|
+
toggle?: boolean;
|
|
19
|
+
size?: 'sm' | 'md';
|
|
20
|
+
formatTime?: (seconds: number, ref?: number) => string;
|
|
21
|
+
toggleLabel?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
current = 0,
|
|
27
|
+
duration = null,
|
|
28
|
+
remaining = $bindable(false),
|
|
29
|
+
toggle = true,
|
|
30
|
+
size = 'sm',
|
|
31
|
+
formatTime = formatMediaTime,
|
|
32
|
+
toggleLabel = 'Toggle remaining time',
|
|
33
|
+
class: klass = '',
|
|
34
|
+
...rest
|
|
35
|
+
}: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
|
|
36
|
+
|
|
37
|
+
const live = $derived(isLiveDuration(duration));
|
|
38
|
+
const total = $derived(live ? 0 : (duration as number));
|
|
39
|
+
const pos = $derived(Math.min(live ? Number.POSITIVE_INFINITY : total, Math.max(0, current)));
|
|
40
|
+
const head = $derived(live ? formatTime(pos) : remaining ? `-${formatTime(total - pos, total)}` : formatTime(pos, total));
|
|
41
|
+
const tail = $derived(live ? null : formatTime(total, total));
|
|
42
|
+
const interactive = $derived(toggle && !live);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{#if interactive}
|
|
46
|
+
<button
|
|
47
|
+
data-tsu="TimeDisplay"
|
|
48
|
+
type="button"
|
|
49
|
+
class="td td--{size} td--button {klass}"
|
|
50
|
+
class:td--remaining={remaining}
|
|
51
|
+
title={toggleLabel}
|
|
52
|
+
aria-pressed={remaining}
|
|
53
|
+
onclick={() => (remaining = !remaining)}
|
|
54
|
+
{...rest}
|
|
55
|
+
>
|
|
56
|
+
<span data-part="position">{head}</span><span class="td-sep" aria-hidden="true">{' / '}</span><span data-part="duration">{tail}</span>
|
|
57
|
+
</button>
|
|
58
|
+
{:else}
|
|
59
|
+
<span data-tsu="TimeDisplay" class="td td--{size} {klass}" {...rest}>
|
|
60
|
+
<span data-part="position">{head}</span>{#if tail !== null}<span class="td-sep" aria-hidden="true">{' / '}</span><span data-part="duration">{tail}</span>{/if}
|
|
61
|
+
</span>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.td {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: baseline;
|
|
68
|
+
font-size: var(--fs-sm);
|
|
69
|
+
font-variant-numeric: tabular-nums;
|
|
70
|
+
color: var(--text-muted);
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
}
|
|
73
|
+
.td--md {
|
|
74
|
+
font-size: var(--fs-base);
|
|
75
|
+
}
|
|
76
|
+
.td--button {
|
|
77
|
+
border: 0;
|
|
78
|
+
background: none;
|
|
79
|
+
padding: 0;
|
|
80
|
+
margin: 0;
|
|
81
|
+
font: inherit;
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
color: inherit;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
border-radius: var(--r-sm);
|
|
86
|
+
}
|
|
87
|
+
.td--button:hover {
|
|
88
|
+
color: var(--text);
|
|
89
|
+
}
|
|
90
|
+
.td--button:focus-visible {
|
|
91
|
+
outline: var(--focus-ring);
|
|
92
|
+
outline-offset: var(--focus-ring-offset);
|
|
93
|
+
}
|
|
94
|
+
.td-sep {
|
|
95
|
+
color: var(--text-faint);
|
|
96
|
+
white-space: pre;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "1:02 / 23:45" readout with tabular numbers. Clicking it shows the
|
|
3
|
+
* remaining time instead ("-22:43 / 23:45"). Live or unknown durations
|
|
4
|
+
* render the position only and cannot be toggled.
|
|
5
|
+
*/
|
|
6
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
7
|
+
type Own = {
|
|
8
|
+
/** Position in seconds. */
|
|
9
|
+
current?: number;
|
|
10
|
+
/** Duration in seconds; `null`/`Infinity` = live or unknown. */
|
|
11
|
+
duration?: number | null;
|
|
12
|
+
/** Show time left instead of the position. */
|
|
13
|
+
remaining?: boolean;
|
|
14
|
+
/** Allow clicking to toggle `remaining`. */
|
|
15
|
+
toggle?: boolean;
|
|
16
|
+
size?: 'sm' | 'md';
|
|
17
|
+
formatTime?: (seconds: number, ref?: number) => string;
|
|
18
|
+
toggleLabel?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
|
|
22
|
+
declare const TimeDisplay: import("svelte").Component<$$ComponentProps, {}, "remaining">;
|
|
23
|
+
type TimeDisplay = ReturnType<typeof TimeDisplay>;
|
|
24
|
+
export default TimeDisplay;
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
// scrim; AppShell renders the hamburger toggle for you (shown only on
|
|
10
10
|
// mobile), locks body scroll while open, closes on Escape / scrim click /
|
|
11
11
|
// a same-origin link click inside it, and marks the rest of the page
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// (all but the hamburger) `inert`. Opening focuses the first control in
|
|
13
|
+
// the drawer; closing returns focus to the toggle. `bind:open` to drive it.
|
|
14
|
+
// Switching to desktop auto-closes the drawer. Safe-area insets are honored;
|
|
15
|
+
// `--shell-header-h` is the rendered header height (incl. the top inset).
|
|
14
16
|
import { untrack, type Snippet } from 'svelte';
|
|
15
17
|
import { browser } from '../../env';
|
|
18
|
+
import { readStorage, writeStorage } from '../../stores/storage';
|
|
16
19
|
import IconButton from '../molecules/IconButton.svelte';
|
|
20
|
+
import { resizeHandle } from './resizable-panel-frame.js';
|
|
17
21
|
|
|
18
22
|
let {
|
|
19
23
|
header,
|
|
@@ -71,6 +75,8 @@
|
|
|
71
75
|
|
|
72
76
|
let isMobile = $state(false);
|
|
73
77
|
let aside = $state<HTMLElement>();
|
|
78
|
+
let menuBtn = $state<HTMLElement>();
|
|
79
|
+
let asideW = $state(0);
|
|
74
80
|
|
|
75
81
|
function setOpen(next: boolean) {
|
|
76
82
|
if (open === next) return;
|
|
@@ -98,44 +104,22 @@
|
|
|
98
104
|
// --- resizable sidebar (desktop) ---
|
|
99
105
|
function loadW(): number | null {
|
|
100
106
|
if (!browser || !sidebarWidthKey) return null;
|
|
101
|
-
const n = Number(
|
|
107
|
+
const n = Number(readStorage(sidebarWidthKey));
|
|
102
108
|
return Number.isFinite(n) && n >= minSidebar ? Math.min(n, maxSidebar) : null;
|
|
103
109
|
}
|
|
104
110
|
let sideW = $state<number | null>(loadW());
|
|
105
111
|
let dragging = $state(false);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
function startDrag(e: PointerEvent) {
|
|
109
|
-
dragging = true;
|
|
110
|
-
lastX = e.clientX;
|
|
111
|
-
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
}
|
|
114
|
-
function onDrag(e: PointerEvent) {
|
|
115
|
-
if (!dragging) return;
|
|
116
|
-
lastX = e.clientX;
|
|
117
|
-
if (rafId) return;
|
|
118
|
-
rafId = requestAnimationFrame(() => {
|
|
119
|
-
rafId = 0;
|
|
120
|
-
sideW = Math.round(Math.max(minSidebar, Math.min(lastX, maxSidebar)));
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
function endDrag(e: PointerEvent) {
|
|
124
|
-
if (!dragging) return;
|
|
125
|
-
dragging = false;
|
|
126
|
-
if (rafId) {
|
|
127
|
-
cancelAnimationFrame(rafId);
|
|
128
|
-
rafId = 0;
|
|
129
|
-
}
|
|
130
|
-
try {
|
|
131
|
-
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
132
|
-
} catch {
|
|
133
|
-
/* already released */
|
|
134
|
-
}
|
|
135
|
-
if (browser && sidebarWidthKey && sideW != null) localStorage.setItem(sidebarWidthKey, String(sideW));
|
|
112
|
+
function persistW(w: number) {
|
|
113
|
+
if (browser && sidebarWidthKey) writeStorage(sidebarWidthKey, String(w));
|
|
136
114
|
}
|
|
115
|
+
const measureW = () => sideW ?? aside?.getBoundingClientRect().width ?? 0;
|
|
137
116
|
// The effective desktop width: a resized px value wins over the prop default.
|
|
138
117
|
const widthCss = $derived(resizableSidebar && sideW != null ? `${sideW}px` : sidebarWidth);
|
|
118
|
+
$effect(() => {
|
|
119
|
+
if (!resizableSidebar || !aside) return;
|
|
120
|
+
void widthCss;
|
|
121
|
+
asideW = Math.round(measureW());
|
|
122
|
+
});
|
|
139
123
|
|
|
140
124
|
$effect(() => {
|
|
141
125
|
if (!browser) return;
|
|
@@ -167,6 +151,24 @@
|
|
|
167
151
|
|
|
168
152
|
// Background is inert (untabbable, hidden from AT) when the drawer is open.
|
|
169
153
|
const bgInert = $derived(open && isMobile);
|
|
154
|
+
|
|
155
|
+
const FOCUSABLE =
|
|
156
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
157
|
+
let wasInert = false;
|
|
158
|
+
$effect(() => {
|
|
159
|
+
const active = bgInert;
|
|
160
|
+
if (active === wasInert) return;
|
|
161
|
+
wasInert = active;
|
|
162
|
+
untrack(() => {
|
|
163
|
+
if (active) {
|
|
164
|
+
aside?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const current = document.activeElement;
|
|
168
|
+
if (current && current !== document.body && !aside?.contains(current)) return;
|
|
169
|
+
menuBtn?.querySelector<HTMLElement>('button')?.focus();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
170
172
|
</script>
|
|
171
173
|
|
|
172
174
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && setOpen(false)} />
|
|
@@ -183,7 +185,7 @@
|
|
|
183
185
|
{#if sidebar}
|
|
184
186
|
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
185
187
|
element, not a :global reach into the IconButton's button. -->
|
|
186
|
-
<div class="shell-menu-btn">
|
|
188
|
+
<div class="shell-menu-btn" bind:this={menuBtn}>
|
|
187
189
|
<IconButton
|
|
188
190
|
icon="menu"
|
|
189
191
|
label="Toggle navigation"
|
|
@@ -192,7 +194,9 @@
|
|
|
192
194
|
/>
|
|
193
195
|
</div>
|
|
194
196
|
{/if}
|
|
195
|
-
{
|
|
197
|
+
<div class="shell-header-content" inert={bgInert || undefined}>
|
|
198
|
+
{@render header?.()}
|
|
199
|
+
</div>
|
|
196
200
|
</header>
|
|
197
201
|
|
|
198
202
|
{#if sidebar}
|
|
@@ -213,15 +217,25 @@
|
|
|
213
217
|
>
|
|
214
218
|
{@render sidebar()}
|
|
215
219
|
{#if resizableSidebar}
|
|
220
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
216
221
|
<div
|
|
217
222
|
class="shell-sidebar-resize"
|
|
218
223
|
role="separator"
|
|
224
|
+
tabindex="0"
|
|
219
225
|
aria-label="Resize sidebar"
|
|
220
226
|
aria-orientation="vertical"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
227
|
+
aria-valuemin={minSidebar}
|
|
228
|
+
aria-valuemax={maxSidebar}
|
|
229
|
+
aria-valuenow={sideW ?? asideW}
|
|
230
|
+
use:resizeHandle={{
|
|
231
|
+
side: 'left',
|
|
232
|
+
min: minSidebar,
|
|
233
|
+
max: maxSidebar,
|
|
234
|
+
measure: measureW,
|
|
235
|
+
onwidth: (w) => (sideW = w),
|
|
236
|
+
oncommit: persistW,
|
|
237
|
+
onactive: (active) => (dragging = active)
|
|
238
|
+
}}
|
|
225
239
|
></div>
|
|
226
240
|
{/if}
|
|
227
241
|
</aside>
|
|
@@ -238,6 +252,7 @@
|
|
|
238
252
|
|
|
239
253
|
<style>
|
|
240
254
|
.shell {
|
|
255
|
+
--shell-header-h: calc(var(--header-h) + var(--safe-top));
|
|
241
256
|
min-height: 100dvh;
|
|
242
257
|
display: grid;
|
|
243
258
|
grid-template-columns: minmax(0, 1fr);
|
|
@@ -256,7 +271,7 @@
|
|
|
256
271
|
align-items: center;
|
|
257
272
|
gap: var(--sp-3);
|
|
258
273
|
min-width: 0;
|
|
259
|
-
height: var(--header-h);
|
|
274
|
+
height: var(--shell-header-h);
|
|
260
275
|
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
261
276
|
padding-top: var(--safe-top);
|
|
262
277
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
@@ -266,6 +281,12 @@
|
|
|
266
281
|
.shell-header > :global(*) {
|
|
267
282
|
min-width: 0;
|
|
268
283
|
}
|
|
284
|
+
.shell-header-content {
|
|
285
|
+
display: contents;
|
|
286
|
+
}
|
|
287
|
+
.shell-header-content > :global(*) {
|
|
288
|
+
min-width: 0;
|
|
289
|
+
}
|
|
269
290
|
.shell-menu-btn {
|
|
270
291
|
display: inline-flex;
|
|
271
292
|
align-items: center;
|
|
@@ -399,9 +420,14 @@
|
|
|
399
420
|
transition: background 0.12s var(--ease);
|
|
400
421
|
}
|
|
401
422
|
.shell-sidebar-resize:hover::after,
|
|
423
|
+
.shell-sidebar-resize:focus-visible::after,
|
|
402
424
|
.shell.dragging .shell-sidebar-resize::after {
|
|
403
425
|
background: var(--accent);
|
|
404
426
|
}
|
|
427
|
+
.shell-sidebar-resize:focus-visible {
|
|
428
|
+
outline: 2px solid var(--accent);
|
|
429
|
+
outline-offset: -2px;
|
|
430
|
+
}
|
|
405
431
|
}
|
|
406
432
|
|
|
407
433
|
/* Tablet: the persistent column narrows to an icon rail so the content and
|
|
@@ -417,4 +443,10 @@
|
|
|
417
443
|
display: none;
|
|
418
444
|
}
|
|
419
445
|
}
|
|
446
|
+
|
|
447
|
+
@media (prefers-reduced-motion: reduce) {
|
|
448
|
+
.shell-sidebar {
|
|
449
|
+
transition: none;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
420
452
|
</style>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// `--container-gutter` so children can bleed to the column edge with
|
|
5
5
|
// `margin-inline: calc(-1 * var(--container-gutter))`.
|
|
6
6
|
import type { Snippet } from 'svelte';
|
|
7
|
+
import { browser } from '../../env';
|
|
7
8
|
|
|
8
9
|
let {
|
|
9
10
|
as = 'div',
|
|
@@ -36,8 +37,26 @@
|
|
|
36
37
|
[key: string]: unknown;
|
|
37
38
|
} = $props();
|
|
38
39
|
|
|
40
|
+
// `100vw` includes the reserved scrollbar gutter (`scrollbar-gutter: stable`).
|
|
41
|
+
let scrollbarW = $state(0);
|
|
42
|
+
$effect(() => {
|
|
43
|
+
if (!browser || !fullWidth) return;
|
|
44
|
+
const sync = () => {
|
|
45
|
+
scrollbarW = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
|
|
46
|
+
};
|
|
47
|
+
sync();
|
|
48
|
+
window.addEventListener('resize', sync);
|
|
49
|
+
const ro = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(sync);
|
|
50
|
+
ro?.observe(document.documentElement);
|
|
51
|
+
return () => {
|
|
52
|
+
window.removeEventListener('resize', sync);
|
|
53
|
+
ro?.disconnect();
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
const style = $derived.by(() => {
|
|
40
58
|
const vars: string[] = [];
|
|
59
|
+
if (fullWidth && scrollbarW) vars.push(`--ct-scrollbar: ${scrollbarW}px`);
|
|
41
60
|
if (!fullWidth && size && size !== 'none') vars.push(`--ct-max: ${size}`);
|
|
42
61
|
if (gutter) vars.push(`--container-gutter: ${gutter}`);
|
|
43
62
|
if (fullWidth && inset) {
|
|
@@ -87,12 +106,15 @@
|
|
|
87
106
|
|
|
88
107
|
/* Break out of any centered ancestor to span the viewport (minus `inset`).
|
|
89
108
|
`margin-inline: calc(50% - 50vw)` pulls each edge out to the viewport,
|
|
90
|
-
keeping the element in normal flow (no transform/overflow side-effects).
|
|
109
|
+
keeping the element in normal flow (no transform/overflow side-effects).
|
|
110
|
+
--ct-scrollbar (measured) takes the scrollbar gutter out of `100vw`. */
|
|
91
111
|
.ct.full {
|
|
92
112
|
--ct-inset-l: 0px;
|
|
93
113
|
--ct-inset-r: 0px;
|
|
114
|
+
--ct-vw: calc(100vw - var(--ct-scrollbar, 0px));
|
|
94
115
|
max-width: none;
|
|
95
|
-
width: calc(
|
|
96
|
-
margin-inline: calc(50% -
|
|
116
|
+
width: calc(var(--ct-vw) - var(--ct-inset-l) - var(--ct-inset-r));
|
|
117
|
+
margin-inline: calc(50% - var(--ct-vw) / 2 + var(--ct-inset-l))
|
|
118
|
+
calc(50% - var(--ct-vw) / 2 + var(--ct-inset-r));
|
|
97
119
|
}
|
|
98
120
|
</style>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// List + detail columns. Above `breakpoint` (measured on this component's own
|
|
3
3
|
// box, not the viewport) both columns render and scroll independently. Below
|
|
4
|
-
// it exactly one pane
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
4
|
+
// it exactly one pane shows: the list while nothing is `selected`, the detail
|
|
5
|
+
// (with a sticky back header) once it is. Both stay mounted, so the list keeps
|
|
6
|
+
// its scroll and the focus moves to Back and returns to the opened item.
|
|
7
|
+
// The consumer drives `selected` — typically from the URL — so each pane can
|
|
8
|
+
// be its own route.
|
|
9
|
+
import { type Snippet, tick } from 'svelte';
|
|
8
10
|
import { browser } from '../../env';
|
|
9
11
|
import Icon from '../atoms/Icon.svelte';
|
|
10
12
|
|
|
@@ -49,6 +51,11 @@
|
|
|
49
51
|
|
|
50
52
|
let root = $state<HTMLElement | null>(null);
|
|
51
53
|
let mobile = $state(false);
|
|
54
|
+
let listEl = $state<HTMLElement | null>(null);
|
|
55
|
+
let backEl = $state<HTMLButtonElement | null>(null);
|
|
56
|
+
let listScroll = 0;
|
|
57
|
+
let listFocus: HTMLElement | null = null;
|
|
58
|
+
let wasSelected: boolean | undefined;
|
|
52
59
|
|
|
53
60
|
function toPx(length: string, el: HTMLElement): number {
|
|
54
61
|
const n = Number.parseFloat(length);
|
|
@@ -71,6 +78,26 @@
|
|
|
71
78
|
|
|
72
79
|
const showList = $derived(!mobile || !selected);
|
|
73
80
|
const showDetail = $derived(!mobile || selected);
|
|
81
|
+
|
|
82
|
+
$effect.pre(() => {
|
|
83
|
+
const now = selected;
|
|
84
|
+
const prev = wasSelected;
|
|
85
|
+
wasSelected = now;
|
|
86
|
+
if (!mobile || prev === undefined || prev === now) return;
|
|
87
|
+
const list = listEl;
|
|
88
|
+
if (now) {
|
|
89
|
+
listScroll = list?.scrollTop ?? 0;
|
|
90
|
+
const active = document.activeElement;
|
|
91
|
+
listFocus = active instanceof HTMLElement && list?.contains(active) ? active : null;
|
|
92
|
+
tick().then(() => backEl?.focus());
|
|
93
|
+
} else {
|
|
94
|
+
tick().then(() => {
|
|
95
|
+
if (list) list.scrollTop = listScroll;
|
|
96
|
+
if (listFocus?.isConnected) listFocus.focus({ preventScroll: true });
|
|
97
|
+
listFocus = null;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
74
101
|
</script>
|
|
75
102
|
|
|
76
103
|
<div
|
|
@@ -84,37 +111,33 @@
|
|
|
84
111
|
data-tsu="MasterDetail"
|
|
85
112
|
{...rest}
|
|
86
113
|
>
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<span>{backLabel}</span>
|
|
99
|
-
</button>
|
|
100
|
-
{#if detailHeader}
|
|
101
|
-
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
102
|
-
{/if}
|
|
103
|
-
</header>
|
|
104
|
-
{:else if detailHeader}
|
|
105
|
-
<header class="md-detail-header">
|
|
114
|
+
<aside bind:this={listEl} class="md-list" aria-label={listLabel} hidden={!showList}>
|
|
115
|
+
{@render list()}
|
|
116
|
+
</aside>
|
|
117
|
+
<section class="md-detail" hidden={!showDetail}>
|
|
118
|
+
{#if mobile}
|
|
119
|
+
<header class="md-detail-header">
|
|
120
|
+
<button bind:this={backEl} type="button" class="md-back" onclick={() => onback?.()}>
|
|
121
|
+
<Icon name="chevron-left" />
|
|
122
|
+
<span>{backLabel}</span>
|
|
123
|
+
</button>
|
|
124
|
+
{#if detailHeader}
|
|
106
125
|
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
107
|
-
</header>
|
|
108
|
-
{/if}
|
|
109
|
-
<div class="md-detail-body">
|
|
110
|
-
{#if selected}
|
|
111
|
-
{@render detail()}
|
|
112
|
-
{:else}
|
|
113
|
-
{@render empty?.()}
|
|
114
126
|
{/if}
|
|
115
|
-
</
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
</header>
|
|
128
|
+
{:else if detailHeader}
|
|
129
|
+
<header class="md-detail-header">
|
|
130
|
+
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
131
|
+
</header>
|
|
132
|
+
{/if}
|
|
133
|
+
<div class="md-detail-body">
|
|
134
|
+
{#if selected}
|
|
135
|
+
{@render detail()}
|
|
136
|
+
{:else}
|
|
137
|
+
{@render empty?.()}
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
</section>
|
|
118
141
|
</div>
|
|
119
142
|
|
|
120
143
|
<style>
|
|
@@ -142,6 +165,9 @@
|
|
|
142
165
|
display: flex;
|
|
143
166
|
flex-direction: column;
|
|
144
167
|
}
|
|
168
|
+
.md-detail[hidden] {
|
|
169
|
+
display: none;
|
|
170
|
+
}
|
|
145
171
|
.md-detail-header {
|
|
146
172
|
position: sticky;
|
|
147
173
|
top: 0;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// (≤ 8rem — e.g. the user drags AppShell's resizable sidebar down to a rail)
|
|
5
5
|
// the label hides and the icon centers, purely in CSS; a non-empty badge then
|
|
6
6
|
// collapses to a small dot indicator so the count cue survives the rail.
|
|
7
|
-
//
|
|
8
|
-
// and shows a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
7
|
+
// In the rail the label and pill stay in the accessible name (visually
|
|
8
|
+
// hidden), and `title` shows the label as a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
9
9
|
// The glyph is `iconChildren` (raw 24×24 SVG content), else `iconPath`
|
|
10
10
|
// (single path d), else the registry `icon`.
|
|
11
11
|
import type { Snippet } from 'svelte';
|
|
@@ -73,7 +73,6 @@
|
|
|
73
73
|
class:active
|
|
74
74
|
class:bar={activeStyle === 'bar'}
|
|
75
75
|
title={label}
|
|
76
|
-
aria-label={label}
|
|
77
76
|
aria-current={active ? 'page' : undefined}
|
|
78
77
|
{onclick}
|
|
79
78
|
{...rest}
|
|
@@ -184,7 +183,12 @@
|
|
|
184
183
|
}
|
|
185
184
|
.nav-label,
|
|
186
185
|
.nav-trail {
|
|
187
|
-
|
|
186
|
+
position: absolute;
|
|
187
|
+
width: 1px;
|
|
188
|
+
height: 1px;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
clip-path: inset(50%);
|
|
191
|
+
white-space: nowrap;
|
|
188
192
|
}
|
|
189
193
|
.nav-dot {
|
|
190
194
|
display: block;
|
|
@@ -21,13 +21,19 @@
|
|
|
21
21
|
class?: string;
|
|
22
22
|
style?: string;
|
|
23
23
|
} = $props();
|
|
24
|
+
|
|
25
|
+
const labelId = $props.id();
|
|
24
26
|
</script>
|
|
25
27
|
|
|
26
28
|
<div class="nav-section {klass}" data-tsu="NavSection" {...rest}>
|
|
27
29
|
{#if label}
|
|
28
|
-
<div class="nav-section-label">{label}</div>
|
|
30
|
+
<div class="nav-section-label" id={labelId}>{label}</div>
|
|
29
31
|
{/if}
|
|
30
|
-
<div
|
|
32
|
+
<div
|
|
33
|
+
class="nav-section-items"
|
|
34
|
+
role={label ? 'group' : undefined}
|
|
35
|
+
aria-labelledby={label ? labelId : undefined}
|
|
36
|
+
>
|
|
31
37
|
{@render children?.()}
|
|
32
38
|
</div>
|
|
33
39
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { untrack } from 'svelte';
|
|
4
4
|
import { browser } from '../../env';
|
|
5
|
+
import { readStorage, writeStorage } from '../../stores/storage';
|
|
5
6
|
import Icon from '../atoms/Icon.svelte';
|
|
6
7
|
import Scrim from '../atoms/Scrim.svelte';
|
|
7
8
|
import { resizeHandle, resolveLength } from './resizable-panel-frame.js';
|
|
@@ -152,7 +153,7 @@
|
|
|
152
153
|
if (!widthKey) return;
|
|
153
154
|
|
|
154
155
|
const savedWidth = parseStoredWidth(
|
|
155
|
-
|
|
156
|
+
readStorage(widthKey),
|
|
156
157
|
boundedMin,
|
|
157
158
|
boundedMax
|
|
158
159
|
);
|
|
@@ -160,7 +161,7 @@
|
|
|
160
161
|
|
|
161
162
|
if (persistCollapsed) {
|
|
162
163
|
const savedCollapsed = parseStoredCollapsed(
|
|
163
|
-
|
|
164
|
+
readStorage(`${widthKey}:collapsed`)
|
|
164
165
|
);
|
|
165
166
|
if (savedCollapsed !== undefined) collapsed = savedCollapsed;
|
|
166
167
|
}
|
|
@@ -212,7 +213,7 @@
|
|
|
212
213
|
});
|
|
213
214
|
|
|
214
215
|
function persistWidth(nextWidth: number) {
|
|
215
|
-
if (browser && widthKey)
|
|
216
|
+
if (browser && widthKey) writeStorage(widthKey, String(nextWidth));
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
function setWidth(nextWidth: number) {
|
|
@@ -292,7 +293,7 @@
|
|
|
292
293
|
}
|
|
293
294
|
collapsed = !collapsed;
|
|
294
295
|
if (browser && widthKey && persistCollapsed) {
|
|
295
|
-
|
|
296
|
+
writeStorage(`${widthKey}:collapsed`, String(collapsed));
|
|
296
297
|
}
|
|
297
298
|
}
|
|
298
299
|
</script>
|
|
@@ -594,10 +595,24 @@
|
|
|
594
595
|
}
|
|
595
596
|
}
|
|
596
597
|
|
|
598
|
+
/* Coarse pointers: an invisible slab grows the chevron towards --touch-target. */
|
|
599
|
+
@media (pointer: coarse) {
|
|
600
|
+
.collapse-control::before {
|
|
601
|
+
content: '';
|
|
602
|
+
position: absolute;
|
|
603
|
+
inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
604
|
+
inset-inline: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
597
608
|
@media (prefers-reduced-motion: reduce) {
|
|
598
609
|
.panel {
|
|
599
610
|
transition: none;
|
|
600
611
|
animation: none;
|
|
601
612
|
}
|
|
613
|
+
.overlay .panel,
|
|
614
|
+
.overlay.right .panel {
|
|
615
|
+
animation: none;
|
|
616
|
+
}
|
|
602
617
|
}
|
|
603
618
|
</style>
|