@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.
Files changed (56) hide show
  1. package/README.md +49 -8
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +10 -3
  5. package/dist/components/atoms/Artwork.svelte.d.ts +2 -0
  6. package/dist/components/atoms/Avatar.svelte +4 -3
  7. package/dist/components/atoms/Badge.svelte +23 -8
  8. package/dist/components/atoms/Badge.svelte.d.ts +2 -1
  9. package/dist/components/atoms/Button.svelte +39 -11
  10. package/dist/components/atoms/Card.svelte +2 -0
  11. package/dist/components/atoms/Dot.svelte +1 -1
  12. package/dist/components/atoms/Icon.svelte +2 -0
  13. package/dist/components/atoms/Icon.svelte.d.ts +2 -0
  14. package/dist/components/atoms/Input.svelte +3 -0
  15. package/dist/components/atoms/Kbd.svelte +28 -16
  16. package/dist/components/atoms/Link.svelte +18 -4
  17. package/dist/components/atoms/Scrim.svelte +18 -1
  18. package/dist/components/atoms/Select.svelte +1 -0
  19. package/dist/components/atoms/Slider.svelte +11 -1
  20. package/dist/components/atoms/Swatch.svelte +12 -0
  21. package/dist/components/atoms/Switch.svelte +12 -0
  22. package/dist/components/layouts/AppShell.svelte +70 -39
  23. package/dist/components/layouts/Container.svelte +25 -3
  24. package/dist/components/layouts/MasterDetail.svelte +59 -33
  25. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  26. package/dist/components/layouts/NavItem.svelte +8 -4
  27. package/dist/components/layouts/NavSection.svelte +8 -2
  28. package/dist/components/layouts/ResizablePanel.svelte +14 -0
  29. package/dist/components/layouts/TabBar.svelte +217 -0
  30. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  31. package/dist/components/layouts/Toolbar.svelte +5 -0
  32. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  33. package/dist/components/layouts/toolbar-roving.js +47 -1
  34. package/dist/components/molecules/Breadcrumb.svelte +44 -2
  35. package/dist/components/molecules/Breadcrumb.svelte.d.ts +6 -0
  36. package/dist/components/molecules/FilterInput.svelte +7 -0
  37. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  38. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  39. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  40. package/dist/components/molecules/SegmentedControl.svelte +44 -1
  41. package/dist/components/molecules/SwipeRow.svelte +349 -0
  42. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  43. package/dist/components/molecules/Toggle.svelte +12 -0
  44. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  45. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  46. package/dist/components/organisms/DataTable.svelte +72 -12
  47. package/dist/components/organisms/DataTable.svelte.d.ts +7 -2
  48. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  49. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +3 -0
  52. package/dist/markdown.js +15 -7
  53. package/dist/styles/reset.css +30 -0
  54. package/dist/styles/syntax.css +0 -29
  55. package/dist/styles/tokens.css +6 -0
  56. 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 () => document.removeEventListener('keydown', onKeydown);
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 = (v: number) => String(v),
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
- // `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';
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
- 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));
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
- {@render header?.()}
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
- onpointerdown={startDrag}
222
- onpointermove={onDrag}
223
- onpointerup={endDrag}
224
- onpointercancel={endDrag}
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(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>
@@ -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>