@hearthlog/ui 0.0.0-stage → 0.1.1
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/LICENSE +235 -0
- package/package.json +37 -3
- package/src/assets/fonts/fira-code-latin-ext-ui.woff2 +0 -0
- package/src/assets/fonts/fira-code-latin-ui.woff2 +0 -0
- package/src/assets/fonts/inter-latin-ext-ui.woff2 +0 -0
- package/src/assets/fonts/inter-latin-ui.woff2 +0 -0
- package/src/components/ConfirmDialog.svelte +104 -0
- package/src/components/Drawer.svelte +135 -0
- package/src/components/EmptyState.svelte +33 -0
- package/src/components/JsonViewer.svelte +113 -0
- package/src/components/Modal.svelte +76 -0
- package/src/components/NotificationCenter.svelte +106 -0
- package/src/components/PageHeader.svelte +26 -0
- package/src/components/commands/CommandConfirmDialog.svelte +32 -0
- package/src/components/commands/CommandDrawer.svelte +31 -0
- package/src/components/commands/CommandFieldset.svelte +18 -0
- package/src/components/commands/CommandModal.svelte +25 -0
- package/src/components/commands/CommandOnly.svelte +26 -0
- package/src/components/data/DataPlaceholder.svelte +8 -0
- package/src/components/data/DataRegion.svelte +8 -0
- package/src/components/data/DataValue.svelte +11 -0
- package/src/components/data-table/DataTable.svelte +139 -0
- package/src/components/data-table/DataTableHeader.svelte +78 -0
- package/src/components/data-table/DataTablePagination.svelte +91 -0
- package/src/components/data-table/table.ts +30 -0
- package/src/components/data-table/types.ts +63 -0
- package/src/components/endless/DayPager.svelte +355 -0
- package/src/components/endless/EndlessList.svelte +498 -0
- package/src/components/endless/bucket-store.svelte.ts +81 -0
- package/src/components/json-viewer.ts +49 -0
- package/src/components/ui/Amount.svelte +17 -0
- package/src/components/ui/Badge.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Checkbox.svelte +29 -0
- package/src/components/ui/ChoiceChips.svelte +53 -0
- package/src/components/ui/CollectionToolbar.svelte +29 -0
- package/src/components/ui/DatePicker.svelte +375 -0
- package/src/components/ui/DateTimePicker.svelte +16 -0
- package/src/components/ui/Field.svelte +52 -0
- package/src/components/ui/FoldLine.svelte +38 -0
- package/src/components/ui/Input.svelte +21 -0
- package/src/components/ui/Label.svelte +18 -0
- package/src/components/ui/Metric.svelte +34 -0
- package/src/components/ui/MonthNavigator.svelte +194 -0
- package/src/components/ui/Panel.svelte +37 -0
- package/src/components/ui/RouteTabs.svelte +57 -0
- package/src/components/ui/Select.svelte +93 -0
- package/src/components/ui/Switch.svelte +41 -0
- package/src/components/ui/Textarea.svelte +19 -0
- package/src/components/ui/Tooltip.svelte +53 -0
- package/src/components/ui/tab-indicator.ts +84 -0
- package/src/state/commands.ts +22 -0
- package/src/state/notifications.svelte.ts +49 -0
- package/src/state/page-context.ts +9 -0
- package/src/state/readiness.ts +20 -0
- package/src/state/sheet-stack.svelte.ts +49 -0
- package/src/styles/base.css +243 -0
- package/src/styles/index.css +13 -0
- package/src/styles/recipes.css +174 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/endless-axis.ts +153 -0
- package/src/utils/number-format.ts +82 -0
- 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> </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}
|