@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.
- package/LICENSE +215 -0
- package/README.md +20 -0
- package/package.json +128 -0
- package/src/app.d.ts +8 -0
- package/src/app.html +12 -0
- package/src/astro/CTABand.astro +32 -0
- package/src/astro/Hero.astro +58 -0
- package/src/astro/SiteFooter.astro +47 -0
- package/src/astro/SiteHeader.astro +46 -0
- package/src/lib/actions/portal.ts +16 -0
- package/src/lib/assets/runwisp-logo.svg +16 -0
- package/src/lib/components/Accordion.svelte +20 -0
- package/src/lib/components/AccordionItem.svelte +74 -0
- package/src/lib/components/Alert.svelte +107 -0
- package/src/lib/components/AlertDialog.svelte +63 -0
- package/src/lib/components/Avatar.svelte +88 -0
- package/src/lib/components/Badge.svelte +131 -0
- package/src/lib/components/Breadcrumb.svelte +58 -0
- package/src/lib/components/Button.svelte +69 -0
- package/src/lib/components/Card.svelte +70 -0
- package/src/lib/components/Checkbox.svelte +90 -0
- package/src/lib/components/CodeBlock.svelte +66 -0
- package/src/lib/components/CronInput.svelte +130 -0
- package/src/lib/components/DataGrid.svelte +368 -0
- package/src/lib/components/Drawer.svelte +156 -0
- package/src/lib/components/Dropdown.svelte +195 -0
- package/src/lib/components/DurationInput.svelte +170 -0
- package/src/lib/components/EmptyState.svelte +46 -0
- package/src/lib/components/ErrorState.svelte +73 -0
- package/src/lib/components/FeatureCard.svelte +35 -0
- package/src/lib/components/FeatureGrid.svelte +37 -0
- package/src/lib/components/FormField.svelte +53 -0
- package/src/lib/components/Heading.svelte +46 -0
- package/src/lib/components/Input.svelte +114 -0
- package/src/lib/components/Kbd.svelte +31 -0
- package/src/lib/components/Link.svelte +56 -0
- package/src/lib/components/LinkButton.svelte +55 -0
- package/src/lib/components/LogConsole.svelte +424 -0
- package/src/lib/components/Logo.svelte +34 -0
- package/src/lib/components/Modal.svelte +138 -0
- package/src/lib/components/PageContainer.svelte +38 -0
- package/src/lib/components/Pagination.svelte +96 -0
- package/src/lib/components/Popover.svelte +130 -0
- package/src/lib/components/Progress.svelte +68 -0
- package/src/lib/components/Prose.svelte +43 -0
- package/src/lib/components/Radio.svelte +82 -0
- package/src/lib/components/RadioGroup.svelte +53 -0
- package/src/lib/components/Select.svelte +454 -0
- package/src/lib/components/Separator.svelte +30 -0
- package/src/lib/components/Skeleton.svelte +75 -0
- package/src/lib/components/Sparkline.svelte +58 -0
- package/src/lib/components/Spinner.svelte +44 -0
- package/src/lib/components/StatusIndicator.svelte +203 -0
- package/src/lib/components/Table.svelte +19 -0
- package/src/lib/components/TableBody.svelte +17 -0
- package/src/lib/components/TableCell.svelte +34 -0
- package/src/lib/components/TableHead.svelte +19 -0
- package/src/lib/components/TableRow.svelte +22 -0
- package/src/lib/components/Tabs.svelte +148 -0
- package/src/lib/components/Text.svelte +57 -0
- package/src/lib/components/Textarea.svelte +81 -0
- package/src/lib/components/ThemeToggle.svelte +47 -0
- package/src/lib/components/TimezoneSelect.svelte +127 -0
- package/src/lib/components/ToastContainer.svelte +84 -0
- package/src/lib/components/Toggle.svelte +84 -0
- package/src/lib/components/Tooltip.svelte +56 -0
- package/src/lib/components/button-styles.ts +51 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
- package/src/lib/components/dashboard/RunsList.svelte +331 -0
- package/src/lib/components/dashboard/run-helpers.ts +12 -0
- package/src/lib/components/dashboard/status-config.ts +141 -0
- package/src/lib/components/dashboard/types.ts +35 -0
- package/src/lib/index.ts +122 -0
- package/src/lib/log-console/LogCache.svelte.ts +93 -0
- package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
- package/src/lib/log-console/types.ts +24 -0
- package/src/lib/theme-tokens.css +235 -0
- package/src/lib/theme.css +276 -0
- package/src/lib/utils/error.ts +18 -0
- package/src/lib/utils/format.ts +76 -0
- package/src/lib/utils/id.ts +6 -0
- package/src/lib/utils/toast.svelte.ts +87 -0
- package/src/lib/utils/toast.ts +60 -0
- package/src/routes/+layout.svelte +10 -0
- package/src/routes/+page.svelte +5 -0
- package/src/routes/layout.css +3 -0
- package/src/stories/Alert.stories.svelte +64 -0
- package/src/stories/Avatar.stories.svelte +76 -0
- package/src/stories/Badge.stories.svelte +76 -0
- package/src/stories/Breadcrumb.stories.svelte +64 -0
- package/src/stories/Button.stories.svelte +101 -0
- package/src/stories/Card.stories.svelte +106 -0
- package/src/stories/Checkbox.stories.svelte +77 -0
- package/src/stories/DataGrid.stories.svelte +130 -0
- package/src/stories/Dropdown.stories.svelte +81 -0
- package/src/stories/EmptyState.stories.svelte +74 -0
- package/src/stories/Input.stories.svelte +88 -0
- package/src/stories/Logo.stories.svelte +82 -0
- package/src/stories/Modal.stories.svelte +101 -0
- package/src/stories/Progress.stories.svelte +65 -0
- package/src/stories/Select.stories.svelte +158 -0
- package/src/stories/Tabs.stories.svelte +103 -0
- package/src/stories/Textarea.stories.svelte +45 -0
- package/src/stories/Toggle.stories.svelte +74 -0
- package/src/stories/Tooltip.stories.svelte +77 -0
- package/src/stories/Welcome.mdx +241 -0
- package/src/stories/assets/runwisp_c.svg +16 -0
- 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>
|