@dorsk/tsumikit 0.68.1 → 0.70.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 CHANGED
@@ -334,7 +334,8 @@ bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger
334
334
  are exported for trigger-char detection and token replacement).
335
335
 
336
336
  **Organisms:** ConversationPanel / ConversationFeed (chat column + sticky
337
- feed, see below), DataTable (generic `<T>`, typed columns + cell snippets;
337
+ feed, see below), DataTable (generic `<T>`, typed columns + cell snippets; `headerSnippets[key]`
338
+ replaces a header cell (a select-all checkbox);
338
339
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
339
340
  `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
340
341
  `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
@@ -346,8 +347,12 @@ for a hover/focus-revealed trailing cell (always visible on touch; under
346
347
  hooks on head/row/cell; `responsive="stack"` turns rows into cards below
347
348
  `stackBelow` (48rem, measured on the table's own box) using `Column.role`
348
349
  `title | detail | meta | actions | hidden` — detail cells get a `data-label`
349
- prefix, the `<table>` stays a table for assistive tech), FilterSearchBar /
350
- FilterInput (`size="sm"` compact bar on `--control-height-compact`,
350
+ prefix, `Column.stackLabel: false` drops it so metas join on one `·` line
351
+ and details share lines, `--dt-meta-font` restyles meta values (e.g. `var(--font-sans)`; a
352
+ literal `inherit` falls back to mono), the `<table>`
353
+ stays a table for assistive tech), FilterSearchBar /
354
+ FilterInput (`size="sm"` compact bar on `--control-height-compact`, text
355
+ floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
351
356
  `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
352
357
  outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
353
358
  `key="cwd"` puts FilterInput in single-key mode — `value` is the bare value
@@ -360,7 +365,11 @@ sidebar on desktop, overlay drawer on mobile, optionally resizable;
360
365
  the content column only, `stickySidebar` pins it to the viewport, and
361
366
  `sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
362
367
  children are `min-width: 0` so a wide title/actions row can't widen the grid on
363
- mobile), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
368
+ mobile; the mobile drawer's state is `bind:open` with `onOpenChange(open)`, and
369
+ `closeOnNavigate` (default `true`) closes it when a plain same-origin link inside
370
+ it is clicked — a router can also close it, e.g. SvelteKit
371
+ `afterNavigate(() => (navOpen = false))` with `<AppShell bind:open={navOpen}>`
372
+ to cover `goto` and back/forward), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
364
373
  icon/emoji + label + count badge; `placement="bar"` pins it to the bottom edge
365
374
  over `--z-nav` with `--safe-bottom` padding, `orientation` stacks or inlines the
366
375
  glyph, `maxWidth` centres the row; items without `href` are buttons that set a
@@ -7,10 +7,11 @@
7
7
  // nav content collapse to icons.
8
8
  // • mobile: the sidebar becomes an off-canvas drawer that slides in over a
9
9
  // scrim; AppShell renders the hamburger toggle for you (shown only on
10
- // mobile), locks body scroll while open, closes on Escape / scrim click,
11
- // and marks the rest of the page `inert` so focus stays in the drawer.
10
+ // mobile), locks body scroll while open, closes on Escape / scrim click /
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.
12
13
  // Switching to desktop auto-closes the drawer. Safe-area insets are honored.
13
- import type { Snippet } from 'svelte';
14
+ import { untrack, type Snippet } from 'svelte';
14
15
  import { browser } from '../../env';
15
16
  import IconButton from '../molecules/IconButton.svelte';
16
17
 
@@ -28,6 +29,9 @@
28
29
  layout = 'header-top',
29
30
  stickySidebar = false,
30
31
  sidebarPadding = 'md',
32
+ open = $bindable(false),
33
+ onOpenChange,
34
+ closeOnNavigate = true,
31
35
  class: klass = '',
32
36
  style: styleProp = '',
33
37
  }: {
@@ -54,12 +58,42 @@
54
58
  stickySidebar?: boolean;
55
59
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
56
60
  sidebarPadding?: 'none' | 'sm' | 'md';
61
+ /** Mobile drawer state; reset to `false` when the viewport reaches 48rem. */
62
+ open?: boolean;
63
+ /** Fires when AppShell itself opens or closes the drawer. */
64
+ onOpenChange?: (open: boolean) => void;
65
+ /** Close the mobile drawer when a same-origin link inside it is clicked
66
+ * (plain left click, no modifier keys, no `target` / `download`). */
67
+ closeOnNavigate?: boolean;
57
68
  class?: string;
58
69
  style?: string;
59
70
  } = $props();
60
71
 
61
- let open = $state(false);
62
72
  let isMobile = $state(false);
