@dorsk/tsumikit 0.21.0 → 0.23.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.
@@ -4,7 +4,11 @@ type $$ComponentProps = {
4
4
  label?: string;
5
5
  copiedLabel?: string;
6
6
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
7
+ /** Defaults to true, or false when `box` makes it icon-only. */
7
8
  showLabel?: boolean;
9
+ /** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
10
+ box?: 'xs' | 'sm' | 'md' | 'lg';
11
+ hitArea?: 'auto' | 'compact';
8
12
  resetMs?: number;
9
13
  class?: string;
10
14
  };
@@ -16,6 +16,8 @@
16
16
  disabled = false,
17
17
  variant = 'default',
18
18
  size = 'md',
19
+ box,
20
+ hitArea = 'auto',
19
21
  class: klass = ''
20
22
  }: {
21
23
  onfiles: (files: File[]) => void;
@@ -36,9 +38,15 @@
36
38
  /** Match the Button atom's sizes so a FileButton lines up with buttons in
37
39
  * the same row. */
38
40
  size?: 'sm' | 'md' | 'lg';
41
+ /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
42
+ box?: 'xs' | 'sm' | 'md' | 'lg';
43
+ /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
44
+ hitArea?: 'auto' | 'compact';
39
45
  class?: string;
40
46
  } = $props();
41
47
 
48
+ const onlyIcon = $derived(iconOnly || box !== undefined);
49
+
42
50
  function onchange(e: Event) {
43
51
  const input = e.currentTarget as HTMLInputElement;
44
52
  const files = Array.from(input.files ?? []);
@@ -54,9 +62,12 @@
54
62
  class:ghost={variant === 'ghost'}
55
63
  class:sm={size === 'sm'}
56
64
  class:lg={size === 'lg'}
57
- class:icon-only={iconOnly}
65
+ class:icon-only={onlyIcon}
66
+ class:box={box !== undefined}
67
+ class:hit-compact={hitArea === 'compact'}
68
+ style:--file-box={box ? `var(--box-${box})` : undefined}
58
69
  class:disabled
59
- aria-label={iconOnly ? label : undefined}
70
+ aria-label={onlyIcon ? label : undefined}
60
71
  >
61
72
  <input
62
73
  class="sr-only"
@@ -71,7 +82,7 @@
71
82
  {:else if emoji}
72
83
  <span class="emoji" aria-hidden="true">{emoji}</span>
73
84
  {/if}
74
- <span class:sr-only={iconOnly}>{label}</span>
85
+ <span class:sr-only={onlyIcon}>{label}</span>
75
86
  </label>
76
87
 
77
88
  <style>
@@ -120,6 +131,24 @@
120
131
  padding: var(--sp-2);
121
132
  aspect-ratio: 1;
122
133
  }
134
+ .file-btn.box {
135
+ width: var(--file-box);
136
+ min-width: var(--file-box);
137
+ height: var(--file-box);
138
+ min-height: var(--file-box);
139
+ padding: 0;
140
+ flex: none;
141
+ }
142
+ @media (pointer: coarse) {
143
+ .file-btn.icon-only:not(.hit-compact) {
144
+ position: relative;
145
+ }
146
+ .file-btn.icon-only:not(.hit-compact)::after {
147
+ content: '';
148
+ position: absolute;
149
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
150
+ }
151
+ }
123
152
  /* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
124
153
  read, so render it a touch larger for legibility. */
