@runwisp/ui 0.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.
Files changed (108) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +20 -0
  3. package/package.json +128 -0
  4. package/src/app.d.ts +8 -0
  5. package/src/app.html +12 -0
  6. package/src/astro/CTABand.astro +32 -0
  7. package/src/astro/Hero.astro +58 -0
  8. package/src/astro/SiteFooter.astro +47 -0
  9. package/src/astro/SiteHeader.astro +46 -0
  10. package/src/lib/actions/portal.ts +16 -0
  11. package/src/lib/assets/runwisp-logo.svg +16 -0
  12. package/src/lib/components/Accordion.svelte +20 -0
  13. package/src/lib/components/AccordionItem.svelte +74 -0
  14. package/src/lib/components/Alert.svelte +107 -0
  15. package/src/lib/components/AlertDialog.svelte +63 -0
  16. package/src/lib/components/Avatar.svelte +88 -0
  17. package/src/lib/components/Badge.svelte +131 -0
  18. package/src/lib/components/Breadcrumb.svelte +58 -0
  19. package/src/lib/components/Button.svelte +69 -0
  20. package/src/lib/components/Card.svelte +70 -0
  21. package/src/lib/components/Checkbox.svelte +90 -0
  22. package/src/lib/components/CodeBlock.svelte +66 -0
  23. package/src/lib/components/CronInput.svelte +130 -0
  24. package/src/lib/components/DataGrid.svelte +368 -0
  25. package/src/lib/components/Drawer.svelte +156 -0
  26. package/src/lib/components/Dropdown.svelte +195 -0
  27. package/src/lib/components/DurationInput.svelte +170 -0
  28. package/src/lib/components/EmptyState.svelte +46 -0
  29. package/src/lib/components/ErrorState.svelte +73 -0
  30. package/src/lib/components/FeatureCard.svelte +35 -0
  31. package/src/lib/components/FeatureGrid.svelte +37 -0
  32. package/src/lib/components/FormField.svelte +53 -0
  33. package/src/lib/components/Heading.svelte +46 -0
  34. package/src/lib/components/Input.svelte +114 -0
  35. package/src/lib/components/Kbd.svelte +31 -0
  36. package/src/lib/components/Link.svelte +56 -0
  37. package/src/lib/components/LinkButton.svelte +55 -0
  38. package/src/lib/components/LogConsole.svelte +424 -0
  39. package/src/lib/components/Logo.svelte +34 -0
  40. package/src/lib/components/Modal.svelte +138 -0
  41. package/src/lib/components/PageContainer.svelte +38 -0
  42. package/src/lib/components/Pagination.svelte +96 -0
  43. package/src/lib/components/Popover.svelte +130 -0
  44. package/src/lib/components/Progress.svelte +68 -0
  45. package/src/lib/components/Prose.svelte +43 -0
  46. package/src/lib/components/Radio.svelte +82 -0
  47. package/src/lib/components/RadioGroup.svelte +53 -0
  48. package/src/lib/components/Select.svelte +454 -0
  49. package/src/lib/components/Separator.svelte +30 -0
  50. package/src/lib/components/Skeleton.svelte +75 -0
  51. package/src/lib/components/Sparkline.svelte +58 -0
  52. package/src/lib/components/Spinner.svelte +44 -0
  53. package/src/lib/components/StatusIndicator.svelte +203 -0
  54. package/src/lib/components/Table.svelte +19 -0
  55. package/src/lib/components/TableBody.svelte +17 -0
  56. package/src/lib/components/TableCell.svelte +34 -0
  57. package/src/lib/components/TableHead.svelte +19 -0
  58. package/src/lib/components/TableRow.svelte +22 -0
  59. package/src/lib/components/Tabs.svelte +148 -0
  60. package/src/lib/components/Text.svelte +57 -0
  61. package/src/lib/components/Textarea.svelte +81 -0
  62. package/src/lib/components/ThemeToggle.svelte +47 -0
  63. package/src/lib/components/TimezoneSelect.svelte +127 -0
  64. package/src/lib/components/ToastContainer.svelte +84 -0
  65. package/src/lib/components/Toggle.svelte +84 -0
  66. package/src/lib/components/Tooltip.svelte +56 -0
  67. package/src/lib/components/button-styles.ts +51 -0
  68. package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
  69. package/src/lib/components/dashboard/RunsList.svelte +331 -0
  70. package/src/lib/components/dashboard/run-helpers.ts +12 -0
  71. package/src/lib/components/dashboard/status-config.ts +141 -0
  72. package/src/lib/components/dashboard/types.ts +35 -0
  73. package/src/lib/index.ts +122 -0
  74. package/src/lib/log-console/LogCache.svelte.ts +93 -0
  75. package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
  76. package/src/lib/log-console/types.ts +24 -0
  77. package/src/lib/theme-tokens.css +235 -0
  78. package/src/lib/theme.css +276 -0
  79. package/src/lib/utils/error.ts +18 -0
  80. package/src/lib/utils/format.ts +76 -0
  81. package/src/lib/utils/id.ts +6 -0
  82. package/src/lib/utils/toast.svelte.ts +87 -0
  83. package/src/lib/utils/toast.ts +60 -0
  84. package/src/routes/+layout.svelte +10 -0
  85. package/src/routes/+page.svelte +5 -0
  86. package/src/routes/layout.css +3 -0
  87. package/src/stories/Alert.stories.svelte +64 -0
  88. package/src/stories/Avatar.stories.svelte +76 -0
  89. package/src/stories/Badge.stories.svelte +76 -0
  90. package/src/stories/Breadcrumb.stories.svelte +64 -0
  91. package/src/stories/Button.stories.svelte +101 -0
  92. package/src/stories/Card.stories.svelte +106 -0
  93. package/src/stories/Checkbox.stories.svelte +77 -0
  94. package/src/stories/DataGrid.stories.svelte +130 -0
  95. package/src/stories/Dropdown.stories.svelte +81 -0
  96. package/src/stories/EmptyState.stories.svelte +74 -0
  97. package/src/stories/Input.stories.svelte +88 -0
  98. package/src/stories/Logo.stories.svelte +82 -0
  99. package/src/stories/Modal.stories.svelte +101 -0
  100. package/src/stories/Progress.stories.svelte +65 -0
  101. package/src/stories/Select.stories.svelte +158 -0
  102. package/src/stories/Tabs.stories.svelte +103 -0
  103. package/src/stories/Textarea.stories.svelte +45 -0
  104. package/src/stories/Toggle.stories.svelte +74 -0
  105. package/src/stories/Tooltip.stories.svelte +77 -0
  106. package/src/stories/Welcome.mdx +241 -0
  107. package/src/stories/assets/runwisp_c.svg +16 -0
  108. package/src/svelte-shims.d.ts +18 -0
