@hearthlog/ui 0.0.0-stage → 0.1.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 (63) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +37 -3
  3. package/src/assets/fonts/fira-code-latin-ext-ui.woff2 +0 -0
  4. package/src/assets/fonts/fira-code-latin-ui.woff2 +0 -0
  5. package/src/assets/fonts/inter-latin-ext-ui.woff2 +0 -0
  6. package/src/assets/fonts/inter-latin-ui.woff2 +0 -0
  7. package/src/components/ConfirmDialog.svelte +104 -0
  8. package/src/components/Drawer.svelte +135 -0
  9. package/src/components/EmptyState.svelte +33 -0
  10. package/src/components/JsonViewer.svelte +113 -0
  11. package/src/components/Modal.svelte +76 -0
  12. package/src/components/NotificationCenter.svelte +106 -0
  13. package/src/components/PageHeader.svelte +26 -0
  14. package/src/components/commands/CommandConfirmDialog.svelte +32 -0
  15. package/src/components/commands/CommandDrawer.svelte +31 -0
  16. package/src/components/commands/CommandFieldset.svelte +18 -0
  17. package/src/components/commands/CommandModal.svelte +25 -0
  18. package/src/components/commands/CommandOnly.svelte +26 -0
  19. package/src/components/data/DataPlaceholder.svelte +8 -0
  20. package/src/components/data/DataRegion.svelte +8 -0
  21. package/src/components/data/DataValue.svelte +11 -0
  22. package/src/components/data-table/DataTable.svelte +139 -0
  23. package/src/components/data-table/DataTableHeader.svelte +78 -0
  24. package/src/components/data-table/DataTablePagination.svelte +91 -0
  25. package/src/components/data-table/table.ts +30 -0
  26. package/src/components/data-table/types.ts +63 -0
  27. package/src/components/endless/DayPager.svelte +355 -0
  28. package/src/components/endless/EndlessList.svelte +498 -0
  29. package/src/components/endless/bucket-store.svelte.ts +81 -0
  30. package/src/components/json-viewer.ts +49 -0
  31. package/src/components/ui/Amount.svelte +17 -0
  32. package/src/components/ui/Badge.svelte +40 -0
  33. package/src/components/ui/Button.svelte +53 -0
  34. package/src/components/ui/Checkbox.svelte +29 -0
  35. package/src/components/ui/ChoiceChips.svelte +53 -0
  36. package/src/components/ui/CollectionToolbar.svelte +29 -0
  37. package/src/components/ui/DatePicker.svelte +375 -0
  38. package/src/components/ui/DateTimePicker.svelte +16 -0
  39. package/src/components/ui/Field.svelte +52 -0
  40. package/src/components/ui/FoldLine.svelte +38 -0
  41. package/src/components/ui/Input.svelte +21 -0
  42. package/src/components/ui/Label.svelte +18 -0
  43. package/src/components/ui/Metric.svelte +34 -0
  44. package/src/components/ui/MonthNavigator.svelte +194 -0
  45. package/src/components/ui/Panel.svelte +37 -0
  46. package/src/components/ui/RouteTabs.svelte +57 -0
  47. package/src/components/ui/Select.svelte +93 -0
  48. package/src/components/ui/Switch.svelte +41 -0
  49. package/src/components/ui/Textarea.svelte +19 -0
  50. package/src/components/ui/Tooltip.svelte +53 -0
  51. package/src/components/ui/tab-indicator.ts +84 -0
  52. package/src/state/commands.ts +22 -0
  53. package/src/state/notifications.svelte.ts +49 -0
  54. package/src/state/page-context.ts +9 -0
  55. package/src/state/readiness.ts +20 -0
  56. package/src/state/sheet-stack.svelte.ts +49 -0
  57. package/src/styles/base.css +243 -0
  58. package/src/styles/index.css +13 -0
  59. package/src/styles/recipes.css +174 -0
  60. package/src/utils/cn.ts +6 -0
  61. package/src/utils/endless-axis.ts +153 -0
  62. package/src/utils/number-format.ts +82 -0
  63. package/README.md +0 -3
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { formatJson, scheduleJsonHighlight } from "./json-viewer.js";
4
+
5
+ let {
6
+ value,
7
+ label = "JSON",
8
+ maxHeight = "24rem",
9
+ }: {
10
+ value: unknown;
11
+ label?: string;
12
+ maxHeight?: string;
13
+ } = $props();
14
+
15
+ const formattedJson = $derived(formatJson(value));
16
+ const lineCount = $derived(formattedJson.split("\n").length);
17
+ const lineNumberWidth = $derived(
18
+ Math.max(2, String(lineCount).length) + 2,
19
+ );
20
+ const lineNumbers = $derived(
21
+ Array.from({ length: lineCount }, (_, index) => index + 1),
22
+ );
23
+
24
+ $effect(() => {
25
+ formattedJson;
26
+ scheduleJsonHighlight();
27
+ });
28
+
29
+ onMount(() => {
30
+ scheduleJsonHighlight();
31
+ return scheduleJsonHighlight;
32
+ });
33
+ </script>
34
+
35
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable code must be keyboard accessible.) -->
36
+ <div
37
+ class="json-viewer overflow-auto rounded-xl stor-well"
38
+ style:max-height={maxHeight}
39
+ role="region"
40
+ aria-label={label}
41
+ tabindex="0"
42
+ data-json-viewer
43
+ >
44
+ <div class="json-code-shell">
45
+ <div
46
+ class="json-line-numbers py-3 font-mono text-[11px] leading-5 sm:text-xs"
47
+ style={`width: ${lineNumberWidth}ch`}
48
+ aria-hidden="true"
49
+ >{#each lineNumbers as lineNumber}<span class="json-line-number">{lineNumber}</span>{/each}</div>
50
+ <pre class="json-code min-w-max py-3 font-mono text-[11px] leading-5 sm:text-xs"><code class="language-json">{formattedJson}</code></pre>
51
+ </div>
52
+ </div>
53
+
54
+ <style>
55
+ .json-viewer {
56
+ color: var(--stor-ink);
57
+ scrollbar-color: var(--stor-field-border) var(--stor-surface-muted);
58
+ }
59
+
60
+ .json-viewer:focus-visible {
61
+ outline: 2px solid var(--stor-accent);
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ .json-code-shell {
66
+ display: flex;
67
+ min-width: max-content;
68
+ }
69
+
70
+ .json-line-numbers {
71
+ position: sticky;
72
+ left: 0;
73
+ z-index: 1;
74
+ flex: 0 0 auto;
75
+ box-sizing: border-box;
76
+ border-right: 1px solid var(--stor-border);
77
+ background: var(--stor-surface-muted);
78
+ padding-right: 1.25ch;
79
+ color: var(--stor-muted-text);
80
+ text-align: right;
81
+ user-select: none;
82
+ white-space: pre;
83
+ }
84
+
85
+ .json-line-number {
86
+ display: block;
87
+ }
88
+
89
+ .json-code {
90
+ margin: 0;
91
+ padding-inline: 1rem;
92
+ }
93
+
94
+ :global(::highlight(key)) {
95
+ color: var(--stor-code-key);
96
+ }
97
+
98
+ :global(::highlight(string)) {
99
+ color: var(--stor-code-string);
100
+ }
101
+
102
+ :global(::highlight(numeric)) {
103
+ color: var(--stor-code-number);
104
+ }
105
+
106
+ :global(::highlight(boolean)) {
107
+ color: var(--stor-code-literal);
108
+ }
109
+
110
+ :global(::highlight(constant)) {
111
+ color: var(--stor-muted-text);
112
+ }
113
+ </style>
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import { Dialog } from "bits-ui";
3
+ import type { Snippet } from "svelte";
4
+ import IconClose from "~icons/mdi/close";
5
+ import { sheetStack } from "../state/sheet-stack.svelte.js";
6
+ import IconBack from "~icons/mdi/arrow-left";
7
+
8
+ let {
9
+ show = false,
10
+ title = "",
11
+ onClose = () => {},
12
+ children,
13
+ }: {
14
+ show?: boolean;
15
+ title?: string;
16
+ onClose?: () => void;
17
+ children: Snippet;
18
+ } = $props();
19
+
20
+ function handleOpenChange(open: boolean) {
21
+ if (!open && show) onClose();
22
+ }
23
+
24
+ // Phones: a sheet opened over another takes its place (see sheet-stack).
25
+ let sheetId = $state<number | null>(null);
26
+ $effect(() => {
27
+ if (!show) return;
28
+ const id = sheetStack.open(() => onClose());
29
+ sheetId = id;
30
+ return () => {
31
+ sheetStack.remove(id);
32
+ sheetId = null;
33
+ };
34
+ });
35
+ const covered = $derived(sheetStack.covered(sheetId));
36
+ const stacked = $derived(sheetStack.stacked(sheetId));
37
+
38
+ </script>
39
+
40
+ <Dialog.Root open={show} onOpenChange={handleOpenChange}>
41
+ <Dialog.Portal>
42
+ <Dialog.Overlay
43
+ data-sheet-covered={covered ? "" : undefined}
44
+ class="fixed inset-0 z-[var(--stor-layer-sheet)] bg-[var(--stor-overlay)]"
45
+ />
46
+ <Dialog.Content
47
+ data-sheet-covered={covered ? "" : undefined}
48
+ class="stor-sheet fixed inset-x-0 bottom-0 z-[var(--stor-layer-sheet-content)] flex max-h-[calc(100dvh-env(safe-area-inset-top))] w-full flex-col overflow-hidden rounded-t-3xl pt-[env(safe-area-inset-top)] outline-none sm:left-1/2 sm:top-1/2 sm:bottom-auto sm:max-h-[90vh] sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-[var(--stor-panel-radius)] sm:pt-0"
49
+ >
50
+ <header class="flex items-center justify-between border-b border-[var(--stor-border)] px-4 py-3 sm:px-6 sm:py-4">
51
+ {#if stacked}
52
+ <button
53
+ type="button"
54
+ class="-ml-2 mr-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
55
+ aria-label="Back"
56
+ onclick={() => onClose()}
57
+ ><IconBack class="h-5 w-5" /></button>
58
+ {/if}
59
+ <Dialog.Title level={3} class="stor-serif-title text-xl text-[var(--stor-ink)]">
60
+ {title}
61
+ </Dialog.Title>
62
+ <Dialog.Close
63
+ class="ml-auto flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[var(--stor-muted-text)] transition-colors hover:bg-[var(--stor-surface-muted)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
64
+ aria-label="Close dialog"
65
+ onclick={(event) => { if (stacked) { event.preventDefault(); sheetStack.closeAll(); } }}
66
+ >
67
+ <IconClose class="h-5 w-5" />
68
+ </Dialog.Close>
69
+ </header>
70
+
71
+ <div class="max-h-[calc(100dvh-4.5rem-env(safe-area-inset-top))] overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))] sm:max-h-[70vh] sm:px-6 sm:py-6">
72
+ {@render children()}
73
+ </div>
74
+ </Dialog.Content>
75
+ </Dialog.Portal>
76
+ </Dialog.Root>
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from "svelte";
3
+ import {
4
+ dismissNotification,
5
+ notificationState,
6
+ type AppNotification,
7
+ } from "../state/notifications.svelte.js";
8
+ import IconCheck from "~icons/mdi/check-circle-outline";
9
+ import IconAlert from "~icons/mdi/alert-circle-outline";
10
+ import IconInfo from "~icons/mdi/information-outline";
11
+ import IconLoading from "~icons/mdi/loading";
12
+ import IconClose from "~icons/mdi/close";
13
+
14
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
15
+
16
+ $effect(() => {
17
+ const ids = new Set(notificationState.items.map((item) => item.id));
18
+
19
+ for (const item of notificationState.items) {
20
+ // A non-positive duration marks a sticky notification that the
21
+ // caller dismisses explicitly when its task settles.
22
+ if (item.duration > 0 && !timers.has(item.id)) {
23
+ timers.set(
24
+ item.id,
25
+ setTimeout(() => {
26
+ dismissNotification(item.id);
27
+ timers.delete(item.id);
28
+ }, item.duration),
29
+ );
30
+ }
31
+ }
32
+
33
+ for (const [id, timer] of timers) {
34
+ if (!ids.has(id)) {
35
+ clearTimeout(timer);
36
+ timers.delete(id);
37
+ }
38
+ }
39
+ });
40
+
41
+ onDestroy(() => {
42
+ for (const timer of timers.values()) clearTimeout(timer);
43
+ timers.clear();
44
+ });
45
+
46
+ function iconFor(item: AppNotification) {
47
+ if (item.type === "success") return IconCheck;
48
+ if (item.type === "error") return IconAlert;
49
+ if (item.type === "progress") return IconLoading;
50
+ return IconInfo;
51
+ }
52
+
53
+ async function runAction(item: AppNotification) {
54
+ dismissNotification(item.id);
55
+ await item.action?.run();
56
+ }
57
+ </script>
58
+
59
+ <div
60
+ class="stor-notifications pointer-events-none fixed inset-x-3 top-[calc(4rem+env(safe-area-inset-top))] z-[var(--stor-layer-toast)] flex flex-col items-end gap-2 md:left-[104px] md:top-[calc(1rem+env(safe-area-inset-top))] lg:bottom-5 lg:left-auto lg:right-5 lg:top-auto lg:w-[24rem]"
61
+ aria-live="polite"
62
+ aria-relevant="additions"
63
+ >
64
+ {#each notificationState.items as item (item.id)}
65
+ {@const Icon = iconFor(item)}
66
+ <div
67
+ class="pointer-events-auto flex w-full items-start gap-3 rounded-xl border bg-[var(--stor-surface-overlay)] px-4 py-3 shadow-[var(--stor-elevation-3)] {item.type ===
68
+ 'error'
69
+ ? 'border-red-200'
70
+ : item.type === 'success'
71
+ ? 'border-emerald-200'
72
+ : 'border-[var(--stor-border)]'}"
73
+ role={item.type === "error" ? "alert" : "status"}
74
+ >
75
+ <Icon
76
+ class="mt-0.5 h-5 w-5 shrink-0 {item.type === 'error'
77
+ ? 'text-red-600'
78
+ : item.type === 'success'
79
+ ? 'text-emerald-600'
80
+ : item.type === 'progress'
81
+ ? 'animate-spin text-[var(--stor-accent-strong)]'
82
+ : 'text-stone-600'}"
83
+ />
84
+ <div class="min-w-0 flex-1 text-sm font-medium text-stone-800">
85
+ {item.message}
86
+ </div>
87
+ {#if item.action}
88
+ <button
89
+ type="button"
90
+ class="shrink-0 text-sm font-semibold text-stone-950 underline decoration-stone-300 underline-offset-4 hover:decoration-stone-900"
91
+ onclick={() => runAction(item)}
92
+ >
93
+ {item.action.label}
94
+ </button>
95
+ {/if}
96
+ <button
97
+ type="button"
98
+ class="-mr-1 shrink-0 rounded-lg p-1 text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-muted)] hover:text-stone-700"
99
+ aria-label="Dismiss notification"
100
+ onclick={() => dismissNotification(item.id)}
101
+ >
102
+ <IconClose class="h-4 w-4" />
103
+ </button>
104
+ </div>
105
+ {/each}
106
+ </div>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ let { title, showTitle = false, children, context, tabs, sidebarTabs = false, class: className = "" }: {
5
+ title: string;
6
+ /** The expanded desktop sidebar lists these sub-pages, so the tab row hides there. */
7
+ sidebarTabs?: boolean;
8
+ /** Detail pages name their subject, so the title stays visible. */
9
+ showTitle?: boolean;
10
+ children?: Snippet; context?: Snippet; tabs?: Snippet;
11
+ class?: string;
12
+ } = $props();
13
+ </script>
14
+ <header class="stor-page-header {className}" data-stor-drag>
15
+ {#if showTitle}
16
+ <h1 class="stor-page-title">{title}</h1>
17
+ {:else}
18
+ <h1 class="sr-only">{title}</h1>
19
+ <p class="stor-page-label" aria-hidden="true">{title}</p>
20
+ {/if}
21
+ <div class="stor-page-header-row">
22
+ {#if tabs}<div class="min-w-0 max-w-full {sidebarTabs ? 'stor-sidebar-owned-tabs' : ''}">{@render tabs()}</div>{/if}
23
+ {#if children}<div class="stor-toolbar ml-auto justify-end">{@render children()}</div>{/if}
24
+ </div>
25
+ {#if context}<div class={tabs ? 'mt-3' : ''}>{@render context()}</div>{/if}
26
+ </header>
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import ConfirmDialog from "../ConfirmDialog.svelte";
3
+ import CommandOnly from "./CommandOnly.svelte";
4
+
5
+ let {
6
+ show = false,
7
+ title = "Confirm Action",
8
+ message = "Are you sure you want to proceed?",
9
+ confirmLabel = "Confirm",
10
+ alternateLabel = "",
11
+ tone = "danger",
12
+ onConfirm = () => {},
13
+ onAlternate = () => {},
14
+ onCancel = () => {},
15
+ }: {
16
+ show?: boolean;
17
+ title?: string;
18
+ message?: string;
19
+ confirmLabel?: string;
20
+ alternateLabel?: string;
21
+ tone?: "danger" | "primary";
22
+ onConfirm?: () => unknown;
23
+ onAlternate?: () => unknown;
24
+ onCancel?: () => unknown;
25
+ } = $props();
26
+ </script>
27
+
28
+ <CommandOnly onUnavailable={() => {
29
+ if (show) void onCancel();
30
+ }}>
31
+ <ConfirmDialog {show} {title} {message} {confirmLabel} {alternateLabel} {tone} {onConfirm} {onAlternate} {onCancel} />
32
+ </CommandOnly>
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import Drawer from "../Drawer.svelte";
4
+ import CommandOnly from "./CommandOnly.svelte";
5
+
6
+ let {
7
+ show = false,
8
+ title = "",
9
+ description = "",
10
+ size = "md",
11
+ onClose = () => {},
12
+ children,
13
+ footer,
14
+ }: {
15
+ show?: boolean;
16
+ title?: string;
17
+ description?: string;
18
+ size?: "md" | "lg";
19
+ onClose?: () => void;
20
+ children: Snippet;
21
+ footer?: Snippet;
22
+ } = $props();
23
+ </script>
24
+
25
+ <CommandOnly onUnavailable={() => {
26
+ if (show) onClose();
27
+ }}>
28
+ <Drawer {show} {title} {description} {size} {onClose} {footer}>
29
+ {@render children()}
30
+ </Drawer>
31
+ </CommandOnly>
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { currentPageResource } from "../../state/page-context.js";
4
+ const resource = currentPageResource();
5
+ import { commandAvailability } from "../../state/commands.js";
6
+
7
+ let {
8
+ children,
9
+ class: className = "contents",
10
+ }: {
11
+ children: Snippet;
12
+ class?: string;
13
+ } = $props();
14
+ </script>
15
+
16
+ <fieldset disabled={!$commandAvailability.available || resource?.().loading} class={className}>
17
+ {@render children()}
18
+ </fieldset>
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import Modal from "../Modal.svelte";
4
+ import CommandOnly from "./CommandOnly.svelte";
5
+
6
+ let {
7
+ show = false,
8
+ title = "",
9
+ onClose = () => {},
10
+ children,
11
+ }: {
12
+ show?: boolean;
13
+ title?: string;
14
+ onClose?: () => void;
15
+ children: Snippet;
16
+ } = $props();
17
+ </script>
18
+
19
+ <CommandOnly onUnavailable={() => {
20
+ if (show) onClose();
21
+ }}>
22
+ <Modal {show} {title} {onClose}>
23
+ {@render children()}
24
+ </Modal>
25
+ </CommandOnly>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { untrack, type Snippet } from "svelte";
3
+ import { currentPageResource } from "../../state/page-context.js";
4
+ import { commandAvailability } from "../../state/commands.js";
5
+
6
+ let {
7
+ children,
8
+ onUnavailable,
9
+ }: {
10
+ children?: Snippet;
11
+ onUnavailable?: () => void;
12
+ } = $props();
13
+
14
+ const resource = currentPageResource();
15
+ const available = $derived($commandAvailability.available && !resource?.().loading);
16
+
17
+ $effect(() => {
18
+ if (!available) untrack(() => onUnavailable?.());
19
+ });
20
+ </script>
21
+
22
+ {#if children}
23
+ <fieldset class="contents" disabled={!available} inert={!available} aria-disabled={!available}>
24
+ {@render children()}
25
+ </fieldset>
26
+ {/if}
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ let { rows = 3, label = 'Loading data', compact = false }: { rows?: number; label?: string; compact?: boolean } = $props();
3
+ </script>
4
+ <div class="grid {compact ? 'gap-2' : 'gap-3 p-5'}" role="status" aria-label={label}>
5
+ {#each Array(rows) as _, i}
6
+ <div class="{compact ? 'h-5' : 'h-8'} rounded-lg bg-[var(--stor-surface-muted)] motion-safe:animate-pulse" style:width={`${100 - (i % 3) * 12}%`} aria-hidden="true"></div>
7
+ {/each}
8
+ </div>
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { providePageResource } from '../../state/page-context.js';
4
+ let { loading, children }: { loading: boolean; children: Snippet } = $props();
5
+ providePageResource(() => ({ loading }));
6
+ </script>
7
+
8
+ {@render children()}
@@ -0,0 +1,11 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { currentPageResource } from '../../state/page-context.js';
4
+ let { children }: { children: Snippet } = $props();
5
+ const resource = currentPageResource();
6
+ </script>
7
+ {#if resource?.().loading}
8
+ <span class="inline-block h-[0.85em] min-w-[3ch] rounded bg-stone-200/70 align-baseline tabular-nums motion-safe:animate-pulse" aria-label="Loading value"><span class="sr-only">Loading</span>&nbsp;</span>
9
+ {:else}
10
+ {@render children()}
11
+ {/if}
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import { FlexRender } from "@tanstack/svelte-table";
3
+ import type { Snippet } from "svelte";
4
+ import EmptyState from "../EmptyState.svelte";
5
+ import { createAppTable } from "./table.js";
6
+ import type { DataTableProps } from "./types.js";
7
+ import {
8
+ fromTableSortingState,
9
+ toTablePaginationState,
10
+ toTableSortingState,
11
+ } from "./types.js";
12
+
13
+ let {
14
+ columns,
15
+ data,
16
+ pagination,
17
+ sorting = { id: null, desc: false },
18
+ onSortingChange,
19
+ isLoading = false,
20
+ emptyTitle = "No rows",
21
+ emptyDescription = "There is nothing to show.",
22
+ emptyContent,
23
+ variant = "default",
24
+ label,
25
+ rowClass,
26
+ testId = "data-table",
27
+ scrollTestId = "data-table-scroll",
28
+ }: DataTableProps = $props();
29
+
30
+ const table = createAppTable({
31
+ get columns() {
32
+ return columns;
33
+ },
34
+ get data() {
35
+ return data;
36
+ },
37
+ getRowId: (row) => String(row.id ?? ""),
38
+ get rowCount() {
39
+ return pagination.rowCount;
40
+ },
41
+ get state() {
42
+ return {
43
+ pagination: toTablePaginationState(pagination),
44
+ sorting: toTableSortingState(sorting),
45
+ };
46
+ },
47
+ onSortingChange: (updater) => {
48
+ const next = fromTableSortingState(
49
+ typeof updater === "function"
50
+ ? updater(toTableSortingState(sorting))
51
+ : updater,
52
+ );
53
+ const current = fromTableSortingState(toTableSortingState(sorting));
54
+ if (
55
+ next.id === current.id &&
56
+ (next.id === null || next.desc === current.desc)
57
+ ) {
58
+ return;
59
+ }
60
+ onSortingChange?.(next);
61
+ },
62
+ manualPagination: true,
63
+ manualSorting: true,
64
+ enableSortingRemoval: true,
65
+ enableMultiSort: false,
66
+ sortDescFirst: false,
67
+ });
68
+
69
+ const rows = $derived(table.getRowModel().rows);
70
+ const quiet = $derived(variant === "quiet");
71
+ </script>
72
+
73
+ {#snippet emptyFallback()}
74
+ {#if emptyContent}
75
+ {@render emptyContent()}
76
+ {:else}
77
+ <EmptyState title={emptyTitle} description={emptyDescription} surface={false} />
78
+ {/if}
79
+ {/snippet}
80
+
81
+ {#if isLoading && rows.length === 0}
82
+ <div class="p-4">
83
+ <div
84
+ class="grid gap-3 p-5"
85
+ role="status"
86
+ aria-label="Loading table rows"
87
+ >
88
+ {#each Array(5) as _, index (index)}
89
+ <div
90
+ class="h-8 rounded-lg bg-[var(--stor-surface-muted)] motion-safe:animate-pulse"
91
+ style:width={`${100 - (index % 3) * 12}%`}
92
+ aria-hidden="true"
93
+ ></div>
94
+ {/each}
95
+ </div>
96
+ </div>
97
+ {:else if rows.length === 0}
98
+ <div class="p-4">
99
+ {@render emptyFallback()}
100
+ </div>
101
+ {:else}
102
+ <div class="max-w-full overflow-x-auto" data-testid={scrollTestId}>
103
+ <table
104
+ class="w-full border-collapse text-left text-sm {quiet ? '' : 'min-w-max'}"
105
+ data-testid={testId}
106
+ aria-label={label}
107
+ >
108
+ <thead>
109
+ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
110
+ <tr class={quiet ? "" : "bg-[var(--stor-surface-muted)]"}>
111
+ {#each headerGroup.headers as header (header.id)}
112
+ <th
113
+ scope="col"
114
+ class="px-3 align-bottom {quiet
115
+ ? 'pb-2 pt-0 text-xs'
116
+ : 'border-b border-[var(--stor-border)] py-2.5'} {header.column.columnDef.meta?.className ?? ''}"
117
+ >
118
+ {#if !header.isPlaceholder}
119
+ <FlexRender {header} />
120
+ {/if}
121
+ </th>
122
+ {/each}
123
+ </tr>
124
+ {/each}
125
+ </thead>
126
+ <tbody class={quiet ? "divide-y divide-[color-mix(in_srgb,var(--stor-border)_55%,transparent)]" : "divide-y divide-[var(--stor-border)]"}>
127
+ {#each rows as row (row.id)}
128
+ <tr class="align-middle transition-colors hover:bg-[var(--stor-surface-muted)]/70 {rowClass ? rowClass(row.original) : ''}">
129
+ {#each row.getAllCells() as cell (cell.id)}
130
+ <td class="max-w-[36rem] px-3 {quiet ? 'h-14' : 'py-2.5'} {cell.column.columnDef.meta?.className ?? ''}">
131
+ <FlexRender {cell} />
132
+ </td>
133
+ {/each}
134
+ </tr>
135
+ {/each}
136
+ </tbody>
137
+ </table>
138
+ </div>
139
+ {/if}