125
154
  .emoji {
@@ -18,6 +18,10 @@ type $$ComponentProps = {
18
18
  /** Match the Button atom's sizes so a FileButton lines up with buttons in
19
19
  * the same row. */
20
20
  size?: 'sm' | 'md' | 'lg';
21
+ /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
22
+ box?: 'xs' | 'sm' | 'md' | 'lg';
23
+ /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
24
+ hitArea?: 'auto' | 'compact';
21
25
  class?: string;
22
26
  };
23
27
  declare const FileButton: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -18,9 +18,18 @@
18
18
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
19
19
  // Semantic state tint (forwarded to Button). Pairs well with `chip`.
20
20
  tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
21
- // Larger 2.5rem outlined square icon-chip (header/toolbar actions).
21
+ // Outlined `--box-lg` square icon-chip (header/toolbar actions).
22
22
  chip?: boolean;
23
+ // Shared square box scale (`--box-xs/sm/md/lg`); default `md` is the classic
24
+ // 2.25rem box. Forwarded to Button.
25
+ box?: 'xs' | 'sm' | 'md' | 'lg';
26
+ // SVG glyph px. Emoji text glyphs render at ×1.35 of it.
23
27
  size?: number;
28
+ // Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
29
+ // no emoji multiplier. Wins over `size`.
30
+ glyphSize?: number | string;
31
+ // Opt out of the 44px coarse-pointer hit slab in dense rows.
32
+ hitArea?: 'auto' | 'compact';
24
33
  // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
25
34
  // no square box; just a muted glyph that brightens on hover. Pair with
26
35
  // `hoverDanger` to tint it red on hover (delete affordances).
@@ -46,7 +55,10 @@
46
55
  variant = 'ghost',
47
56
  tone = 'none',
48
57
  chip = false,
58
+ box,
49
59
  size = 18,
60
+ glyphSize,
61
+ hitArea = 'auto',
50
62
  inline = false,
51
63
  hoverDanger = false,
52
64
  pressed,
@@ -55,6 +67,11 @@
55
67
  class: klass = '',
56
68
  ...rest
57
69
  }: IconButtonProps = $props();
70
+
71
+ const glyphCss = $derived(
72
+ glyphSize === undefined ? undefined : typeof glyphSize === 'number' ? `${glyphSize}px` : glyphSize
73
+ );
74
+ const emojiCss = $derived(glyphCss ?? `${size * 1.35}px`);
58
75
  </script>
59
76
 
60
77
  <!-- Composition: the icon-only button is a Button (canonical control styling)
@@ -65,6 +82,8 @@
65
82
  {variant}
66
83
  {tone}
67
84
  {chip}
85
+ {box}
86
+ {hitArea}
68
87
  {disabled}
69
88
  {title}
70
89
  {onclick}
@@ -76,19 +95,28 @@
76
95
  aria-label={label}
77
96
  >