@@ -0,0 +1,90 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { HTMLInputAttributes } from "svelte/elements";
6
+ import { Check } from "@lucide/svelte";
7
+
8
+ type CheckboxSize = "sm" | "md" | "lg";
9
+
10
+ interface Props extends Omit<HTMLInputAttributes, "size" | "type"> {
11
+ size?: CheckboxSize;
12
+ label?: string;
13
+ description?: string;
14
+ checked?: boolean;
15
+ indeterminate?: boolean;
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ size = "md",
21
+ label,
22
+ description,
23
+ checked = $bindable(false),
24
+ indeterminate = false,
25
+ disabled = false,
26
+ class: className = "",
27
+ ...restProps
28
+ }: Props = $props();
29
+
30
+ const sizeClasses: Record<CheckboxSize, { box: string; icon: number; text: string }> = {
31
+ sm: { box: "h-4 w-4", icon: 12, text: "text-sm" },
32
+ md: { box: "h-5 w-5", icon: 14, text: "text-sm" },
33
+ lg: { box: "h-6 w-6", icon: 16, text: "text-base" },
34
+ };
35
+ </script>
36
+
37
+ <label
38
+ class="inline-flex items-start gap-3 {disabled
39
+ ? 'cursor-not-allowed opacity-50'
40
+ : 'cursor-pointer'} {className}"
41
+ >
42
+ <div class="relative flex shrink-0 items-center justify-center">
43
+ <input
44
+ type="checkbox"
45
+ bind:checked
46
+ {disabled}
47
+ class="
48
+ peer {sizeClasses[size].box} duration-fast
49
+ cursor-pointer appearance-none rounded
50
+ border-2 border-outline-hover
51
+ bg-surface-raised transition-colors checked:border-primary
52
+ checked:bg-primary hover:border-on-surface-faint checked:hover:border-primary-hover checked:hover:bg-primary-hover
53
+ focus:ring-2 focus:ring-ring/20
54
+ focus:ring-offset-0 focus:outline-none
55
+ disabled:cursor-not-allowed disabled:hover:border-outline-hover
56
+ disabled:checked:hover:bg-primary
57
+ "
58
+ {...restProps}
59
+ />
60
+ <div
61
+ class="duration-fast pointer-events-none absolute text-on-primary opacity-0 transition-opacity peer-checked:opacity-100"
62
+ >
63
+ {#if indeterminate}
64
+ <svg
65
+ width={sizeClasses[size].icon}
66
+ height={sizeClasses[size].icon}
67
+ viewBox="0 0 24 24"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ stroke-width="3"
71
+ >
72
+ <line x1="5" y1="12" x2="19" y2="12" />
73
+ </svg>
74
+ {:else}
75
+ <Check size={sizeClasses[size].icon} strokeWidth={3} />
76
+ {/if}
77
+ </div>
78
+ </div>
79
+
80
+ {#if label || description}
81
+ <div class="flex flex-col gap-0.5">
82
+ {#if label}
83
+ <span class="{sizeClasses[size].text} font-medium text-on-surface">{label}</span>
84
+ {/if}
85
+ {#if description}
86
+ <span class="text-sm text-on-surface-muted">{description}</span>
87
+ {/if}
88
+ </div>
89
+ {/if}
90
+ </label>
@@ -0,0 +1,66 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { Check, Copy } from "@lucide/svelte";
6
+
7
+ interface Props {
8
+ code: string;
9
+ language?: string;
10
+ filename?: string;
11
+ class?: string;
12
+ }
13
+
14
+ let { code, language, filename, class: className = "" }: Props = $props();
15
+
16
+ let copied = $state(false);
17
+ let resetTimer = $state<ReturnType<typeof setTimeout> | undefined>(undefined);
18
+
19
+ async function writeClipboard(): Promise<void> {
20
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
21
+ try {
22
+ await navigator.clipboard.writeText(code);
23
+ copied = true;
24
+ if (resetTimer) clearTimeout(resetTimer);
25
+ resetTimer = setTimeout(() => {
26
+ copied = false;
27
+ }, 2000);
28
+ } catch {
29
+ copied = false;
30
+ }
31
+ }
32
+
33
+ function handleCopy(): void {
34
+ void writeClipboard();
35
+ }
36
+ </script>
37
+
38
+ <div class="overflow-hidden rounded-xl border border-outline bg-surface-sunken {className}">
39
+ {#if filename || language}
40
+ <div
41
+ class="flex items-center justify-between border-b border-outline-faint px-4 py-2 font-mono text-xs text-on-surface-muted"
42
+ >
43
+ <span>{filename ?? language}</span>
44
+ </div>
45
+ {/if}
46
+ <div class="relative">
47
+ <pre
48
+ class="overflow-x-auto px-4 py-4 font-mono text-sm leading-relaxed text-on-surface"><code
49
+ >{code}</code
50
+ ></pre>
51
+ <button
52
+ type="button"
53
+ onclick={handleCopy}
54
+ class="absolute top-2 right-2 inline-flex items-center gap-1.5 rounded-md border border-outline bg-surface-raised px-2 py-1 text-xs font-medium text-on-surface-muted shadow-sm transition-colors hover:border-outline-hover hover:text-on-surface"
55
+ aria-label={copied ? "Copied" : "Copy to clipboard"}
56
+ >
57
+ {#if copied}
58
+ <Check size={14} class="text-success-surface" />
59
+ <span>Copied</span>
60
+ {:else}
61
+ <Copy size={14} />
62
+ <span>Copy</span>
63
+ {/if}
64
+ </button>
65
+ </div>
66
+ </div>
@@ -0,0 +1,130 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { Info } from "@lucide/svelte";
6
+ import Input from "./Input.svelte";
7
+ import Modal from "./Modal.svelte";
8
+ import cronstrue from "cronstrue";
9
+ import { CronExpressionParser } from "cron-parser";
10
+
11
+ let { value = $bindable(), disabled = false } = $props<{
12
+ value: string;
13
+ disabled?: boolean;
14
+ }>();
15
+
16
+ let examplesModalOpen = $state(false);
17
+
18
+ const cronExamples = [
19
+ { cron: "* * * * *", description: "Every minute" },
20
+ { cron: "0 * * * *", description: "Every hour" },
21
+ { cron: "0 0 * * *", description: "Every day at midnight" },
22
+ { cron: "0 0 * * 0", description: "Every Sunday at midnight" },
23
+ { cron: "0 0 1 * *", description: "First day of every month" },
24
+ { cron: "0 9 * * 1-5", description: "Weekdays at 9 AM" },
25
+ { cron: "*/15 * * * *", description: "Every 15 minutes" },
26
+ { cron: "0 */6 * * *", description: "Every 6 hours" },
27
+ { cron: "30 4 1,15 * *", description: "1st and 15th at 4:30 AM" },
28
+ { cron: "0 0 * * 0,6", description: "Weekends at midnight" },
29
+ ];
30
+
31
+ function selectExample(cron: string) {
32
+ value = cron;
33
+ examplesModalOpen = false;
34
+ }
35
+
36
+ let explanation = $derived.by(() => {
37
+ if (!value || value.trim() === "") {
38
+ return { text: "Empty schedule", next: undefined, isValid: false };
39
+ }
40
+ try {
41
+ // cronstrue throws on invalid cron if throwExceptionOnParseError is true,
42
+ // but we want to catch it to show "Invalid cron expression".
43
+ const text = cronstrue.toString(value, { throwExceptionOnParseError: true });
44
+
45
+ // Calculate next run
46
+ let next: Date | undefined;
47
+ try {
48
+ const interval = CronExpressionParser.parse(value);
49
+ next = interval.next().toDate();
50
+ } catch {
51
+ // If parser fails, we consider it invalid or at least can't show next run
52
+ }
53
+
54
+ return { text, next, isValid: true };
55
+ } catch {
56
+ return { text: "Invalid cron expression", next: undefined, isValid: false };
57
+ }
58
+ });
59
+
60
+ // Determine state for styling
61
+ let isError = $derived(!disabled && value && value.trim() !== "" && !explanation.isValid);
62
+ </script>
63
+
64
+ <div class="space-y-2">
65
+ <div class="flex items-center justify-between">
66
+ <div class="text-sm font-medium text-on-surface-muted">Cron Schedule</div>
67
+ <button
68
+ type="button"
69
+ class="flex items-center gap-1 text-xs text-primary hover:text-primary-hover"
70
+ onclick={() => (examplesModalOpen = true)}
71
+ >
72
+ Examples <Info size={12} />
73
+ </button>
74
+ </div>
75
+ <div class="flex gap-4">
76
+ <div class="flex-1">
77
+ <!-- Pass connection-status-like classes effectively by using the component props or class -->
78
+ <Input
79
+ placeholder="* * * * *"
80
+ bind:value
81
+ {disabled}
82
+ class="font-mono {isError
83
+ ? '!focus:border-danger-500 !focus:ring-danger-500 border-danger-300!'
84
+ : ''}"
85
+ />
86
+ </div>
87
+ </div>
88
+ <!-- Live Feedback -->
89
+ <div
90
+ class="flex items-start gap-3 rounded-md border p-3 transition-colors duration-200
91
+ {isError ? 'border-danger-200 bg-danger-soft' : 'border-outline bg-surface-sunken'}"
92
+ >
93
+ <Info size={18} class="mt-0.5 {isError ? 'text-danger-surface' : 'text-primary'}" />
94
+ <div>
95
+ <div
96
+ class="text-sm font-medium {isError ? 'text-danger-soft-text' : 'text-on-surface'}"
97
+ >
98
+ {explanation.text}
99
+ </div>
100
+ {#if explanation.next}
101
+ <div class="mt-0.5 text-xs text-on-surface-muted">
102
+ Next run: <span class="font-medium text-on-surface"
103
+ >{explanation.next.toLocaleString()}</span
104
+ >
105
+ </div>
106
+ {/if}
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <Modal title="Cron Examples" size="lg" bind:open={examplesModalOpen}>
112
+ <div class="space-y-2">
113
+ <p class="text-sm text-on-surface-muted">
114
+ Click an example to use it, or use the format: minute hour day-of-month month
115
+ day-of-week
116
+ </p>
117
+ <div class="mt-4 max-h-100 space-y-1 overflow-y-auto">
118
+ {#each cronExamples as example (example.cron)}
119
+ <button
120
+ type="button"
121
+ class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-colors hover:bg-surface-sunken"
122
+ onclick={() => selectExample(example.cron)}
123
+ >
124
+ <code class="font-mono text-sm text-on-surface">{example.cron}</code>
125
+ <span class="text-sm text-on-surface-muted">{example.description}</span>
126
+ </button>
127
+ {/each}
128
+ </div>
129
+ </div>
130
+ </Modal>
@@ -0,0 +1,368 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script module lang="ts">
5
+ import type { Snippet } from "svelte";
6
+
7
+ export interface Column<T> {
8
+ key: keyof T | string;
9
+ label: string;
10
+ sortable?: boolean;
11
+ width?: string;
12
+ align?: "left" | "center" | "right";
13
+ render?: Snippet<[T]>;
14
+ }
15
+
16
+ export interface DataGridProps<T> {
17
+ columns: Column<T>[];
18
+ data: T[];
19
+ selectable?: boolean;
20
+ selectedRows?: T[];
21
+ sortKey?: string;
22
+ sortDirection?: "asc" | "desc";
23
+ onSort?: (key: string) => void;
24
+ filterable?: boolean;
25
+ filterPlaceholder?: string;
26
+ filterKeys?: string[];
27
+ filterQuery?: string;
28
+ paginate?: boolean;
29
+ page?: number;
30
+ pageSize?: number;
31
+ pageSizeOptions?: number[];
32
+ total?: number;
33
+ onPageChange?: (page: number) => void;
34
+ onPageSizeChange?: (pageSize: number) => void;
35
+ onSelect?: (rows: T[]) => void;
36
+ emptyState?: Snippet;
37
+ emptyMessage?: string;
38
+ striped?: boolean;
39
+ hoverable?: boolean;
40
+ compact?: boolean;
41
+ rowKey?: keyof T;
42
+ rowAction?: Snippet<[T]>;
43
+ onRowClick?: (row: T) => void;
44
+ loading?: boolean;
45
+ stickyHeader?: boolean;
46
+ class?: string;
47
+ wrapperClass?: string;
48
+ }
49
+ </script>
50
+
51
+ <script lang="ts" generics="T extends object">
52
+ import { ArrowUp, ArrowDown, ArrowUpDown } from "@lucide/svelte";
53
+ import Checkbox from "./Checkbox.svelte";
54
+ import Input from "./Input.svelte";
55
+ import Pagination from "./Pagination.svelte";
56
+ import Fuse from "fuse.js";
57
+
58
+ import dlv from "dlv";
59
+
60
+ let {
61
+ columns,
62
+ data,
63
+ selectable = false,
64
+ selectedRows = $bindable([]),
65
+ sortKey = $bindable(undefined),
66
+ sortDirection = $bindable(undefined),
67
+ onSort,
68
+ filterable = false,
69
+ filterPlaceholder = "Filter…",
70
+ filterKeys,
71
+ filterQuery = $bindable(""),
72
+ paginate = false,
73
+ page = $bindable(1),
74
+ pageSize = $bindable(20),
75
+ pageSizeOptions,
76
+ total,
77
+ onPageChange,
78
+ onPageSizeChange,
79
+ onSelect,
80
+ emptyState,
81
+ emptyMessage = "No data available",
82
+ striped = false,
83
+ hoverable = true,
84
+ compact = false,
85
+ rowKey = "id" as keyof T,
86
+ rowAction,
87
+ onRowClick,
88
+ loading = false,
89
+ stickyHeader = false,
90
+ class: className = "",
91
+ wrapperClass,
92
+ }: DataGridProps<T> = $props();
93
+
94
+ function toggleAll() {
95
+ if (allSelected) {
96
+ selectedRows = [];
97
+ } else {
98
+ selectedRows = [...pagedData];
99
+ }
100
+ onSelect?.(selectedRows);
101
+ }
102
+
103
+ function toggleRow(row: T) {
104
+ const idx = selectedRows.findIndex((r) => r[rowKey] === row[rowKey]);
105
+ if (idx >= 0) {
106
+ selectedRows = selectedRows.filter((_, i) => i !== idx);
107
+ } else {
108
+ selectedRows = [...selectedRows, row];
109
+ }
110
+ onSelect?.(selectedRows);
111
+ }
112
+
113
+ function isSelected(row: T): boolean {
114
+ return selectedRows.some((r) => r[rowKey] === row[rowKey]);
115
+ }
116
+
117
+ function handleSort(key: string) {
118
+ if (onSort) {
119
+ onSort(key);
120
+ return;
121
+ }
122
+
123
+ if (sortKey === key) {
124
+ sortDirection = sortDirection === "asc" ? "desc" : "asc";
125
+ } else {
126
+ sortKey = key;
127
+ sortDirection = "asc";
128
+ }
129
+ }
130
+
131
+ const searchableKeys = $derived(
132
+ filterKeys?.length ? filterKeys : columns.map((c) => String(c.key)),
133
+ );
134
+
135
+ const fuse = $derived(
136
+ new Fuse(data, {
137
+ keys: searchableKeys,
138
+ threshold: 0.3,
139
+ ignoreLocation: true,
140
+ }),
141
+ );
142
+
143
+ const filteredData = $derived.by(() => {
144
+ if (typeof total === "number") return data;
145
+
146
+ if (!filterable || !filterQuery.trim()) return data;
147
+
148
+ return fuse.search(filterQuery).map((r) => r.item);
149
+ });
150
+
151
+ const totalItems = $derived(total ?? filteredData.length);
152
+ const totalPages = $derived(Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize))));
153
+
154
+ $effect(() => {
155
+ if (!paginate) return;
156
+ if (page < 1) page = 1;
157
+ if (totalItems > 0 && page > totalPages) page = totalPages;
158
+ });
159
+
160
+ const pagedData = $derived.by(() => {
161
+ if (typeof total === "number") return filteredData;
162
+ if (!paginate) return filteredData;
163
+ const start = (page - 1) * pageSize;
164
+ return filteredData.slice(start, start + pageSize);
165
+ });
166
+
167
+ let allSelected = $derived(pagedData.length > 0 && selectedRows.length === pagedData.length);
168
+ let someSelected = $derived(selectedRows.length > 0 && selectedRows.length < pagedData.length);
169
+ </script>
170
+
171
+ <div
172
+ class="relative flex flex-col rounded-xl border border-outline bg-surface-raised shadow-sm {className}"
173
+ >
174
+ {#if filterable}
175
+ <div class="border-b border-outline p-3">
176
+ <Input
177
+ value={filterQuery}
178
+ oninput={(e) => {
179
+ filterQuery = (e.currentTarget as HTMLInputElement).value;
180
+ if (paginate && !total) page = 1;
181
+ }}
182
+ type="search"
183
+ placeholder={filterPlaceholder}
184
+ size="sm"
185
+ class="max-w-sm"
186
+ />
187
+ </div>
188
+ {/if}
189
+
190
+ <div
191
+ class="relative min-h-0 flex-1 rounded-t-xl rounded-b-xl {wrapperClass ??
192
+ (stickyHeader ? '' : 'overflow-x-auto')}"
193
+ >
194
+ {#if loading}
195
+ <div
196
+ class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised/50 backdrop-blur-[1px]"
197
+ >
198
+ <div
199
+ class="flex items-center gap-3 rounded-full border border-outline-faint bg-surface-raised px-4 py-2 shadow-lg"
200
+ >
201
+ <svg class="h-5 w-5 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
202
+ <circle
203
+ class="opacity-25"
204
+ cx="12"
205
+ cy="12"
206
+ r="10"
207
+ stroke="currentColor"
208
+ stroke-width="4"
209
+ ></circle>
210
+ <path
211
+ class="opacity-75"
212
+ fill="currentColor"
213
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
214
+ ></path>
215
+ </svg>
216
+ <span class="text-sm font-medium text-on-surface-muted">Loading...</span>
217
+ </div>
218
+ </div>
219
+ {/if}
220
+
221
+ <table class="w-full text-sm">
222
+ <thead
223
+ class={stickyHeader
224
+ ? "sticky top-0 z-10 bg-surface-sunken/95 backdrop-blur-sm"
225
+ : "bg-surface-sunken/50"}
226
+ >
227
+ <tr class="border-b border-outline text-left">
228
+ {#if selectable}
229
+ <th class="w-12 px-4 py-3">
230
+ <Checkbox
231
+ checked={allSelected}
232
+ indeterminate={someSelected}
233
+ onchange={toggleAll}
234
+ size="sm"
235
+ />
236
+ </th>
237
+ {/if}
238
+ {#each columns as column (column.key)}
239
+ <th
240
+ class="
241
+ group px-4 {compact ? 'py-2' : 'py-3'}
242
+ font-medium text-on-surface-muted
243
+ {column.align === 'center' ? 'text-center' : ''}
244
+ {column.align === 'right' ? 'text-right' : ''}
245
+ "
246
+ style={column.width ? `width: ${column.width}` : ""}
247
+ >
248
+ {#if column.sortable}
249
+ <button
250
+ onclick={() => handleSort(column.key as string)}
251
+ class="
252
+ inline-flex items-center gap-1 transition-colors group-hover:text-on-surface
253
+ {sortKey === column.key ? 'text-on-surface' : ''}
254
+ "
255
+ >
256
+ {column.label}
257
+ {#if sortKey === column.key}
258
+ {#if sortDirection === "asc"}
259
+ <ArrowUp size={14} class="text-primary" />
260
+ {:else}
261
+ <ArrowDown size={14} class="text-primary" />
262
+ {/if}
263
+ {:else}
264
+ <ArrowUpDown
265
+ size={14}
266
+ class="text-on-surface-faint opacity-0 transition-opacity group-hover:opacity-100"
267
+ />
268
+ {/if}
269
+ </button>
270
+ {:else}
271
+ {column.label}
272
+ {/if}
273
+ </th>
274
+ {/each}
275
+ {#if rowAction}
276
+ <th class="w-16 px-4 py-3"></th>
277
+ {/if}
278
+ </tr>
279
+ </thead>
280
+ <tbody>
281
+ {#if pagedData.length === 0 && !loading}
282
+ <tr>
283
+ <td
284
+ colspan={columns.length + (selectable ? 1 : 0) + (rowAction ? 1 : 0)}
285
+ class="px-4 py-12 text-center text-on-surface-muted"
286
+ >
287
+ {#if emptyState}
288
+ {@render emptyState()}
289
+ {:else}
290
+ {emptyMessage}
291
+ {/if}
292
+ </td>
293
+ </tr>
294
+ {:else}
295
+ {#each pagedData as row, idx (idx)}
296
+ <tr
297
+ class="
298
+ border-b border-outline-faint last:border-b-0
299
+ {striped && idx % 2 === 1 ? 'bg-surface-sunken/30' : ''}
300
+ {hoverable ? 'hover:bg-surface-sunken' : ''}
301
+ {isSelected(row) ? 'bg-primary-soft/30' : ''}
302
+ {onRowClick ? 'cursor-pointer' : ''}
303
+ transition-colors duration-200
304
+ "
305
+ onclick={() => onRowClick?.(row)}
306
+ >
307
+ {#if selectable}
308
+ <td
309
+ class="px-4 {compact ? 'py-2' : 'py-3'} cursor-pointer"
310
+ onclick={(e) => {
311
+ if ((e.target as HTMLElement).tagName !== "INPUT")
312
+ toggleRow(row);
313
+ }}
314
+ >
315
+ <Checkbox
316
+ checked={isSelected(row)}
317
+ size="sm"
318
+ class="pointer-events-none"
319
+ />
320
+ </td>
321
+ {/if}
322
+ {#each columns as column (column.key)}
323
+ <td
324
+ class="
325
+ px-4 {compact ? 'py-2' : 'py-3'} text-on-surface-muted
326
+ {column.align === 'center' ? 'text-center' : ''}
327
+ {column.align === 'right' ? 'text-right' : ''}
328
+ "
329
+ >
330
+ {#if column.render}
331
+ {@render column.render(row)}
332
+ {:else}
333
+ {dlv(row, column.key as string) ?? "-"}
334
+ {/if}
335
+ </td>
336
+ {/each}
337
+ {#if rowAction}
338
+ <td class="px-4 {compact ? 'py-2' : 'py-3'} text-right">
339
+ {@render rowAction(row)}
340
+ </td>
341
+ {/if}
342
+ </tr>
343
+ {/each}
344
+ {/if}
345
+ </tbody>
346
+ </table>
347
+ </div>
348
+
349
+ {#if paginate}
350
+ <div class="border-t border-outline p-3">
351
+ <Pagination
352
+ {page}
353
+ {pageSize}
354
+ {pageSizeOptions}
355
+ {totalItems}
356
+ onPageChange={(p) => {
357
+ page = p;
358
+ onPageChange?.(p);
359
+ }}
360
+ onPageSizeChange={(s) => {
361
+ pageSize = s;
362
+ page = 1;
363
+ onPageSizeChange?.(s);
364
+ }}
365
+ />
366
+ </div>
367
+ {/if}
368
+ </div>