@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.
Files changed (84) hide show
  1. package/README.md +54 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +7 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +7 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  18. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  19. package/dist/components/layouts/AppShell.svelte +72 -40
  20. package/dist/components/layouts/Container.svelte +25 -3
  21. package/dist/components/layouts/MasterDetail.svelte +59 -33
  22. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  23. package/dist/components/layouts/NavItem.svelte +8 -4
  24. package/dist/components/layouts/NavSection.svelte +8 -2
  25. package/dist/components/layouts/ResizablePanel.svelte +19 -4
  26. package/dist/components/layouts/TabBar.svelte +217 -0
  27. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  28. package/dist/components/layouts/Toolbar.svelte +5 -0
  29. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  30. package/dist/components/layouts/toolbar-roving.js +47 -1
  31. package/dist/components/molecules/Accordion.svelte +2 -1
  32. package/dist/components/molecules/AttachmentList.svelte +1 -0
  33. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  34. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  35. package/dist/components/molecules/CapBar.svelte +3 -1
  36. package/dist/components/molecules/Composer.svelte +27 -5
  37. package/dist/components/molecules/Drawer.svelte +10 -1
  38. package/dist/components/molecules/Dropzone.svelte +2 -1
  39. package/dist/components/molecules/FilterInput.svelte +17 -1
  40. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Metric.svelte +28 -3
  42. package/dist/components/molecules/Modal.svelte +20 -4
  43. package/dist/components/molecules/Popover.svelte +7 -3
  44. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  45. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  46. package/dist/components/molecules/SectionHeader.svelte +29 -14
  47. package/dist/components/molecules/SeekSlider.svelte +370 -0
  48. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  49. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  50. package/dist/components/molecules/SelectButton.svelte +33 -22
  51. package/dist/components/molecules/SwipeRow.svelte +349 -0
  52. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  53. package/dist/components/molecules/Tabs.svelte +11 -5
  54. package/dist/components/molecules/Timestamp.svelte +1 -1
  55. package/dist/components/molecules/Toaster.svelte +70 -13
  56. package/dist/components/molecules/Tooltip.svelte +19 -10
  57. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  58. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  59. package/dist/components/molecules/combobox-keyboard.js +2 -1
  60. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  61. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  62. package/dist/components/organisms/DataTable.svelte +22 -5
  63. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  64. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  65. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.js +7 -0
  68. package/dist/markdown.js +15 -7
  69. package/dist/media-time.d.ts +6 -0
  70. package/dist/media-time.js +25 -0
  71. package/dist/query/edit.js +1 -16
  72. package/dist/query/parser.js +68 -28
  73. package/dist/query/query.js +9 -5
  74. package/dist/query/quotes.d.ts +24 -0
  75. package/dist/query/quotes.js +78 -0
  76. package/dist/query/single.js +1 -3
  77. package/dist/query/suggest.js +25 -32
  78. package/dist/stores/fontscale.svelte.js +3 -2
  79. package/dist/stores/storage.d.ts +2 -0
  80. package/dist/stores/storage.js +16 -0
  81. package/dist/stores/theme.svelte.js +4 -7
  82. package/dist/styles/reset.css +30 -0
  83. package/dist/styles/syntax.css +0 -29
  84. 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
- // `inert` so focus stays in the drawer. `bind:open` to drive it yourself.
13
- // Switching to desktop auto-closes the drawer. Safe-area insets are honored.
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(localStorage.getItem(sidebarWidthKey));
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
- let rafId = 0;
107
- let lastX = 0;
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
- {@render header?.()}
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
- onpointerdown={startDrag}
222
- onpointermove={onDrag}
223
- onpointerup={endDrag}
224
- onpointercancel={endDrag}
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(100vw - var(--ct-inset-l) - var(--ct-inset-r));
96
- margin-inline: calc(50% - 50vw + var(--ct-inset-l)) calc(50% - 50vw + var(--ct-inset-r));
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 renders: the list while nothing is `selected`, the
5
- // detail (with a sticky back header) once it is. The consumer drives
6
- // `selected` — typically from the URL — so each pane can be its own route.
7
- import type { Snippet } from 'svelte';
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
- {#if showList}
88
- <aside class="md-list" aria-label={listLabel}>
89
- {@render list()}
90
- </aside>
91
- {/if}
92
- {#if showDetail}
93
- <section class="md-detail">
94
- {#if mobile}
95
- <header class="md-detail-header">
96
- <button type="button" class="md-back" onclick={() => onback?.()}>
97
- <Icon name="chevron-left" />
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
- </div>
116
- </section>
117
- {/if}
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;
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  list: Snippet;
4
4
  detail: Snippet;
@@ -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
- // `title`/`aria-label` carry the name so the icon-only state stays accessible
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
- display: none;
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 class="nav-section-items">
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
- localStorage.getItem(widthKey),
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
- localStorage.getItem(`${widthKey}:collapsed`)
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) localStorage.setItem(widthKey, String(nextWidth));
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
- localStorage.setItem(`${widthKey}:collapsed`, String(collapsed));
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>