73
+ let aside = $state<HTMLElement>();
74
+
75
+ function setOpen(next: boolean) {
76
+ if (open === next) return;
77
+ open = next;
78
+ onOpenChange?.(next);
79
+ }
80
+
81
+ function onSidebarClick(e: MouseEvent) {
82
+ if (!closeOnNavigate || !open) return;
83
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
84
+ const link = (e.target as Element | null)?.closest?.('a[href]');
85
+ if (!link) return;
86
+ const target = link.getAttribute('target');
87
+ if ((target && target !== '_self') || link.hasAttribute('download')) return;
88
+ let url: URL;
89
+ try {
90
+ url = new URL(link.getAttribute('href') ?? '', document.baseURI);
91
+ } catch {
92
+ return;
93
+ }
94
+ if (url.origin !== location.origin) return;
95
+ setOpen(false);
96
+ }
63
97
 
64
98
  // --- resizable sidebar (desktop) ---
65
99
  function loadW(): number | null {
@@ -108,13 +142,20 @@
108
142
  const mq = window.matchMedia('(max-width: 47.999rem)');
109
143
  const sync = () => {
110
144
  isMobile = mq.matches;
111
- if (!isMobile) open = false; // desktop: drawer concept doesn't apply
145
+ if (!isMobile) setOpen(false);
112
146
  };
113
- sync();
147
+ untrack(sync);
114
148
  mq.addEventListener('change', sync);
115
149
  return () => mq.removeEventListener('change', sync);
116
150
  });
117
151
 
152
+ $effect(() => {
153
+ const el = aside;
154
+ if (!el) return;
155
+ el.addEventListener('click', onSidebarClick);
156
+ return () => el.removeEventListener('click', onSidebarClick);
157
+ });
158
+
118
159
  // Lock body scroll while the mobile drawer is open.
