@ruckc/truss-ui 1.2.0 → 1.4.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 +6 -1
- package/dist/commands.svelte.d.ts +41 -0
- package/dist/commands.svelte.js +59 -0
- package/dist/components/Accordion.svelte +1 -1
- package/dist/components/Button.svelte +65 -8
- package/dist/components/Button.svelte.d.ts +2 -0
- package/dist/components/Calendar.svelte +2 -2
- package/dist/components/Card.svelte +1 -0
- package/dist/components/Card.svelte.d.ts +1 -0
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CommandPalette.svelte +207 -0
- package/dist/components/CommandPalette.svelte.d.ts +39 -0
- package/dist/components/DataTable.svelte +201 -27
- package/dist/components/DataTable.svelte.d.ts +50 -4
- package/dist/components/Dialog.svelte +2 -1
- package/dist/components/Drawer.svelte +1 -1
- package/dist/components/EditableText.svelte +2 -2
- package/dist/components/Eyebrow.svelte +1 -1
- package/dist/components/FileUpload.svelte +1 -1
- package/dist/components/Footer.svelte +29 -2
- package/dist/components/Footer.svelte.d.ts +8 -0
- package/dist/components/GridLayout.svelte +242 -0
- package/dist/components/GridLayout.svelte.d.ts +30 -0
- package/dist/components/HoverCard.svelte +158 -0
- package/dist/components/HoverCard.svelte.d.ts +46 -0
- package/dist/components/Kbd.svelte +36 -0
- package/dist/components/Kbd.svelte.d.ts +8 -0
- package/dist/components/Link.svelte +1 -1
- package/dist/components/List.svelte +113 -0
- package/dist/components/List.svelte.d.ts +17 -0
- package/dist/components/ListItem.svelte +13 -0
- package/dist/components/ListItem.svelte.d.ts +9 -0
- package/dist/components/Menu.svelte +4 -3
- package/dist/components/MultiSelect.svelte +2 -2
- package/dist/components/Navbar.svelte +13 -3
- package/dist/components/Navbar.svelte.d.ts +5 -1
- package/dist/components/Panel.svelte +2 -2
- package/dist/components/Popover.svelte +22 -1
- package/dist/components/Popover.svelte.d.ts +2 -0
- package/dist/components/RadioGroup.svelte +1 -1
- package/dist/components/Select.svelte +1 -1
- package/dist/components/Shell.svelte +12 -1
- package/dist/components/Shell.svelte.d.ts +2 -0
- package/dist/components/ShortcutSheet.svelte +62 -0
- package/dist/components/ShortcutSheet.svelte.d.ts +9 -0
- package/dist/components/Sidebar.svelte +2 -2
- package/dist/components/Skeleton.svelte +80 -6
- package/dist/components/Skeleton.svelte.d.ts +8 -1
- package/dist/components/Slider.svelte +1 -1
- package/dist/components/Switch.svelte +1 -1
- package/dist/components/Tabs.svelte +252 -32
- package/dist/components/Tabs.svelte.d.ts +21 -9
- package/dist/components/Tag.svelte +7 -7
- package/dist/components/TagInput.svelte +1 -1
- package/dist/components/Text.svelte +1 -1
- package/dist/components/Toaster.svelte +33 -2
- package/dist/components/ToggleChip.svelte +1 -1
- package/dist/components/Toolbar.svelte +27 -0
- package/dist/components/Toolbar.svelte.d.ts +10 -0
- package/dist/components/ToolbarSeparator.svelte +15 -0
- package/dist/components/ToolbarSeparator.svelte.d.ts +3 -0
- package/dist/components/Tooltip.svelte +19 -28
- package/dist/components/Tooltip.svelte.d.ts +6 -1
- package/dist/components/TooltipTip.svelte +59 -0
- package/dist/components/TooltipTip.svelte.d.ts +12 -0
- package/dist/components/Tree.svelte +343 -44
- package/dist/components/Tree.svelte.d.ts +29 -2
- package/dist/grid-layout.d.ts +42 -0
- package/dist/grid-layout.js +100 -0
- package/dist/hotkeys.svelte.d.ts +45 -0
- package/dist/hotkeys.svelte.js +108 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +13 -0
- package/dist/keys.d.ts +25 -0
- package/dist/keys.js +88 -0
- package/dist/roving.d.ts +25 -0
- package/dist/roving.js +129 -0
- package/dist/styles/base.css +2 -0
- package/dist/styles/tokens.css +49 -0
- package/dist/table-export.d.ts +24 -0
- package/dist/table-export.js +130 -0
- package/dist/toast.svelte.d.ts +25 -1
- package/dist/toast.svelte.js +42 -4
- package/dist/types.d.ts +6 -0
- package/package.json +7 -3
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
import Icon from '../icons/Icon.svelte';
|
|
5
5
|
import Button from './Button.svelte';
|
|
6
6
|
import Checkbox from './Checkbox.svelte';
|
|
7
|
+
import Menu from './Menu.svelte';
|
|
7
8
|
import Popover from './Popover.svelte';
|
|
8
9
|
import Spinner from './Spinner.svelte';
|
|
9
10
|
|
|
10
|
-
import
|
|
11
|
+
import { toasts } from '../toast.svelte.js';
|
|
12
|
+
import { displayValue, titleValue, serializeRows, type ExportFormat, type ExportSerializer } from '../table-export.js';
|
|
13
|
+
import type { Column, ColumnFilters, MenuEntry, SortState } from '../types.js';
|
|
11
14
|
|
|
12
15
|
let {
|
|
13
16
|
columns,
|
|
@@ -27,12 +30,24 @@
|
|
|
27
30
|
pageSize = 10,
|
|
28
31
|
// virtual scrolling + infinite loading
|
|
29
32
|
virtual = false,
|
|
30
|
-
rowHeight
|
|
33
|
+
rowHeight,
|
|
31
34
|
overscan = 6,
|
|
32
35
|
hasMore = false,
|
|
33
36
|
loading = false,
|
|
34
37
|
loadThreshold = 8,
|
|
35
38
|
onloadmore,
|
|
39
|
+
// streaming / refresh state
|
|
40
|
+
stale = false,
|
|
41
|
+
status,
|
|
42
|
+
oncancel,
|
|
43
|
+
// rows: activation, focus, context menu
|
|
44
|
+
onactivate,
|
|
45
|
+
onfocusrow,
|
|
46
|
+
rowMenu,
|
|
47
|
+
// formatting + export
|
|
48
|
+
locale,
|
|
49
|
+
headerExtra,
|
|
50
|
+
exporters,
|
|
36
51
|
// column filtering / sorting
|
|
37
52
|
filterable = false,
|
|
38
53
|
filters = $bindable({}),
|
|
@@ -65,7 +80,7 @@
|
|
|
65
80
|
|
|
66
81
|
/** Render only the rows in view (fixed `rowHeight`). Needs a bounded `maxHeight`. Use with `pagination={false}`. */
|
|
67
82
|
virtual?: boolean;
|
|
68
|
-
/** px; every row is exactly this tall when `virtual`. */
|
|
83
|
+
/** px; every row is exactly this tall when `virtual`. Defaults to the `--tr-row-h` density token (32px). */
|
|
69
84
|
rowHeight?: number;
|
|
70
85
|
/** Extra rows rendered above/below the viewport. */
|
|
71
86
|
overscan?: number;
|
|
@@ -77,6 +92,24 @@
|
|
|
77
92
|
loadThreshold?: number;
|
|
78
93
|
/** Called near the end of the list. Append to `rows` (or flip `hasMore` off); it is not called again until `rows.length` changes. */
|
|
79
94
|
onloadmore?: () => void | Promise<void>;
|
|
95
|
+
/** The rows are out of date while a refresh loads: dims them but keeps them visible and interactive, and marks the table `aria-busy`. */
|
|
96
|
+
stale?: boolean;
|
|
97
|
+
/** Status text in the bar (paged or continuous), e.g. "1,240 rows · streaming…". Replaces the default row count. */
|
|
98
|
+
status?: Snippet<[info: { count: number; hasMore: boolean; loading: boolean }]>;
|
|
99
|
+
/** Shows a Cancel button in the loading row; call it to abort the in-flight fetch. */
|
|
100
|
+
oncancel?: () => void;
|
|
101
|
+
/** Enter or double-click on a row (Space only selects). */
|
|
102
|
+
onactivate?: (row: T) => void;
|
|
103
|
+
/** A row received focus (mouse or keyboard); not called for focus inside a row's inner controls. */
|
|
104
|
+
onfocusrow?: (row: T) => void;
|
|
105
|
+
/** Context menu for a row: right-click, the Menu key or Shift+F10. Return an empty list to keep the browser's menu. */
|
|
106
|
+
rowMenu?: (row: T) => MenuEntry[];
|
|
107
|
+
/** BCP 47 locale for default number and date formatting (default: the runtime locale). */
|
|
108
|
+
locale?: string;
|
|
109
|
+
/** Rendered under each column's label, e.g. a type glyph, null % or a sparkline. */
|
|
110
|
+
headerExtra?: Snippet<[column: Column<T>]>;
|
|
111
|
+
/** Extra entries in the toolbar's Copy / Export menu, e.g. SQL `VALUES`. */
|
|
112
|
+
exporters?: { label: string; serialize: ExportSerializer<T> }[];
|
|
80
113
|
|
|
81
114
|
/** Show a filter row; columns opt in with `filter`. */
|
|
82
115
|
/**
|
|
@@ -103,6 +136,7 @@
|
|
|
103
136
|
const cols = $derived(columns.filter((c) => !hidden.includes(c.key)));
|
|
104
137
|
const total = $derived(cols.reduce((a, c) => a + w(c), selectable ? 40 : 0));
|
|
105
138
|
const toggleCol = (key: string) => (hidden = hidden.includes(key) ? hidden.filter((k) => k !== key) : [...hidden, key]);
|
|
139
|
+
const alignOf = (c: Column<T>) => c.align ?? (c.type === 'number' || c.type === 'date' || c.type === 'datetime' ? 'end' : 'start');
|
|
106
140
|
const ncols = $derived(cols.length + (selectable ? 1 : 0));
|
|
107
141
|
const rowFilters = $derived(filterable === true || filterable === 'row' || filterable === 'both');
|
|
108
142
|
const menuFilters = $derived(filterable === 'menu' || filterable === 'both');
|
|
@@ -258,16 +292,29 @@
|
|
|
258
292
|
let wrap = $state<HTMLDivElement>();
|
|
259
293
|
let viewH = $state(0);
|
|
260
294
|
let headH = $state(28);
|
|
295
|
+
let headRowH = $state(28);
|
|
261
296
|
let scrollTop = $state(0);
|
|
262
|
-
|
|
263
|
-
|
|
297
|
+
// row height: explicit `rowHeight`, else whatever the --tr-row-h density token resolves to (measured via a hidden probe)
|
|
298
|
+
let probe = $state<HTMLElement>();
|
|
299
|
+
let probeH = $state(32);
|
|
300
|
+
$effect(() => {
|
|
301
|
+
if (!probe) return;
|
|
302
|
+
const read = () => { const h = probe!.getBoundingClientRect().height; if (h > 0) probeH = h; };
|
|
303
|
+
read();
|
|
304
|
+
const ro = new ResizeObserver(read);
|
|
305
|
+
ro.observe(probe);
|
|
306
|
+
return () => ro.disconnect();
|
|
307
|
+
});
|
|
308
|
+
const rowH = $derived(rowHeight ?? probeH);
|
|
309
|
+
const start = $derived(virtual ? Math.max(0, Math.floor(scrollTop / rowH) - overscan) : 0);
|
|
310
|
+
const end = $derived(virtual ? Math.min(list.length, Math.ceil((scrollTop + viewH) / rowH) + overscan) : list.length);
|
|
264
311
|
const shown = $derived(virtual ? list.slice(start, end) : list);
|
|
265
312
|
|
|
266
313
|
// ---------- infinite loading
|
|
267
314
|
let calledAt = -1;
|
|
268
315
|
function maybeLoad(el: HTMLElement) {
|
|
269
316
|
if (!onloadmore || !hasMore || loading || pagination) return;
|
|
270
|
-
if (el.scrollHeight - el.scrollTop - el.clientHeight > loadThreshold *
|
|
317
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight > loadThreshold * rowH) return;
|
|
271
318
|
if (calledAt === rows.length) return;
|
|
272
319
|
calledAt = rows.length;
|
|
273
320
|
onloadmore();
|
|
@@ -325,7 +372,7 @@
|
|
|
325
372
|
async function focusIndex(i: number) {
|
|
326
373
|
if (i < 0 || i >= list.length || !wrap) return;
|
|
327
374
|
if (virtual && (i < start + 1 || i >= end - 1)) {
|
|
328
|
-
scrollTop = wrap.scrollTop = Math.max(0, i *
|
|
375
|
+
scrollTop = wrap.scrollTop = Math.max(0, i * rowH - viewH / 2 + headH);
|
|
329
376
|
await tick();
|
|
330
377
|
}
|
|
331
378
|
wrap.querySelector<HTMLElement>(`tr[data-index="${i}"]`)?.focus();
|
|
@@ -333,8 +380,19 @@
|
|
|
333
380
|
function rowKeydown(e: KeyboardEvent, f: Flat, index: number) {
|
|
334
381
|
if (e.target !== e.currentTarget) return; // ignore keys from inner controls (checkbox, twisty)
|
|
335
382
|
const id = rowKey(f.row);
|
|
336
|
-
const page_ = Math.max(1, Math.floor((viewH - headH) /
|
|
337
|
-
if (e.key === '
|
|
383
|
+
const page_ = Math.max(1, Math.floor((viewH - headH) / rowH) - 1);
|
|
384
|
+
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
|
385
|
+
if (!rowMenu) return;
|
|
386
|
+
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
387
|
+
if (openRowMenu(f.row, r.left + 24, r.top + r.height / 2, e.currentTarget as HTMLElement)) e.preventDefault();
|
|
388
|
+
}
|
|
389
|
+
else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'c') {
|
|
390
|
+
if (getSelection()?.toString()) return; // let the browser copy highlighted text
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
copyRows('tsv', { focus: f.row, header: false });
|
|
393
|
+
}
|
|
394
|
+
else if (e.key === 'Enter') { e.preventDefault(); selected = id; onactivate?.(f.row); }
|
|
395
|
+
else if (e.key === ' ') { e.preventDefault(); selected = id; }
|
|
338
396
|
else if (f.expandable && e.key === 'ArrowRight' && !expanded.has(id)) toggleExpand(id);
|
|
339
397
|
else if (f.expandable && e.key === 'ArrowLeft' && expanded.has(id)) toggleExpand(id);
|
|
340
398
|
else if (e.key === 'ArrowDown') { e.preventDefault(); focusIndex(index + 1); }
|
|
@@ -345,7 +403,91 @@
|
|
|
345
403
|
else if (e.key === 'End') { e.preventDefault(); focusIndex(list.length - 1); }
|
|
346
404
|
}
|
|
347
405
|
|
|
348
|
-
|
|
406
|
+
// ---------- row events: focus, double-click, context menu
|
|
407
|
+
const inControl = (e: Event) => !!(e.target as HTMLElement).closest('button, a, input, select, textarea, label');
|
|
408
|
+
function rowDblclick(e: MouseEvent, f: Flat) {
|
|
409
|
+
if (onactivate && !inControl(e)) onactivate(f.row);
|
|
410
|
+
}
|
|
411
|
+
function rowContext(e: MouseEvent, f: Flat) {
|
|
412
|
+
if (!rowMenu || inControl(e)) return;
|
|
413
|
+
if (openRowMenu(f.row, e.clientX, e.clientY, e.currentTarget as HTMLElement)) e.preventDefault();
|
|
414
|
+
}
|
|
415
|
+
let ctx = $state<{ row: T; x: number; y: number; items: MenuEntry[]; opener: HTMLElement } | null>(null);
|
|
416
|
+
let ctxAnchor = $state<HTMLElement>();
|
|
417
|
+
function openRowMenu(row: T, x: number, y: number, opener: HTMLElement) {
|
|
418
|
+
const items = rowMenu?.(row) ?? [];
|
|
419
|
+
if (!items.length) return false; // nothing to offer: keep the browser's own menu
|
|
420
|
+
headMenu = null; filterMenu = null;
|
|
421
|
+
ctx = { row, x, y, items, opener };
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
function closeRowMenu(refocus: boolean) {
|
|
425
|
+
const o = ctx?.opener;
|
|
426
|
+
ctx = null;
|
|
427
|
+
if (refocus) o?.focus();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------- copy + export
|
|
431
|
+
/** Rows an export covers: the checked rows, else (for `focus` / the selected row) that row, else every row in the current filter and sort. */
|
|
432
|
+
function rowsFor(scope: 'auto' | 'all', focus?: T): T[] {
|
|
433
|
+
if (scope === 'all') return flat.map((f) => f.row);
|
|
434
|
+
if (checked.length) {
|
|
435
|
+
const ids = new Set(checked);
|
|
436
|
+
return flat.filter((f) => ids.has(rowKey(f.row))).map((f) => f.row);
|
|
437
|
+
}
|
|
438
|
+
const one = focus ?? (selected != null ? flat.find((f) => rowKey(f.row) === selected)?.row : undefined);
|
|
439
|
+
return one ? [one] : flat.map((f) => f.row);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Serialize rows to text without touching the clipboard. `scope: 'auto'` (default) uses the checked rows, else the
|
|
443
|
+
* selected row, else every row in the current filter and sort order; `'all'` always uses every row.
|
|
444
|
+
* Reach it with `bind:this`. Only visible columns are included; collapsed tree children are not.
|
|
445
|
+
*/
|
|
446
|
+
export function exportRows(format: ExportFormat | ExportSerializer<T>, opts: { scope?: 'auto' | 'all'; header?: boolean } = {}): string {
|
|
447
|
+
return serializeRows(rowsFor(opts.scope ?? 'auto'), cols, format, { header: opts.header, locale });
|
|
448
|
+
}
|
|
449
|
+
async function writeClip(text: string) {
|
|
450
|
+
try { await navigator.clipboard.writeText(text); return true; }
|
|
451
|
+
catch {
|
|
452
|
+
const back = document.activeElement as HTMLElement | null;
|
|
453
|
+
const ta = document.createElement('textarea');
|
|
454
|
+
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
|
455
|
+
document.body.appendChild(ta); ta.select();
|
|
456
|
+
let ok = false;
|
|
457
|
+
try { ok = document.execCommand('copy'); } catch { /* unavailable */ }
|
|
458
|
+
ta.remove(); back?.focus();
|
|
459
|
+
return ok;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Copy rows to the clipboard (TSV by default) and confirm with a toast. Returns whether it worked. */
|
|
463
|
+
export async function copyRows(format: ExportFormat | ExportSerializer<T> = 'tsv', opts: { scope?: 'auto' | 'all'; header?: boolean; focus?: T } = {}): Promise<boolean> {
|
|
464
|
+
const picked = rowsFor(opts.scope ?? 'auto', opts.focus);
|
|
465
|
+
const ok = await writeClip(serializeRows(picked, cols, format, { header: opts.header, locale }));
|
|
466
|
+
const name = typeof format === 'string' ? format.toUpperCase() : 'custom format';
|
|
467
|
+
if (ok) toasts.success(`Copied ${fmt(picked.length)} ${picked.length === 1 ? 'row' : 'rows'} as ${name}`);
|
|
468
|
+
else toasts.danger('Could not copy to the clipboard');
|
|
469
|
+
return ok;
|
|
470
|
+
}
|
|
471
|
+
function download(format: 'csv' | 'json') {
|
|
472
|
+
const text = serializeRows(rowsFor('auto'), cols, format, { locale });
|
|
473
|
+
const url = URL.createObjectURL(new Blob([text], { type: format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json' }));
|
|
474
|
+
const a = document.createElement('a');
|
|
475
|
+
a.href = url; a.download = `${caption.replace(/[^\w-]+/g, '-').replace(/^-|-$/g, '') || 'table'}.${format}`;
|
|
476
|
+
a.click();
|
|
477
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
478
|
+
}
|
|
479
|
+
let copyOpen = $state(false);
|
|
480
|
+
const copyItems = $derived<MenuEntry[]>([
|
|
481
|
+
{ label: 'Copy as TSV', icon: 'copy', onselect: () => void copyRows('tsv') },
|
|
482
|
+
{ label: 'Copy as CSV', icon: 'copy', onselect: () => void copyRows('csv') },
|
|
483
|
+
{ label: 'Copy as JSON', icon: 'copy', onselect: () => void copyRows('json') },
|
|
484
|
+
...(exporters?.length ? [{ divider: true } as const, ...exporters.map((x) => ({ label: `Copy as ${x.label}`, icon: 'copy' as const, onselect: () => void copyRows(x.serialize) }))] : []),
|
|
485
|
+
{ divider: true },
|
|
486
|
+
{ label: 'Download CSV', icon: 'download', onselect: () => download('csv') },
|
|
487
|
+
{ label: 'Download JSON', icon: 'download', onselect: () => download('json') }
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
const showBar = $derived(selectable || columnMenu || !!toolbar || !!status || activeCount > 0 || !pagination);
|
|
349
491
|
const fmt = (n: number) => n.toLocaleString();
|
|
350
492
|
</script>
|
|
351
493
|
|
|
@@ -365,7 +507,8 @@
|
|
|
365
507
|
</span>
|
|
366
508
|
{/snippet}
|
|
367
509
|
|
|
368
|
-
<div class="tr-table" style:--rh=
|
|
510
|
+
<div class="tr-table" style:--rh={rowHeight != null ? `${rowHeight}px` : undefined}>
|
|
511
|
+
<span class="probe" bind:this={probe} aria-hidden="true"></span>
|
|
369
512
|
{#if showBar}
|
|
370
513
|
<div class="bar">
|
|
371
514
|
{#if selectable}<span class="sel" aria-live="polite">{checked.length ? `${fmt(checked.length)} selected` : ''}</span>{/if}
|
|
@@ -373,9 +516,17 @@
|
|
|
373
516
|
{#if activeCount > 0}
|
|
374
517
|
<Button variant="minimal" intent="neutral" size="sm" icon="cross" onclick={clearFilters}>Clear {activeCount === 1 ? 'filter' : `${activeCount} filters`}</Button>
|
|
375
518
|
{/if}
|
|
376
|
-
{#if
|
|
519
|
+
{#if status}
|
|
520
|
+
<span class="rows" aria-live="polite">{@render status({ count: flat.length, hasMore, loading })}</span>
|
|
521
|
+
{:else if !pagination}
|
|
377
522
|
<span class="rows" aria-live="polite">{fmt(flat.length)}{hasMore ? '+' : ''} rows</span>
|
|
378
523
|
{/if}
|
|
524
|
+
<div class="tr-anchor">
|
|
525
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="copy" aria-haspopup="menu" aria-expanded={copyOpen} onclick={() => (copyOpen = !copyOpen)}>Copy / Export</Button>
|
|
526
|
+
<Popover bind:open={copyOpen} align="end">
|
|
527
|
+
<Menu items={copyItems} label="Copy and export" autofocus onclose={() => (copyOpen = false)} />
|
|
528
|
+
</Popover>
|
|
529
|
+
</div>
|
|
379
530
|
{#if columnMenu}
|
|
380
531
|
<div class="tr-anchor">
|
|
381
532
|
<Button variant="minimal" intent="neutral" size="sm" icon="cog" aria-haspopup="true" aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>Columns</Button>
|
|
@@ -391,15 +542,15 @@
|
|
|
391
542
|
</div>
|
|
392
543
|
{/if}
|
|
393
544
|
|
|
394
|
-
<div class="tr-table-wrap" style:max-height={maxHeight} bind:this={wrap} bind:clientHeight={viewH} onscroll={onScroll} data-virtual={virtual}>
|
|
395
|
-
<table style:width="{total}px" role={getChildren ? 'treegrid' : 'grid'} aria-rowcount={virtual ? list.length + 1 : undefined} aria-busy={loading || undefined}>
|
|
545
|
+
<div class="tr-table-wrap" style:max-height={maxHeight} style:--hh="{headRowH}px" bind:this={wrap} bind:clientHeight={viewH} onscroll={onScroll} data-virtual={virtual}>
|
|
546
|
+
<table style:width="{total}px" role={getChildren ? 'treegrid' : 'grid'} aria-rowcount={virtual ? list.length + 1 : undefined} aria-busy={loading || stale || undefined} data-stale={stale || undefined}>
|
|
396
547
|
<caption class="tr-visually-hidden">{caption}</caption>
|
|
397
548
|
<colgroup>
|
|
398
549
|
{#if selectable}<col style:width="40px" />{/if}
|
|
399
550
|
{#each cols as c (c.key)}<col style:width="{w(c)}px" />{/each}
|
|
400
551
|
</colgroup>
|
|
401
552
|
<thead bind:clientHeight={headH}>
|
|
402
|
-
<tr>
|
|
553
|
+
<tr bind:clientHeight={headRowH}>
|
|
403
554
|
{#if selectable}
|
|
404
555
|
<th scope="col" class="chk"><Checkbox checked={allOn} indeterminate={!allOn && someOn} aria-label={pagination ? 'Select all rows on this page' : 'Select all rows'} onchange={(e) => toggleAll((e.currentTarget as HTMLInputElement).checked)} /></th>
|
|
405
556
|
{/if}
|
|
@@ -407,7 +558,7 @@
|
|
|
407
558
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
408
559
|
<th
|
|
409
560
|
scope="col"
|
|
410
|
-
data-align={c
|
|
561
|
+
data-align={alignOf(c)}
|
|
411
562
|
data-sortable={c.sortable}
|
|
412
563
|
data-menu={hasHeadMenu(c)}
|
|
413
564
|
oncontextmenu={(e) => onHeadContext(e, c)}
|
|
@@ -429,6 +580,7 @@
|
|
|
429
580
|
><Icon name="filter" /></button>
|
|
430
581
|
{/if}
|
|
431
582
|
</div>
|
|
583
|
+
{#if headerExtra}<div class="hx">{@render headerExtra(c)}</div>{/if}
|
|
432
584
|
{#if c.resizable !== false && i < cols.length - 1}
|
|
433
585
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
434
586
|
<div
|
|
@@ -467,7 +619,7 @@
|
|
|
467
619
|
</thead>
|
|
468
620
|
<tbody>
|
|
469
621
|
{#if virtual && start > 0}
|
|
470
|
-
<tr class="spacer" aria-hidden="true" style:height="{start *
|
|
622
|
+
<tr class="spacer" aria-hidden="true" style:height="{start * rowH}px"><td colspan={ncols} style:height="{start * rowH}px"></td></tr>
|
|
471
623
|
{/if}
|
|
472
624
|
{#each shown as f, k (rowKey(f.row))}
|
|
473
625
|
{@const id = rowKey(f.row)}
|
|
@@ -481,14 +633,17 @@
|
|
|
481
633
|
aria-level={getChildren ? f.depth + 1 : undefined}
|
|
482
634
|
aria-expanded={f.expandable ? expanded.has(id) || !!kept : undefined}
|
|
483
635
|
onclick={() => (selected = id)}
|
|
636
|
+
ondblclick={(e) => rowDblclick(e, f)}
|
|
637
|
+
oncontextmenu={(e) => rowContext(e, f)}
|
|
638
|
+
onfocus={() => onfocusrow?.(f.row)}
|
|
484
639
|
onkeydown={(e) => rowKeydown(e, f, index)}
|
|
485
640
|
>
|
|
486
641
|
{#if selectable}
|
|
487
642
|
<td class="chk" onclick={(e) => e.stopPropagation()}><Checkbox checked={checked.includes(id)} aria-label="Select row" onchange={() => toggleRow(id)} /></td>
|
|
488
643
|
{/if}
|
|
489
644
|
{#each cols as c, ci (c.key)}
|
|
490
|
-
<td data-align={c.
|
|
491
|
-
<div class="cell" style:padding-inline-start={ci === 0 && getChildren ? `${f.depth * 20}px` : undefined}>
|
|
645
|
+
<td data-align={alignOf(c)} data-type={c.type}>
|
|
646
|
+
<div class="cell" title={cell ? undefined : titleValue(f.row, c)} style:padding-inline-start={ci === 0 && getChildren ? `${f.depth * 20}px` : undefined}>
|
|
492
647
|
{#if ci === 0 && getChildren}
|
|
493
648
|
{#if f.expandable}
|
|
494
649
|
<button
|
|
@@ -501,17 +656,17 @@
|
|
|
501
656
|
</button>
|
|
502
657
|
{:else}<span class="twisty"></span>{/if}
|
|
503
658
|
{/if}
|
|
504
|
-
{#if cell}{@render cell(f.row, c)}{:else}{f.row
|
|
659
|
+
{#if cell}{@render cell(f.row, c)}{:else}{displayValue(f.row, c, locale)}{/if}
|
|
505
660
|
</div>
|
|
506
661
|
</td>
|
|
507
662
|
{/each}
|
|
508
663
|
</tr>
|
|
509
664
|
{/each}
|
|
510
665
|
{#if virtual && end < list.length}
|
|
511
|
-
<tr class="spacer" aria-hidden="true" style:height="{(list.length - end) *
|
|
666
|
+
<tr class="spacer" aria-hidden="true" style:height="{(list.length - end) * rowH}px"><td colspan={ncols} style:height="{(list.length - end) * rowH}px"></td></tr>
|
|
512
667
|
{/if}
|
|
513
668
|
{#if loading}
|
|
514
|
-
<tr class="more" aria-live="polite"><td colspan={ncols}><span class="ld"><Spinner size={16} label="Loading more rows" /> Loading more
|
|
669
|
+
<tr class="more" aria-live="polite"><td colspan={ncols}><span class="ld"><Spinner size={16} label="Loading more rows" /> Loading more…{#if oncancel} <Button variant="minimal" intent="neutral" size="sm" onclick={oncancel}>Cancel</Button>{/if}</span></td></tr>
|
|
515
670
|
{:else if list.length === 0}
|
|
516
671
|
<tr class="more"><td colspan={ncols}><span class="ld">{empty}</span></td></tr>
|
|
517
672
|
{/if}
|
|
@@ -560,6 +715,15 @@
|
|
|
560
715
|
</Popover>
|
|
561
716
|
{/if}
|
|
562
717
|
|
|
718
|
+
{#if ctx}
|
|
719
|
+
<div class="ctxpt" bind:this={ctxAnchor} style:left="{ctx.x}px" style:top="{ctx.y}px" aria-hidden="true"></div>
|
|
720
|
+
{#if ctxAnchor}
|
|
721
|
+
<Popover open={true} fixed anchor={ctxAnchor} onclose={closeRowMenu}>
|
|
722
|
+
<Menu items={ctx.items} label="Row actions" autofocus onclose={() => closeRowMenu(true)} />
|
|
723
|
+
</Popover>
|
|
724
|
+
{/if}
|
|
725
|
+
{/if}
|
|
726
|
+
|
|
563
727
|
{#if filterMenu && menuCol}
|
|
564
728
|
<Popover open={true} fixed anchor={filterMenu.el} onclose={() => (filterMenu = null)}>
|
|
565
729
|
<ul class="fmenu" aria-label="{menuCol.header} values">
|
|
@@ -575,20 +739,28 @@
|
|
|
575
739
|
<style>
|
|
576
740
|
.bar { display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
|
|
577
741
|
.grow { flex: 1; }
|
|
578
|
-
.sel { font-size: var(--tr-text-sm); font-weight: 600; color: var(--tr-primary); min-width: 80px; }
|
|
742
|
+
.sel { font-size: var(--tr-text-sm); font-weight: 600; color: var(--tr-primary-text, var(--tr-primary)); min-width: 80px; }
|
|
579
743
|
.rows { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); font-variant-numeric: tabular-nums; }
|
|
580
744
|
.colmenu { list-style: none; margin: 0; padding: 8px 12px; display: grid; gap: 4px; white-space: nowrap; }
|
|
581
745
|
.chk { width: 40px; padding: 0 0 0 10px !important; }
|
|
582
746
|
.tr-table-wrap { position: relative; overflow: auto; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface); }
|
|
583
747
|
table { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: var(--tr-text-md); }
|
|
584
748
|
/* Frozen header */
|
|
749
|
+
.tr-table { --rh: var(--tr-row-h, 32px); position: relative; }
|
|
750
|
+
.probe { position: absolute; width: 0; height: var(--rh); visibility: hidden; pointer-events: none; }
|
|
751
|
+
.ctxpt { position: fixed; width: 0; height: 0; pointer-events: none; }
|
|
585
752
|
thead th {
|
|
586
|
-
position: sticky; top: 0; z-index: 2; height: 28px; padding: 0 8px; text-align: start;
|
|
753
|
+
position: sticky; top: 0; z-index: 2; height: var(--tr-row-h-head, 28px); padding: 0 8px; text-align: start;
|
|
587
754
|
background: var(--tr-surface-container); border-bottom: 1px solid var(--tr-outline-variant);
|
|
588
755
|
font-size: var(--tr-text-sm); font-weight: 700; letter-spacing: 0.02em; color: var(--tr-on-surface-variant);
|
|
589
756
|
white-space: nowrap; overflow: hidden;
|
|
590
757
|
}
|
|
591
758
|
th[data-align='end'], td[data-align='end'] { text-align: end; }
|
|
759
|
+
th[data-align='end'] .hc { justify-content: flex-end; }
|
|
760
|
+
td[data-type='number'], td[data-type='date'], td[data-type='datetime'] { font-variant-numeric: tabular-nums; }
|
|
761
|
+
td[data-type='json'] { font-family: var(--tr-font-mono); font-size: var(--tr-text-sm); }
|
|
762
|
+
.hx { font-weight: 400; letter-spacing: 0; min-width: 0; padding-bottom: 4px; }
|
|
763
|
+
th[data-align='end'] .hx { display: flex; justify-content: flex-end; }
|
|
592
764
|
.head { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; }
|
|
593
765
|
button.head { cursor: pointer; }
|
|
594
766
|
button.head:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: 2px; }
|
|
@@ -599,7 +771,7 @@
|
|
|
599
771
|
.grip:focus-visible { outline: none; }
|
|
600
772
|
|
|
601
773
|
/* Heading cell: label + (optional) funnel that opens the column menu */
|
|
602
|
-
.hc { display: flex; align-items: center; gap: 2px; height: 28px; min-width: 0; }
|
|
774
|
+
.hc { display: flex; align-items: center; gap: 2px; min-height: var(--tr-row-h-head, 28px); min-width: 0; }
|
|
603
775
|
.hf { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); cursor: pointer; opacity: 0; transition: opacity var(--tr-dur-fast) var(--tr-ease), background-color var(--tr-dur-fast) var(--tr-ease); }
|
|
604
776
|
th:hover .hf, .hf:focus-visible, .hf[data-on='true'], .hf[aria-expanded='true'] { opacity: 1; }
|
|
605
777
|
.hf:hover { background: var(--tr-surface-container-highest); }
|
|
@@ -612,11 +784,11 @@
|
|
|
612
784
|
.hi { display: flex; align-items: center; gap: 8px; width: 100%; height: var(--tr-control-h); padding: 0 8px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface); font: inherit; text-align: left; cursor: pointer; }
|
|
613
785
|
.hi:hover:not(:disabled), .hi:focus-visible { outline: none; background: var(--tr-surface-container-highest); }
|
|
614
786
|
.hi:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
615
|
-
.hi[data-on='true'] { color: var(--tr-primary); font-weight: 700; }
|
|
787
|
+
.hi[data-on='true'] { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 700; }
|
|
616
788
|
.fmenu.inline { padding: 0; max-height: 160px; }
|
|
617
789
|
|
|
618
790
|
/* Filter row sits under the header, also frozen */
|
|
619
|
-
thead tr.filters th { top: 28px; z-index: 2; height: 34px; padding: 4px 6px; font-weight: 400; background: var(--tr-surface-container-low); overflow: visible; }
|
|
791
|
+
thead tr.filters th { top: var(--hh, 28px); z-index: 2; height: 34px; padding: 4px 6px; font-weight: 400; background: var(--tr-surface-container-low); overflow: visible; }
|
|
620
792
|
.fi { width: 100%; min-width: 0; height: 24px; padding: 0 6px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-sm); background: var(--tr-surface); color: var(--tr-on-surface); font: var(--tr-text-sm) var(--tr-font-sans); box-sizing: border-box; }
|
|
621
793
|
.fi::placeholder { color: var(--tr-placeholder); }
|
|
622
794
|
.fi:hover { border-color: var(--tr-outline); }
|
|
@@ -634,6 +806,8 @@
|
|
|
634
806
|
tbody tr.spacer, tbody tr.spacer td { padding: 0; border: 0; pointer-events: none; }
|
|
635
807
|
tbody tr.more td { text-align: center; color: var(--tr-on-surface-variant); border-bottom: 0; }
|
|
636
808
|
tbody tr.more:hover { background: none; }
|
|
809
|
+
tbody { transition: opacity var(--tr-dur-fast) var(--tr-ease); }
|
|
810
|
+
table[data-stale] tbody tr:not(.more) { opacity: 0.55; }
|
|
637
811
|
.ld { display: inline-flex; align-items: center; gap: 8px; font-size: var(--tr-text-sm); }
|
|
638
812
|
.cell { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
|
639
813
|
td[data-align='end'] .cell { justify-content: flex-end; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Snippet } from 'svelte';
|
|
2
|
-
import
|
|
2
|
+
import { type ExportFormat, type ExportSerializer } from '../table-export.js';
|
|
3
|
+
import type { Column, ColumnFilters, MenuEntry, SortState } from '../types.js';
|
|
3
4
|
declare function $$render<T extends Record<string, any>>(): {
|
|
4
5
|
props: {
|
|
5
6
|
columns: Column<T>[];
|
|
@@ -24,7 +25,7 @@ declare function $$render<T extends Record<string, any>>(): {
|
|
|
24
25
|
pageSize?: number;
|
|
25
26
|
/** Render only the rows in view (fixed `rowHeight`). Needs a bounded `maxHeight`. Use with `pagination={false}`. */
|
|
26
27
|
virtual?: boolean;
|
|
27
|
-
/** px; every row is exactly this tall when `virtual`. */
|
|
28
|
+
/** px; every row is exactly this tall when `virtual`. Defaults to the `--tr-row-h` density token (32px). */
|
|
28
29
|
rowHeight?: number;
|
|
29
30
|
/** Extra rows rendered above/below the viewport. */
|
|
30
31
|
overscan?: number;
|
|
@@ -36,6 +37,31 @@ declare function $$render<T extends Record<string, any>>(): {
|
|
|
36
37
|
loadThreshold?: number;
|
|
37
38
|
/** Called near the end of the list. Append to `rows` (or flip `hasMore` off); it is not called again until `rows.length` changes. */
|
|
38
39
|
onloadmore?: () => void | Promise<void>;
|
|
40
|
+
/** The rows are out of date while a refresh loads: dims them but keeps them visible and interactive, and marks the table `aria-busy`. */
|
|
41
|
+
stale?: boolean;
|
|
42
|
+
/** Status text in the bar (paged or continuous), e.g. "1,240 rows · streaming…". Replaces the default row count. */
|
|
43
|
+
status?: Snippet<[info: {
|
|
44
|
+
count: number;
|
|
45
|
+
hasMore: boolean;
|
|
46
|
+
loading: boolean;
|
|
47
|
+
}]>;
|
|
48
|
+
/** Shows a Cancel button in the loading row; call it to abort the in-flight fetch. */
|
|
49
|
+
oncancel?: () => void;
|
|
50
|
+
/** Enter or double-click on a row (Space only selects). */
|
|
51
|
+
onactivate?: (row: T) => void;
|
|
52
|
+
/** A row received focus (mouse or keyboard); not called for focus inside a row's inner controls. */
|
|
53
|
+
onfocusrow?: (row: T) => void;
|
|
54
|
+
/** Context menu for a row: right-click, the Menu key or Shift+F10. Return an empty list to keep the browser's menu. */
|
|
55
|
+
rowMenu?: (row: T) => MenuEntry[];
|
|
56
|
+
/** BCP 47 locale for default number and date formatting (default: the runtime locale). */
|
|
57
|
+
locale?: string;
|
|
58
|
+
/** Rendered under each column's label, e.g. a type glyph, null % or a sparkline. */
|
|
59
|
+
headerExtra?: Snippet<[column: Column<T>]>;
|
|
60
|
+
/** Extra entries in the toolbar's Copy / Export menu, e.g. SQL `VALUES`. */
|
|
61
|
+
exporters?: {
|
|
62
|
+
label: string;
|
|
63
|
+
serialize: ExportSerializer<T>;
|
|
64
|
+
}[];
|
|
39
65
|
/** Show a filter row; columns opt in with `filter`. */
|
|
40
66
|
/**
|
|
41
67
|
* Column filtering UI (columns opt in with `filter`): `'row'` (or `true`) adds a filter row under the header;
|
|
@@ -52,7 +78,17 @@ declare function $$render<T extends Record<string, any>>(): {
|
|
|
52
78
|
filterDelay?: number;
|
|
53
79
|
empty?: string;
|
|
54
80
|
};
|
|
55
|
-
exports: {
|
|
81
|
+
exports: {
|
|
82
|
+
exportRows: (format: ExportFormat | ExportSerializer<T>, opts?: {
|
|
83
|
+
scope?: "auto" | "all";
|
|
84
|
+
header?: boolean;
|
|
85
|
+
}) => string;
|
|
86
|
+
copyRows: (format?: ExportFormat | ExportSerializer<T>, opts?: {
|
|
87
|
+
scope?: "auto" | "all";
|
|
88
|
+
header?: boolean;
|
|
89
|
+
focus?: T;
|
|
90
|
+
}) => Promise<boolean>;
|
|
91
|
+
};
|
|
56
92
|
bindings: "checked" | "sort" | "selected" | "filters";
|
|
57
93
|
slots: {};
|
|
58
94
|
events: {};
|
|
@@ -62,7 +98,17 @@ declare class __sveltets_Render<T extends Record<string, any>> {
|
|
|
62
98
|
events(): ReturnType<typeof $$render<T>>['events'];
|
|
63
99
|
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
64
100
|
bindings(): "checked" | "sort" | "selected" | "filters";
|
|
65
|
-
exports(): {
|
|
101
|
+
exports(): {
|
|
102
|
+
exportRows: (format: ExportFormat | ExportSerializer<T>, opts?: {
|
|
103
|
+
scope?: "auto" | "all";
|
|
104
|
+
header?: boolean;
|
|
105
|
+
}) => string;
|
|
106
|
+
copyRows: (format?: ExportFormat | ExportSerializer<T>, opts?: {
|
|
107
|
+
scope?: "auto" | "all";
|
|
108
|
+
header?: boolean;
|
|
109
|
+
focus?: T | undefined;
|
|
110
|
+
}) => Promise<boolean>;
|
|
111
|
+
};
|
|
66
112
|
}
|
|
67
113
|
interface $$IsomorphicComponent {
|
|
68
114
|
new <T extends Record<string, any>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
@@ -30,13 +30,14 @@
|
|
|
30
30
|
});
|
|
31
31
|
</script>
|
|
32
32
|
|
|
33
|
+
<!-- `close` fires asynchronously: when the dialog was reopened meanwhile, the event is stale and must not close it again -->
|
|
33
34
|
<dialog
|
|
34
35
|
bind:this={el}
|
|
35
36
|
class="tr-dialog"
|
|
36
37
|
role={alert ? 'alertdialog' : undefined}
|
|
37
38
|
data-size={size}
|
|
38
39
|
aria-labelledby="{uid}-title"
|
|
39
|
-
onclose={() => { open = false; onclose?.(); }}
|
|
40
|
+
onclose={() => { if (el?.open) return; open = false; onclose?.(); }}
|
|
40
41
|
onclick={(e) => e.target === el && (open = false)}
|
|
41
42
|
>
|
|
42
43
|
<div class="frame">
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
<dialog
|
|
33
33
|
bind:this={el} class="tr-drawer" data-pos={position} data-size={size} aria-labelledby="{uid}-t"
|
|
34
|
-
onclose={() => { open = false; onclose?.(); }}
|
|
34
|
+
onclose={() => { if (el?.open) return; open = false; onclose?.(); }}
|
|
35
35
|
onclick={(e) => e.target === el && (open = false)}
|
|
36
36
|
>
|
|
37
37
|
<div class="frame">
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
{/if}
|
|
61
61
|
|
|
62
62
|
<style>
|
|
63
|
-
.view { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height:
|
|
63
|
+
.view { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: calc(var(--tr-control-h) - 4px); padding: 0 6px; margin: 0 -6px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: inherit; font: inherit; text-align: left; cursor: text; }
|
|
64
64
|
.view:disabled { cursor: default; }
|
|
65
65
|
.view:hover:not(:disabled) { background: var(--tr-surface-container-high); }
|
|
66
66
|
.view:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
@@ -68,6 +68,6 @@
|
|
|
68
68
|
.view:hover :global(svg), .view:focus-visible :global(svg) { opacity: 1; }
|
|
69
69
|
.tx { white-space: pre-wrap; overflow-wrap: break-word; }
|
|
70
70
|
.tx[data-empty='true'] { color: var(--tr-placeholder); font-style: italic; }
|
|
71
|
-
.ed { width: 100%; min-height:
|
|
71
|
+
.ed { width: 100%; min-height: calc(var(--tr-control-h) - 4px); padding: 2px 6px; margin: 0 -6px; box-sizing: content-box; border: 1px solid var(--tr-primary); border-radius: var(--tr-radius-sm); background: var(--tr-surface-container); color: var(--tr-on-surface); font: inherit; outline: none; box-shadow: inset 0 -2px 0 var(--tr-primary), var(--tr-focus-ring); }
|
|
72
72
|
textarea.ed { resize: vertical; line-height: 1.4; }
|
|
73
73
|
</style>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<svelte:element this={as} class="eb {klass}" data-tone={tone} {...rest}>{@render children?.()}</svelte:element>
|
|
15
15
|
|
|
16
16
|
<style>
|
|
17
|
-
.eb { margin: 0; font-family: var(--tr-font-display, var(--tr-font-sans)); font-size: var(--tr-text-sm); font-weight: 700; line-height: 1.3; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tr-primary); }
|
|
17
|
+
.eb { margin: 0; font-family: var(--tr-font-display, var(--tr-font-sans)); font-size: var(--tr-text-sm); font-weight: 700; line-height: 1.3; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tr-primary-text, var(--tr-primary)); }
|
|
18
18
|
.eb[data-tone='muted'] { color: var(--tr-on-surface-variant); }
|
|
19
19
|
.eb[data-tone='default'] { color: var(--tr-on-surface); }
|
|
20
20
|
</style>
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
.zone:focus-visible { outline: none; border-color: var(--tr-primary); box-shadow: var(--tr-focus-ring); }
|
|
127
127
|
.zone[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
|
128
128
|
strong { color: var(--tr-on-surface); }
|
|
129
|
-
.link { color: var(--tr-primary); font-weight: 600; text-decoration: underline; }
|
|
129
|
+
.link { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 600; text-decoration: underline; }
|
|
130
130
|
.ic { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
|
|
131
131
|
.hint { font-size: var(--tr-text-sm); }
|
|
132
132
|
.list { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 6px; }
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
start,
|
|
7
7
|
center,
|
|
8
8
|
end,
|
|
9
|
+
brand,
|
|
10
|
+
meta,
|
|
11
|
+
links,
|
|
9
12
|
children,
|
|
10
13
|
variant = 'plain',
|
|
14
|
+
contained = false,
|
|
11
15
|
class: klass,
|
|
12
16
|
...rest
|
|
13
17
|
}: {
|
|
@@ -17,18 +21,31 @@
|
|
|
17
21
|
center?: Snippet;
|
|
18
22
|
/** Trailing region (e.g. social links). */
|
|
19
23
|
end?: Snippet;
|
|
24
|
+
/** Identity (e.g. a logo) at the start. With `brand`, `meta` or `links`, `start`/`center`/`end` are ignored. */
|
|
25
|
+
brand?: Snippet;
|
|
26
|
+
/** Small print (e.g. copyright) stacked directly under `brand`. */
|
|
27
|
+
meta?: Snippet;
|
|
28
|
+
/** One or more link groups, right-aligned on the same row when wide. */
|
|
29
|
+
links?: Snippet;
|
|
20
30
|
/** Free-form content; replaces the three regions. */
|
|
21
31
|
children?: Snippet;
|
|
22
32
|
/** `bordered` adds a top border. */
|
|
23
33
|
variant?: 'plain' | 'bordered';
|
|
34
|
+
/** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
|
|
35
|
+
contained?: boolean | 'sm' | 'md' | 'lg';
|
|
24
36
|
class?: string;
|
|
25
37
|
} & Omit<HTMLAttributes<HTMLElement>, 'children' | 'class'> = $props();
|
|
26
38
|
</script>
|
|
27
39
|
|
|
28
40
|
<!-- A page-level <footer> is the contentinfo landmark; keep a single one per page. -->
|
|
29
|
-
<footer {...rest} class={['ft', klass]} data-variant={variant}>
|
|
41
|
+
<footer {...rest} class={['ft', klass]} data-variant={variant} data-contained={contained === true ? 'lg' : contained || undefined}>
|
|
30
42
|
{#if children}
|
|
31
43
|
<div class="in free">{@render children()}</div>
|
|
44
|
+
{:else if brand || meta || links}
|
|
45
|
+
<div class="in sem">
|
|
46
|
+
{#if brand || meta}<div class="bm">{#if brand}{@render brand()}{/if}{#if meta}<div class="meta">{@render meta()}</div>{/if}</div>{/if}
|
|
47
|
+
{#if links}<div class="l">{@render links()}</div>{/if}
|
|
48
|
+
</div>
|
|
32
49
|
{:else}
|
|
33
50
|
<div class="in">
|
|
34
51
|
<div class="s">{#if start}{@render start()}{/if}</div>
|
|
@@ -42,13 +59,23 @@
|
|
|
42
59
|
.ft { container-type: inline-size; background: var(--tr-surface-container); color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
|
|
43
60
|
.ft[data-variant='bordered'] { border-top: 1px solid var(--tr-outline-variant); }
|
|
44
61
|
.in { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px 16px; padding: 16px var(--tr-gutter, clamp(16px, 4vw, 32px)); }
|
|
62
|
+
.ft[data-contained] { --_w: var(--tr-container-max); }
|
|
63
|
+
.ft[data-contained='sm'] { --_w: 40rem; }
|
|
64
|
+
.ft[data-contained='md'] { --_w: 56rem; }
|
|
65
|
+
.ft[data-contained] .in { padding-inline: max(var(--tr-gutter, 16px), calc((100% - var(--_w)) / 2 + var(--tr-gutter, 16px))); }
|
|
66
|
+
.in.sem { grid-template-columns: minmax(0, 1fr) minmax(0, auto); }
|
|
67
|
+
.bm { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; }
|
|
68
|
+
.l { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px 24px; min-width: 0; }
|
|
45
69
|
.in.free { display: block; }
|
|
46
70
|
.s, .c, .e { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; min-width: 0; }
|
|
47
71
|
.c { justify-content: center; }
|
|
48
72
|
.e { justify-content: flex-end; }
|
|
49
73
|
.s:empty, .c:empty, .e:empty { display: none; }
|
|
74
|
+
/* each region keeps its own column, so an empty centre doesn't pull `end` into the middle */
|
|
75
|
+
.s { grid-column: 1; } .c { grid-column: 2; } .e { grid-column: 3; }
|
|
50
76
|
@container (max-width: 640px) {
|
|
51
77
|
.in:not(.free) { grid-template-columns: minmax(0, 1fr); justify-items: start; }
|
|
52
|
-
.c, .e {
|
|
78
|
+
.s, .c, .e { grid-column: auto; }
|
|
79
|
+
.c, .e, .l { justify-content: flex-start; }
|
|
53
80
|
}
|
|
54
81
|
</style>
|