@dorsk/tsumikit 0.70.0 → 0.72.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 +49 -8
- package/dist/autoresize.js +13 -0
- package/dist/clipboard.js +12 -0
- package/dist/components/atoms/Artwork.svelte +10 -3
- package/dist/components/atoms/Artwork.svelte.d.ts +2 -0
- package/dist/components/atoms/Avatar.svelte +4 -3
- package/dist/components/atoms/Badge.svelte +23 -8
- package/dist/components/atoms/Badge.svelte.d.ts +2 -1
- package/dist/components/atoms/Button.svelte +39 -11
- package/dist/components/atoms/Card.svelte +2 -0
- package/dist/components/atoms/Dot.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +2 -0
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +3 -0
- package/dist/components/atoms/Kbd.svelte +28 -16
- package/dist/components/atoms/Link.svelte +18 -4
- package/dist/components/atoms/Scrim.svelte +18 -1
- package/dist/components/atoms/Select.svelte +1 -0
- package/dist/components/atoms/Slider.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +12 -0
- package/dist/components/atoms/Switch.svelte +12 -0
- package/dist/components/layouts/AppShell.svelte +70 -39
- 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 +14 -0
- 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/Breadcrumb.svelte +44 -2
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +6 -0
- package/dist/components/molecules/FilterInput.svelte +7 -0
- package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
- package/dist/components/molecules/PullToRefresh.svelte +205 -0
- package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
- package/dist/components/molecules/SegmentedControl.svelte +44 -1
- package/dist/components/molecules/SwipeRow.svelte +349 -0
- package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
- package/dist/components/molecules/Toggle.svelte +12 -0
- package/dist/components/organisms/ConversationFeed.svelte +22 -8
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
- package/dist/components/organisms/DataTable.svelte +72 -12
- package/dist/components/organisms/DataTable.svelte.d.ts +7 -2
- package/dist/components/organisms/FilterSearchBar.svelte +4 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown.js +15 -7
- package/dist/styles/reset.css +30 -0
- package/dist/styles/syntax.css +0 -29
- package/dist/styles/tokens.css +6 -0
- package/package.json +1 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
const open: object[] = [];
|
|
3
|
+
</script>
|
|
4
|
+
|
|
1
5
|
<script lang="ts">
|
|
2
6
|
import { browser } from '../../env';
|
|
3
7
|
|
|
@@ -38,15 +42,27 @@
|
|
|
38
42
|
return () => query.removeEventListener('change', sync);
|
|
39
43
|
});
|
|
40
44
|
|
|
45
|
+
let el = $state<HTMLButtonElement | null>(null);
|
|
46
|
+
|
|
47
|
+
// Only the topmost scrim reacts, and never to an Escape aimed at a dialog
|
|
48
|
+
// or popover layered above it.
|
|
41
49
|
$effect(() => {
|
|
42
50
|
if (!browser) return;
|
|
51
|
+
const self = {};
|
|
52
|
+
open.push(self);
|
|
43
53
|
const onKeydown = (event: KeyboardEvent) => {
|
|
44
54
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
55
|
+
if (open.at(-1) !== self) return;
|
|
56
|
+
const layer = (event.target as Element | null)?.closest?.('dialog[open], [popover]');
|
|
57
|
+
if (layer && !(el && layer.contains(el))) return;
|
|
45
58
|
event.preventDefault();
|
|
46
59
|
onclose?.();
|
|
47
60
|
};
|
|
48
61
|
document.addEventListener('keydown', onKeydown);
|
|
49
|
-
return () =>
|
|
62
|
+
return () => {
|
|
63
|
+
document.removeEventListener('keydown', onKeydown);
|
|
64
|
+
open.splice(open.indexOf(self), 1);
|
|
65
|
+
};
|
|
50
66
|
});
|
|
51
67
|
</script>
|
|
52
68
|
|
|
@@ -54,6 +70,7 @@
|
|
|
54
70
|
<button
|
|
55
71
|
type="button"
|
|
56
72
|
class="scrim {klass}"
|
|
73
|
+
bind:this={el}
|
|
57
74
|
style={styleProp}
|
|
58
75
|
style:z-index={z}
|
|
59
76
|
aria-label={label}
|
|
@@ -241,6 +241,7 @@
|
|
|
241
241
|
with Button size="sm", Popover size="sm" and SegmentedControl size="sm". */
|
|
242
242
|
.select.select-sm {
|
|
243
243
|
height: var(--select-size, var(--control-height-compact));
|
|
244
|
+
min-height: var(--select-size, var(--control-height-compact));
|
|
244
245
|
}
|
|
245
246
|
.select.w-auto {
|
|
246
247
|
width: auto;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
step = 1,
|
|
16
16
|
label,
|
|
17
17
|
showValue = false,
|
|
18
|
-
format
|
|
18
|
+
format: formatProp,
|
|
19
19
|
id: idProp,
|
|
20
20
|
'aria-describedby': ariaDescribedby,
|
|
21
21
|
'aria-invalid': ariaInvalid,
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
const fallbackId = $props.id();
|
|
54
54
|
const id = $derived(idProp ?? field?.id ?? fallbackId);
|
|
55
55
|
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
56
|
+
const format = $derived(formatProp ?? ((v: number) => String(v)));
|
|
57
|
+
const valueText = $derived(
|
|
58
|
+
marks.find((m) => m.value === Number(value))?.label ?? formatProp?.(Number(value))
|
|
59
|
+
);
|
|
56
60
|
|
|
57
61
|
$effect(() => warnUnlabelled(el, 'Slider'));
|
|
58
62
|
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
{step}
|
|
79
83
|
bind:value
|
|
80
84
|
aria-label={label}
|
|
85
|
+
aria-valuetext={valueText}
|
|
81
86
|
{...rest}
|
|
82
87
|
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
83
88
|
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
@@ -212,6 +217,11 @@
|
|
|
212
217
|
outline: var(--focus-ring);
|
|
213
218
|
outline-offset: 2px;
|
|
214
219
|
}
|
|
220
|
+
@media (pointer: coarse) {
|
|
221
|
+
input[type='range'] {
|
|
222
|
+
min-height: var(--touch-target-compact);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
215
225
|
input[type='range']:disabled {
|
|
216
226
|
opacity: 0.45;
|
|
217
227
|
cursor: not-allowed;
|
|
@@ -111,4 +111,16 @@
|
|
|
111
111
|
outline: 2px solid var(--accent);
|
|
112
112
|
outline-offset: 3px;
|
|
113
113
|
}
|
|
114
|
+
@media (pointer: coarse) {
|
|
115
|
+
.interactive {
|
|
116
|
+
position: relative;
|
|
117
|
+
}
|
|
118
|
+
.interactive::before {
|
|
119
|
+
content: '';
|
|
120
|
+
position: absolute;
|
|
121
|
+
inset-block: var(--touch-slab);
|
|
122
|
+
inset-inline-start: var(--touch-slab);
|
|
123
|
+
inset-inline-end: 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
114
126
|
</style>
|
|
@@ -124,6 +124,18 @@
|
|
|
124
124
|
.switch-sm.on .knob {
|
|
125
125
|
transform: translateX(0.8rem);
|
|
126
126
|
}
|
|
127
|
+
@media (pointer: coarse) {
|
|
128
|
+
.switch {
|
|
129
|
+
position: relative;
|
|
130
|
+
}
|
|
131
|
+
.switch::before {
|
|
132
|
+
content: '';
|
|
133
|
+
position: absolute;
|
|
134
|
+
inset-block: var(--touch-slab);
|
|
135
|
+
inset-inline-start: var(--touch-slab);
|
|
136
|
+
inset-inline-end: 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
127
139
|
.switch-row {
|
|
128
140
|
display: inline-flex;
|
|
129
141
|
align-items: center;
|
|
@@ -9,11 +9,14 @@
|
|
|
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';
|
|
16
18
|
import IconButton from '../molecules/IconButton.svelte';
|
|
19
|
+
import { resizeHandle } from './resizable-panel-frame.js';
|
|
17
20
|
|
|
18
21
|
let {
|
|
19
22
|
header,
|
|
@@ -71,6 +74,8 @@
|
|
|
71
74
|
|
|
72
75
|
let isMobile = $state(false);
|
|
73
76
|
let aside = $state<HTMLElement>();
|
|
77
|
+
let menuBtn = $state<HTMLElement>();
|
|
78
|
+
let asideW = $state(0);
|
|
74
79
|
|
|
75
80
|
function setOpen(next: boolean) {
|
|
76
81
|
if (open === next) return;
|
|
@@ -103,39 +108,17 @@
|
|
|
103
108
|
}
|
|
104
109
|
let sideW = $state<number | null>(loadW());
|
|
105
110
|
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));
|
|
111
|
+
function persistW(w: number) {
|
|
112
|
+
if (browser && sidebarWidthKey) localStorage.setItem(sidebarWidthKey, String(w));
|
|
136
113
|
}
|
|
114
|
+
const measureW = () => sideW ?? aside?.getBoundingClientRect().width ?? 0;
|
|
137
115
|
// The effective desktop width: a resized px value wins over the prop default.
|
|
138
116
|
const widthCss = $derived(resizableSidebar && sideW != null ? `${sideW}px` : sidebarWidth);
|
|
117
|
+
$effect(() => {
|
|
118
|
+
if (!resizableSidebar || !aside) return;
|
|
119
|
+
void widthCss;
|
|
120
|
+
asideW = Math.round(measureW());
|
|
121
|
+
});
|
|
139
122
|
|
|
140
123
|
$effect(() => {
|
|
141
124
|
if (!browser) return;
|
|
@@ -167,6 +150,24 @@
|
|
|
167
150
|
|
|
168
151
|
// Background is inert (untabbable, hidden from AT) when the drawer is open.
|
|
169
152
|
const bgInert = $derived(open && isMobile);
|
|
153
|
+
|
|
154
|
+
const FOCUSABLE =
|
|
155
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
156
|
+
let wasInert = false;
|
|
157
|
+
$effect(() => {
|
|
158
|
+
const active = bgInert;
|
|
159
|
+
if (active === wasInert) return;
|
|
160
|
+
wasInert = active;
|
|
161
|
+
untrack(() => {
|
|
162
|
+
if (active) {
|
|
163
|
+
aside?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const current = document.activeElement;
|
|
167
|
+
if (current && current !== document.body && !aside?.contains(current)) return;
|
|
168
|
+
menuBtn?.querySelector<HTMLElement>('button')?.focus();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
170
171
|
</script>
|
|
171
172
|
|
|
172
173
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && setOpen(false)} />
|
|
@@ -183,7 +184,7 @@
|
|
|
183
184
|
{#if sidebar}
|
|
184
185
|
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
185
186
|
element, not a :global reach into the IconButton's button. -->
|
|
186
|
-
<div class="shell-menu-btn">
|
|
187
|
+
<div class="shell-menu-btn" bind:this={menuBtn}>
|
|
187
188
|
<IconButton
|
|
188
189
|
icon="menu"
|
|
189
190
|
label="Toggle navigation"
|
|
@@ -192,7 +193,9 @@
|
|
|
192
193
|
/>
|
|
193
194
|
</div>
|
|
194
195
|
{/if}
|
|
195
|
-
{
|
|
196
|
+
<div class="shell-header-content" inert={bgInert || undefined}>
|
|
197
|
+
{@render header?.()}
|
|
198
|
+
</div>
|
|
196
199
|
</header>
|
|
197
200
|
|
|
198
201
|
{#if sidebar}
|
|
@@ -213,15 +216,25 @@
|
|
|
213
216
|
>
|
|
214
217
|
{@render sidebar()}
|
|
215
218
|
{#if resizableSidebar}
|
|
219
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
216
220
|
<div
|
|
217
221
|
class="shell-sidebar-resize"
|
|
218
222
|
role="separator"
|
|
223
|
+
tabindex="0"
|
|
219
224
|
aria-label="Resize sidebar"
|
|
220
225
|
aria-orientation="vertical"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
aria-valuemin={minSidebar}
|
|
227
|
+
aria-valuemax={maxSidebar}
|
|
228
|
+
aria-valuenow={sideW ?? asideW}
|
|
229
|
+
use:resizeHandle={{
|
|
230
|
+
side: 'left',
|
|
231
|
+
min: minSidebar,
|
|
232
|
+
max: maxSidebar,
|
|
233
|
+
measure: measureW,
|
|
234
|
+
onwidth: (w) => (sideW = w),
|
|
235
|
+
oncommit: persistW,
|
|
236
|
+
onactive: (active) => (dragging = active)
|
|
237
|
+
}}
|
|
225
238
|
></div>
|
|
226
239
|
{/if}
|
|
227
240
|
</aside>
|
|
@@ -238,6 +251,7 @@
|
|
|
238
251
|
|
|
239
252
|
<style>
|
|
240
253
|
.shell {
|
|
254
|
+
--shell-header-h: calc(var(--header-h) + var(--safe-top));
|
|
241
255
|
min-height: 100dvh;
|
|
242
256
|
display: grid;
|
|
243
257
|
grid-template-columns: minmax(0, 1fr);
|
|
@@ -256,7 +270,7 @@
|
|
|
256
270
|
align-items: center;
|
|
257
271
|
gap: var(--sp-3);
|
|
258
272
|
min-width: 0;
|
|
259
|
-
height: var(--header-h);
|
|
273
|
+
height: var(--shell-header-h);
|
|
260
274
|
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
261
275
|
padding-top: var(--safe-top);
|
|
262
276
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
@@ -266,6 +280,12 @@
|
|
|
266
280
|
.shell-header > :global(*) {
|
|
267
281
|
min-width: 0;
|
|
268
282
|
}
|
|
283
|
+
.shell-header-content {
|
|
284
|
+
display: contents;
|
|
285
|
+
}
|
|
286
|
+
.shell-header-content > :global(*) {
|
|
287
|
+
min-width: 0;
|
|
288
|
+
}
|
|
269
289
|
.shell-menu-btn {
|
|
270
290
|
display: inline-flex;
|
|
271
291
|
align-items: center;
|
|
@@ -399,9 +419,14 @@
|
|
|
399
419
|
transition: background 0.12s var(--ease);
|
|
400
420
|
}
|
|
401
421
|
.shell-sidebar-resize:hover::after,
|
|
422
|
+
.shell-sidebar-resize:focus-visible::after,
|
|
402
423
|
.shell.dragging .shell-sidebar-resize::after {
|
|
403
424
|
background: var(--accent);
|
|
404
425
|
}
|
|
426
|
+
.shell-sidebar-resize:focus-visible {
|
|
427
|
+
outline: 2px solid var(--accent);
|
|
428
|
+
outline-offset: -2px;
|
|
429
|
+
}
|
|
405
430
|
}
|
|
406
431
|
|
|
407
432
|
/* Tablet: the persistent column narrows to an icon rail so the content and
|
|
@@ -417,4 +442,10 @@
|
|
|
417
442
|
display: none;
|
|
418
443
|
}
|
|
419
444
|
}
|
|
445
|
+
|
|
446
|
+
@media (prefers-reduced-motion: reduce) {
|
|
447
|
+
.shell-sidebar {
|
|
448
|
+
transition: none;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
420
451
|
</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>
|
|
@@ -594,10 +594,24 @@
|
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
+
/* Coarse pointers: an invisible slab grows the chevron towards --touch-target. */
|
|
598
|
+
@media (pointer: coarse) {
|
|
599
|
+
.collapse-control::before {
|
|
600
|
+
content: '';
|
|
601
|
+
position: absolute;
|
|
602
|
+
inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
603
|
+
inset-inline: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
597
607
|
@media (prefers-reduced-motion: reduce) {
|
|
598
608
|
.panel {
|
|
599
609
|
transition: none;
|
|
600
610
|
animation: none;
|
|
601
611
|
}
|
|
612
|
+
.overlay .panel,
|
|
613
|
+
.overlay.right .panel {
|
|
614
|
+
animation: none;
|
|
615
|
+
}
|
|
602
616
|
}
|
|
603
617
|
</style>
|