119
160
  $effect(() => {
120
161
  if (!browser) return;
@@ -128,7 +169,7 @@
128
169
  const bgInert = $derived(open && isMobile);
129
170
  </script>
130
171
 
131
- <svelte:window onkeydown={(e) => e.key === 'Escape' && (open = false)} />
172
+ <svelte:window onkeydown={(e) => e.key === 'Escape' && setOpen(false)} />
132
173
 
133
174
  <div
134
175
  class="shell {klass}"
@@ -147,7 +188,7 @@
147
188
  icon="menu"
148
189
  label="Toggle navigation"
149
190
  aria-expanded={open}
150
- onclick={() => (open = !open)}
191
+ onclick={() => setOpen(!open)}
151
192
  />
152
193
  </div>
153
194
  {/if}
@@ -160,7 +201,7 @@
160
201
  class:show={open && isMobile}
161
202
  tabindex="-1"
162
203
  aria-hidden="true"
163
- onclick={() => (open = false)}
204
+ onclick={() => setOpen(false)}
164
205
  ></button>
165
206
  <aside
166
207
  class="shell-sidebar"
@@ -168,6 +209,7 @@
168
209
  data-padding={sidebarPadding}
169
210
  aria-label={navLabel}
170
211
  inert={isMobile && !open ? true : undefined}
212
+ bind:this={aside}
171
213
  >
172
214
  {@render sidebar()}
173
215
  {#if resizableSidebar}
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  header?: Snippet;
4
4
  sidebar?: Snippet;
@@ -23,9 +23,16 @@ type $$ComponentProps = {
23
23
  stickySidebar?: boolean;
24
24
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
25
25
  sidebarPadding?: 'none' | 'sm' | 'md';
26
+ /** Mobile drawer state; reset to `false` when the viewport reaches 48rem. */
27
+ open?: boolean;
28
+ /** Fires when AppShell itself opens or closes the drawer. */
29
+ onOpenChange?: (open: boolean) => void;
30
+ /** Close the mobile drawer when a same-origin link inside it is clicked
31
+ * (plain left click, no modifier keys, no `target` / `download`). */
32
+ closeOnNavigate?: boolean;
26
33
  class?: string;
27
34
  style?: string;
28
35
  };
29
- declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "open">;
30
37
  type AppShell = ReturnType<typeof AppShell>;
31
38
  export default AppShell;
@@ -524,6 +524,14 @@
524
524
  outline: none;
525
525
  min-width: 0;
526
526
  }
527
+ @media (pointer: coarse) {
528
+ .fi__input {
529
+ font-size: max(16px, var(--fs-md));
530
+ }
531
+ .fi--sm .fi__input {
532
+ font-size: max(16px, var(--fs-sm));
533
+ }
534
+ }
527
535
  .fi__input::placeholder {
528
536
  color: var(--text-faint);
529
537
  }
@@ -21,6 +21,9 @@
21
21
  /** Placement inside the stacked card (`responsive="stack"` only). Columns
22
22
  * without a role render as `label: value` detail lines. */
23
23
  role?: 'title' | 'detail' | 'meta' | 'actions' | 'hidden';
24
+ /** `false` drops the stacked label: meta cells join on one `·` line and
25
+ * detail cells share lines (`responsive="stack"` only, default true). */
26
+ stackLabel?: boolean;
24
27
  }
25
28
  </script>
26
29
 
@@ -40,6 +43,7 @@
40
43
  onrowclick,
41
44
  onsort,
42
45
  cellSnippets = {},
46
+ headerSnippets = {},
43
47
  empty = 'No data.',
44
48
  stickyHeader = false,
45
49
  stickyOffset,
@@ -69,6 +73,8 @@
69
73
  * table only reflects the indicator and emits; it does not reorder rows. */
70
74
  onsort?: (key: string, dir: 'asc' | 'desc') => void;
71
75
  cellSnippets?: Record<string, Snippet<[T]>>;
76
+ /** Replaces a column's header content (e.g. a select-all checkbox). */
77
+ headerSnippets?: Record<string, Snippet<[Column<T>]>>;
72
78
  empty?: string | Snippet;
73
79
  stickyHeader?: boolean;
74
80
  /** Distance from the scroll container's top for the sticky header, e.g.
@@ -210,7 +216,9 @@
210
216
  : 'none'
211
217
  : undefined}
212
218
  >
213
- {#if col.sortable}
219
+ {#if headerSnippets[col.key]}
220
+ {@render headerSnippets[col.key](col)}
221
+ {:else if col.sortable}
214
222
  <button type="button" class="dt-sort" onclick={() => toggleSort(col)}>
215
223
  <span>{col.label}</span>
216
224
  <span class="dt-arrow" class:active={sortKey === col.key}>
@@ -264,6 +272,7 @@
264
272
  data-hide-below={col.hideBelow}
265
273
  data-role={responsive === 'stack' ? (col.role ?? 'detail') : undefined}
266
274
  data-label={responsive === 'stack' ? col.label : undefined}
275
+ data-stack-label={responsive === 'stack' && col.stackLabel === false ? 'false' : undefined}
267
276
  class:truncate={col.truncate}
268
277
  class:nowrap={col.nowrap}
269
278
  style:text-align={stacked ? undefined : (col.align ?? 'left')}
@@ -528,12 +537,27 @@
528
537
  [data-stacked] td[data-role='meta'] {
529
538
  flex: none;
530
539
  order: 1;
531
- font-family: var(--font-mono);
540
+ font-family: var(--dt-meta-font, var(--font-mono));
532
541
  font-size: var(--fs-xs);
533
542
  color: var(--text-muted);
534
543
  }
535
544
  [data-stacked] td[data-role='meta']::before {
536
545
  content: attr(data-label) ' ';
546
+ font-family: var(--font-sans);
547
+ color: var(--text-faint);
548
+ }
549
+ [data-stacked] td[data-role='detail'][data-stack-label='false'] {
550
+ flex: 0 1 auto;
551
+ }
552
+ [data-stacked] td[data-stack-label='false']::before {
553
+ content: none;
554
+ }
555
+ [data-stacked] td[data-role='meta'] ~ td[data-role='meta'][data-stack-label='false'] {
556
+ margin-inline-start: calc(var(--sp-1) - var(--sp-3));
557
+ }
558
+ [data-stacked] td[data-role='meta'] ~ td[data-role='meta'][data-stack-label='false']::before {
559
+ content: '·';
560
+ margin-inline-end: var(--sp-1);
537
561
  color: var(--text-faint);
538
562
  }
539
563
  [data-stacked] td[data-role='actions'],
@@ -19,6 +19,9 @@ export interface Column<T> {
19
19
  /** Placement inside the stacked card (`responsive="stack"` only). Columns
20
20
  * without a role render as `label: value` detail lines. */
21
21
  role?: 'title' | 'detail' | 'meta' | 'actions' | 'hidden';
22
+ /** `false` drops the stacked label: meta cells join on one `·` line and
23
+ * detail cells share lines (`responsive="stack"` only, default true). */
24
+ stackLabel?: boolean;
22
25
  }
23
26
  import type { Snippet } from 'svelte';
24
27
  declare function $$render<T>(): {
@@ -32,6 +35,8 @@ declare function $$render<T>(): {
32
35
  * table only reflects the indicator and emits; it does not reorder rows. */
33
36
  onsort?: (key: string, dir: "asc" | "desc") => void;
34
37
  cellSnippets?: Record<string, Snippet<[T]>>;
38
+ /** Replaces a column's header content (e.g. a select-all checkbox). */
39
+ headerSnippets?: Record<string, Snippet<[Column<T>]>>;
35
40
  empty?: string | Snippet;
36
41
  stickyHeader?: boolean;
37
42
  /** Distance from the scroll container's top for the sticky header, e.g.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.68.1",
3
+ "version": "0.70.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",