78
97
  {#if children}
79
- <Icon {size}>{@render children()}</Icon>
98
+ {#if glyphCss}
99
+ <span class="glyph" style="font-size: {glyphCss}"><Icon>{@render children()}</Icon></span>
100
+ {:else}
101
+ <Icon {size}>{@render children()}</Icon>
102
+ {/if}
80
103
  {:else if emoji}
81
- <span class="emoji" style="font-size: {size * 1.35}px" aria-hidden="true">{emoji}</span>
104
+ <span class="emoji" style="font-size: {emojiCss}" aria-hidden="true">{emoji}</span>
82
105
  {:else if icon}
83
- <Icon name={icon} {size} />
106
+ {#if glyphCss}
107
+ <span class="glyph" style="font-size: {glyphCss}"><Icon name={icon} /></span>
108
+ {:else}
109
+ <Icon name={icon} {size} />
110
+ {/if}
84
111
  {/if}
85
112
  </Button>
86
113
 
87
114
  <style>
88
115
  /* Off-registry glyph (emoji) rendered as text rather than an SVG. Sized off the
89
116
  `size` prop (×1.35, since an emoji reads small next to an SVG glyph of the
90
- same px) and centered so it shares the button's tap target. */
91
- .emoji {
117
+ same px) unless `glyphSize` is exact; centered so it shares the tap target. */
118
+ .emoji,
119
+ .glyph {
92
120
  display: inline-flex;
93
121
  align-items: center;
94
122
  justify-content: center;
@@ -14,7 +14,10 @@ type IconButtonProps = HTMLButtonAttributes & {
14
14
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
15
15
  tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
16
16
  chip?: boolean;
17
+ box?: 'xs' | 'sm' | 'md' | 'lg';
17
18
  size?: number;
19
+ glyphSize?: number | string;
20
+ hitArea?: 'auto' | 'compact';
18
21
  inline?: boolean;
19
22
  hoverDanger?: boolean;
20
23
  pressed?: boolean;
@@ -27,8 +27,10 @@
27
27
  variant,
28
28
  tone = 'none',
29
29
  size,
30
+ box,
30
31
  control = false,
31
32
  block = false,
33
+ hitArea = 'auto',
32
34
  disabled = false,
33
35
  onopen,
34
36
  onclose
@@ -52,9 +54,14 @@
52
54
  variant?: TriggerVariant;
53
55
  tone?: TriggerTone;
54
56
  size?: TriggerSize;
57
+ /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
58
+ * the default trigger is the `md` 2.25rem box. */
59
+ box?: 'xs' | 'sm' | 'md' | 'lg';
55
60
  /** Use the shared `--control-height` toolbar/composer contract. */
56
61
  control?: boolean;
57
62
  block?: boolean;
63
+ /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
64
+ hitArea?: 'auto' | 'compact';
58
65
  disabled?: boolean;
59
66
  onopen?: () => void;
60
67
  onclose?: () => void;
@@ -109,6 +116,9 @@
109
116
  class:trigger-lg={size === 'lg'}
110
117
  class:trigger-control={control}
111
118
  class:trigger-block={block}
119
+ class:trigger-box={box !== undefined}
120
+ class:hit-compact={hitArea === 'compact'}
121
+ style:--pop-box={box ? `var(--box-${box})` : undefined}
112
122
  class:trigger-tone-accent={tone === 'accent'}
113
123
  class:trigger-tone-success={tone === 'success'}
114
124
  class:trigger-tone-info={tone === 'info'}
@@ -143,8 +153,8 @@
143
153
  display: inline-flex;
144
154
  align-items: center;
145
155
  justify-content: center;
146
- min-height: 2.25rem;
147
- min-width: 2.25rem;
156
+ min-height: var(--box-md);
157
+ min-width: var(--box-md);
148
158
  padding: var(--sp-2);
149
159
  border: 1px solid transparent;
150
160
  border-radius: var(--r-md);
@@ -221,6 +231,26 @@
221
231
  .pop-trigger.trigger-block {
222
232
  width: 100%;
223
233
  }
234
+ .pop-trigger.trigger-box {
235
+ width: var(--pop-box);
236
+ min-width: var(--pop-box);
237
+ height: var(--pop-box);
238
+ min-height: var(--pop-box);
239
+ padding: 0;
240
+ flex: none;
241
+ }
242
+ /* Coarse pointers: icon-only triggers extend their hit area to --touch-target
243
+ via an invisible slab; layout does not move. */
244
+ @media (pointer: coarse) {
245
+ .pop-trigger:not(.canonical, .bare, .hit-compact) {
246
+ position: relative;
247
+ }
248
+ .pop-trigger:not(.canonical, .bare, .hit-compact)::after {
249
+ content: '';
250
+ position: absolute;
251
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
252
+ }
253
+ }
224
254
  .pop-trigger.trigger-tone-accent {
225
255
  --pop-trigger-tone: var(--accent);
226
256
  }
@@ -23,9 +23,14 @@ type $$ComponentProps = {
23
23
  variant?: TriggerVariant;
24
24
  tone?: TriggerTone;
25
25
  size?: TriggerSize;
26
+ /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
27
+ * the default trigger is the `md` 2.25rem box. */
28
+ box?: 'xs' | 'sm' | 'md' | 'lg';
26
29
  /** Use the shared `--control-height` toolbar/composer contract. */
27
30
  control?: boolean;
28
31
  block?: boolean;
32
+ /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
33
+ hitArea?: 'auto' | 'compact';
29
34
  disabled?: boolean;
30
35
  onopen?: () => void;
31
36
  onclose?: () => void;
@@ -17,6 +17,8 @@
17
17
  options,
18
18
  groups,
19
19
  onchange,
20
+ box,
21
+ hitArea = 'auto',
20
22
  class: klass = '',
21
23
  ...rest
22
24
  }: {
@@ -31,6 +33,9 @@
31
33
  // the theme picker to split light/dark; falls back to `options` otherwise.
32
34
  groups?: { label: string; options: Option[] }[];
33
35
  onchange: (value: string) => void;
36
+ // Shared square box scale (`--box-xs/sm/md/lg`); default is the 2.25rem icon box.
37
+ box?: 'xs' | 'sm' | 'md' | 'lg';
38
+ hitArea?: 'auto' | 'compact';
34
39
  class?: string;
35
40
  [key: string]: unknown;
36
41
  } = $props();
@@ -39,7 +44,7 @@
39
44
  <!-- The wrapper (owned here, so styled scoped) is the positioning context that
40
45
  clips the transparent overlaid <select>; the icon Button shows through. -->
41
46
  <span class="select-button {klass}" data-tsu="SelectButton" {...rest}>
42
- <Button variant="ghost" icon {title} aria-label={label}>
47
+ <Button variant="ghost" icon {box} {hitArea} {title} aria-label={label}>
43
48
  <span aria-hidden="true">{glyph}</span>
44
49
  <Select
45
50
  variant="ghost"
@@ -13,6 +13,8 @@ type $$ComponentProps = {
13
13
  options: Option[];
14
14
  }[];
15
15
  onchange: (value: string) => void;
16
+ box?: 'xs' | 'sm' | 'md' | 'lg';
17
+ hitArea?: 'auto' | 'compact';
16
18
  class?: string;
17
19
  [key: string]: unknown;
18
20
  };
@@ -1,4 +1,6 @@
1
1
  <script lang="ts" module>
2
+ export type RowTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
3
+
2
4
  export interface Column<T> {
3
5
  /** Key into the row, or an arbitrary id when paired with a cell snippet. */
4
6
  key: string;
@@ -10,6 +12,12 @@
10
12
  /** Header becomes a sort toggle. Sorts by the displayed value unless an
11
13
  * `onsort` handler is supplied (then the caller controls ordering). */
12
14
  sortable?: boolean;
15
+ /** One-line cells clipped with an ellipsis (pair with `layout="fixed"`). */
16
+ truncate?: boolean;
17
+ nowrap?: boolean;
18
+ /** Drop the column when the table's own box is narrower than the breakpoint
19
+ * (sm 30rem, md 48rem, lg 64rem). */
20
+ hideBelow?: 'sm' | 'md' | 'lg';
13
21
  }
14
22
  </script>
15
23
 
@@ -30,7 +38,19 @@
30
38
  onsort,
31
39
  cellSnippets = {},
32
40
  empty = 'No data.',
33
- stickyHeader = false
41
+ stickyHeader = false,
42
+ stickyOffset,
43
+ layout = 'auto',
44
+ hideHeader = false,
45
+ size = 'md',
46
+ rowTone,
47
+ rowClass,
48
+ rowActions,
49
+ rowActionsLabel = 'Actions',
50
+ loading = false,
51
+ loadingLabel = 'Loading…',
52
+ onloadmore,
53
+ loadMoreLabel = 'Load more'
34
54
  }: {
35
55
  columns: Column<T>[];
36
56
  rows: T[];
@@ -41,13 +61,34 @@
41
61
  * table only reflects the indicator and emits; it does not reorder rows. */
42
62
  onsort?: (key: string, dir: 'asc' | 'desc') => void;
43
63
  cellSnippets?: Record<string, Snippet<[T]>>;
44
- empty?: string;
64
+ empty?: string | Snippet;
45
65
  stickyHeader?: boolean;
66
+ /** Distance from the scroll container's top for the sticky header, e.g.
67
+ * `var(--header-h)` when the page header overlaps. Defaults to 0. */
68
+ stickyOffset?: string;
69
+ /** `fixed` makes declared column widths authoritative (needed for truncate). */
70
+ layout?: 'auto' | 'fixed';
71
+ /** Visually hides the header; it stays in the DOM for assistive tech. */
72
+ hideHeader?: boolean;
73
+ size?: 'sm' | 'md';
74
+ /** Per-row semantic tone, rendered as a left accent bar + `data-tone`. */
75
+ rowTone?: (row: T) => RowTone | undefined;
76
+ rowClass?: (row: T) => string | undefined;
77
+ /** Trailing actions cell revealed on hover/focus (always visible on touch). */
78
+ rowActions?: Snippet<[T]>;
79
+ rowActionsLabel?: string;
80
+ loading?: boolean;
81
+ loadingLabel?: string;
82
+ /** Renders a footer "load more" button that calls this. */
83
+ onloadmore?: () => void;
84
+ loadMoreLabel?: string;
46
85
  } = $props();
47
86
 
48
87
  let sortKey = $state<string | null>(null);
49
88
  let sortDir = $state<'asc' | 'desc'>('asc');
50
89
 
90
+ const colCount = $derived(columns.length + (rowActions ? 1 : 0));
91
+
51
92
  function display(col: Column<T>, row: T): unknown {
52
93
  if (col.get) return col.get(row);
53
94
  return (row as Record<string, unknown>)[col.key];
@@ -80,13 +121,21 @@
80
121
  });
81
122
  </script>
82
123
 
83
- <div class="dt-scroll" data-tsu="DataTable">
84
- <table class="dt" class:sticky={stickyHeader}>
85
- <thead>
124
+ <div class="dt-scroll" data-tsu="DataTable" data-size={size} aria-busy={loading || undefined}>
125
+ <table
126
+ class="dt"
127
+ class:sticky={stickyHeader}
128
+ class:fixed={layout === 'fixed'}
129
+ class:head-hidden={hideHeader}
130
+ class:sm={size === 'sm'}
131
+ style:--dt-sticky-offset={stickyOffset}
132
+ >
133
+ <thead data-part="head">
86
134
  <tr>
87
135
  {#each columns as col (col.key)}
88
136
  <th
89
137
  scope="col"
138
+ data-hide-below={col.hideBelow}
90
139
  style:width={col.width}
91
140
  style:text-align={col.align ?? 'left'}
92
141
  aria-sort={col.sortable
@@ -109,17 +158,31 @@
109
158
  {/if}
110
159
  </th>
111
160
  {/each}
161
+ {#if rowActions}
162
+ <th scope="col" class="dt-actions-head"><span class="sr-only">{rowActionsLabel}</span></th>
163
+ {/if}
112
164
  </tr>
113
165
  </thead>
114
166
  <tbody>
115
- {#if sortedRows.length === 0}
116
- <tr>
117
- <td class="dt-empty" colspan={columns.length}>{empty}</td>
167
+ {#if sortedRows.length === 0 && !loading}
168
+ <tr data-part="empty">
169
+ <td class="dt-empty" colspan={colCount}>
170
+ {#if typeof empty === 'string'}
171
+ {empty}
172
+ {:else}
173
+ {@render empty()}
174
+ {/if}
175
+ </td>
118
176
  </tr>
119
177
  {:else}
120
178
  {#each sortedRows as row (rowKey(row))}
179
+ {@const tone = rowTone?.(row)}
121
180
  <tr
181
+ data-part="row"
182
+ data-tone={tone}
183
+ class={rowClass?.(row)}
122
184
  class:clickable={!!onrowclick}
185
+ class:toned={!!tone && tone !== 'neutral'}
123
186
  tabindex={onrowclick ? 0 : undefined}
124
187
  role={onrowclick ? 'button' : undefined}
125
188
  onclick={onrowclick ? () => onrowclick(row) : undefined}
@@ -133,7 +196,13 @@
133
196
  : undefined}
134
197
  >
135
198
  {#each columns as col (col.key)}
136
- <td style:text-align={col.align ?? 'left'}>
199
+ <td
200
+ data-part="cell"
201
+ data-hide-below={col.hideBelow}
202
+ class:truncate={col.truncate}
203
+ class:nowrap={col.nowrap}
204
+ style:text-align={col.align ?? 'left'}
205
+ >
137
206
  {#if cellSnippets[col.key]}
138
207
  {@render cellSnippets[col.key](row)}
139
208
  {:else}
@@ -141,10 +210,29 @@
141
210
  {/if}
142
211
  </td>
143
212
  {/each}
213
+ {#if rowActions}
214
+ <td data-part="actions" class="dt-actions">
215
+ {@render rowActions(row)}
216
+ </td>
217
+ {/if}
144
218
  </tr>
145
219
  {/each}
146
220
  {/if}
221
+ {#if loading}
222
+ <tr data-part="loading">
223
+ <td class="dt-empty" colspan={colCount}>{loadingLabel}</td>
224
+ </tr>
225
+ {/if}
147
226
  </tbody>
227
+ {#if onloadmore && !loading}
228
+ <tfoot>
229
+ <tr>
230
+ <td class="dt-more" colspan={colCount}>
231
+ <button type="button" class="dt-more-btn" onclick={onloadmore}>{loadMoreLabel}</button>
232
+ </td>
233
+ </tr>
234
+ </tfoot>
235
+ {/if}
148
236
  </table>
149
237
  </div>
150
238
 
@@ -155,6 +243,7 @@
155
243
  -webkit-overflow-scrolling: touch;
156
244
  border: 1px solid var(--border);
157
245
  border-radius: var(--r-lg);
246
+ container-type: inline-size;
158
247
  }
159
248
  .dt {
160
249
  width: 100%;
@@ -162,11 +251,21 @@
162
251
  font-size: var(--fs-sm);
163
252
  background: var(--surface);
164
253
  }
254
+ .dt.fixed {
255
+ table-layout: fixed;
256
+ }
257
+ .dt.sm {
258
+ font-size: var(--fs-xs);
259
+ }
165
260
  th,
166
261
  td {
167
262
  padding: var(--sp-2) var(--sp-3);
168
263
  border-bottom: 1px solid var(--border);
169
264
  }
265
+ .dt.sm th,
266
+ .dt.sm td {
267
+ padding: var(--sp-1) var(--sp-2);
268
+ }
170
269
  th {
171
270
  font-size: var(--fs-xs);
172
271
  font-weight: var(--fw-semibold);
@@ -178,9 +277,23 @@
178
277
  }
179
278
  .dt.sticky th {
180
279
  position: sticky;
181
- top: 0;
280
+ top: var(--dt-sticky-offset, 0);
182
281
  z-index: 1;
183
282
  }
283
+ /* Header stays in flow (so widths still apply) but paints nothing. */
284
+ .dt.head-hidden th {
285
+ height: 0;
286
+ padding: 0;
287
+ border: 0;
288
+ line-height: 0;
289
+ overflow: hidden;
290
+ clip-path: inset(50%);
291
+ }
292
+ .dt.head-hidden th > * {
293
+ display: block;
294
+ height: 0;
295
+ overflow: hidden;
296
+ }
184
297
  /* Sort toggle: a bare button that inherits the th's type styling. */
185
298
  .dt-sort {
186
299
  display: inline-flex;
@@ -218,9 +331,96 @@
218
331
  background: var(--bg-elevated-2);
219
332
  outline: none;
220
333
  }
334
+ td.truncate {
335
+ white-space: nowrap;
336
+ overflow: hidden;
337
+ text-overflow: ellipsis;
338
+ max-width: 0;
339
+ }
340
+ td.nowrap {
341
+ white-space: nowrap;
342
+ }
343
+ tr[data-tone='ok'] {
344
+ --dt-tone: var(--ok);
345
+ }
346
+ tr[data-tone='warn'] {
347
+ --dt-tone: var(--warn);
348
+ }
349
+ tr[data-tone='danger'] {
350
+ --dt-tone: var(--danger);
351
+ }
352
+ tr[data-tone='info'] {
353
+ --dt-tone: var(--info);
354
+ }
355
+ tr.toned td:first-child {
356
+ box-shadow: inset 3px 0 0 var(--dt-tone);
357
+ }
358
+ .dt-actions-head,
359
+ .dt-actions {
360
+ width: 1%;
361
+ white-space: nowrap;
362
+ text-align: right;
363
+ }
364
+ .dt-actions {
365
+ opacity: 0;
366
+ transition: opacity 0.12s var(--ease);
367
+ }
368
+ tr:hover .dt-actions,
369
+ tr:focus-within .dt-actions {
370
+ opacity: 1;
371
+ }
372
+ @media (pointer: coarse) {
373
+ .dt-actions {
374
+ opacity: 1;
375
+ }
376
+ }
377
+ @container (max-width: 30rem) {
378
+ [data-hide-below='sm'] {
379
+ display: none;
380
+ }
381
+ }
382
+ @container (max-width: 48rem) {
383
+ [data-hide-below='md'] {
384
+ display: none;
385
+ }
386
+ }
387
+ @container (max-width: 64rem) {
388
+ [data-hide-below='lg'] {
389
+ display: none;
390
+ }
391
+ }
221
392
  .dt-empty {
222
393
  text-align: center;
223
394
  color: var(--text-faint);
224
395
  padding: var(--sp-8);
225
396
  }
397
+ .dt-more {
398
+ text-align: center;
399
+ padding: var(--sp-2);
400
+ border-top: 1px solid var(--border);
401
+ }
402
+ .dt-more-btn {
403
+ border: 0;
404
+ background: none;
405
+ padding: var(--sp-1) var(--sp-3);
406
+ font: inherit;
407
+ color: var(--accent);
408
+ cursor: pointer;
409
+ border-radius: var(--r-md, 6px);
410
+ }
411
+ .dt-more-btn:hover,
412
+ .dt-more-btn:focus-visible {
413
+ background: var(--bg-elevated-2);
414
+ }
415
+ .sr-only {
416
+ position: absolute;
417
+ width: 1px;
418
+ height: 1px;
419
+ padding: 0;
420
+ margin: -1px;
421
+ overflow: hidden;
422
+ clip: rect(0, 0, 0, 0);
423
+ white-space: nowrap;
424
+ border: 0;
425
+ }
226
426
  </style>
@@ -1,3 +1,4 @@
1
+ export type RowTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
1
2
  export interface Column<T> {
2
3
  /** Key into the row, or an arbitrary id when paired with a cell snippet. */
3
4
  key: string;
@@ -9,6 +10,12 @@ export interface Column<T> {
9
10
  /** Header becomes a sort toggle. Sorts by the displayed value unless an
10
11
  * `onsort` handler is supplied (then the caller controls ordering). */
11
12
  sortable?: boolean;
13
+ /** One-line cells clipped with an ellipsis (pair with `layout="fixed"`). */
14
+ truncate?: boolean;
15
+ nowrap?: boolean;
16
+ /** Drop the column when the table's own box is narrower than the breakpoint
17
+ * (sm 30rem, md 48rem, lg 64rem). */
18
+ hideBelow?: 'sm' | 'md' | 'lg';
12
19
  }
13
20
  import type { Snippet } from 'svelte';
14
21
  declare function $$render<T>(): {
@@ -22,8 +29,27 @@ declare function $$render<T>(): {
22
29
  * table only reflects the indicator and emits; it does not reorder rows. */
23
30
  onsort?: (key: string, dir: "asc" | "desc") => void;
24
31
  cellSnippets?: Record<string, Snippet<[T]>>;
25
- empty?: string;
32
+ empty?: string | Snippet;
26
33
  stickyHeader?: boolean;
34
+ /** Distance from the scroll container's top for the sticky header, e.g.
35
+ * `var(--header-h)` when the page header overlaps. Defaults to 0. */
36
+ stickyOffset?: string;
37
+ /** `fixed` makes declared column widths authoritative (needed for truncate). */
38
+ layout?: "auto" | "fixed";
39
+ /** Visually hides the header; it stays in the DOM for assistive tech. */
40
+ hideHeader?: boolean;
41
+ size?: "sm" | "md";
42
+ /** Per-row semantic tone, rendered as a left accent bar + `data-tone`. */
43
+ rowTone?: (row: T) => RowTone | undefined;
44
+ rowClass?: (row: T) => string | undefined;
45
+ /** Trailing actions cell revealed on hover/focus (always visible on touch). */
46
+ rowActions?: Snippet<[T]>;
47
+ rowActionsLabel?: string;
48
+ loading?: boolean;
49
+ loadingLabel?: string;
50
+ /** Renders a footer "load more" button that calls this. */
51
+ onloadmore?: () => void;
52
+ loadMoreLabel?: string;
27
53
  };
28
54
  exports: {};
29
55
  bindings: "";