@runwisp/ui 1.0.1 → 1.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.
- package/package.json +4 -4
- package/src/astro/SiteFooter.astro +149 -31
- package/src/lib/components/AlertDialog.svelte +63 -25
- package/src/lib/components/Button.svelte +1 -1
- package/src/lib/components/DataGrid.svelte +151 -90
- package/src/lib/components/FilterBar.svelte +156 -0
- package/src/lib/components/FilterPill.svelte +180 -0
- package/src/lib/components/NotificationBell.svelte +70 -0
- package/src/lib/components/SearchInput.svelte +91 -0
- package/src/lib/components/StatusDot.svelte +38 -0
- package/src/lib/components/ThemeToggle.svelte +28 -33
- package/src/lib/components/filter-spec.ts +94 -0
- package/src/lib/index.ts +26 -1
- package/src/lib/utils/debounce.test.ts +31 -0
- package/src/lib/utils/debounce.ts +22 -0
- package/src/lib/utils/filter.test.ts +172 -0
- package/src/lib/utils/filter.ts +79 -0
- package/src/lib/utils/theme.svelte.test.ts +142 -0
- package/src/lib/utils/theme.svelte.ts +71 -0
- package/src/stories/DataGrid.stories.svelte +64 -1
- package/src/stories/FilterBar.stories.svelte +61 -0
- package/src/stories/Welcome.mdx +1 -1
- package/src/astro/CTABand.astro +0 -39
- package/src/astro/Hero.astro +0 -58
- package/src/astro/SiteHeader.astro +0 -46
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<!--
|
|
5
|
+
One filter, one element. The pill is both the control and its own "chip":
|
|
6
|
+
inactive it reads `Label ▾` (muted); active it reads `Label: value ✕` (accent)
|
|
7
|
+
with an inline clear. Active vs inactive is a restyle of the SAME element — it
|
|
8
|
+
never spawns a second row, so the table below never jumps. Clicking the body
|
|
9
|
+
opens a type-specific editor popover (select menu / number / date range).
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import { untrack } from "svelte";
|
|
14
|
+
import Popover from "./Popover.svelte";
|
|
15
|
+
import Input from "./Input.svelte";
|
|
16
|
+
import { Check, ChevronDown, X } from "@lucide/svelte";
|
|
17
|
+
import {
|
|
18
|
+
type FilterField,
|
|
19
|
+
type FilterValues,
|
|
20
|
+
fieldKeys,
|
|
21
|
+
fieldLabel,
|
|
22
|
+
isFieldActive,
|
|
23
|
+
isBlank,
|
|
24
|
+
} from "./filter-spec.js";
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
field,
|
|
28
|
+
values = $bindable({}),
|
|
29
|
+
// Open the editor on mount — set when the pill was just added from the
|
|
30
|
+
// "+ Add filter" menu, so the user lands straight in the value picker.
|
|
31
|
+
autoOpen = false,
|
|
32
|
+
onClose,
|
|
33
|
+
}: {
|
|
34
|
+
field: FilterField;
|
|
35
|
+
values?: FilterValues;
|
|
36
|
+
autoOpen?: boolean;
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
} = $props();
|
|
39
|
+
|
|
40
|
+
// Capture the initial autoOpen once; later prop changes must not reopen it.
|
|
41
|
+
let open = $state(untrack(() => autoOpen));
|
|
42
|
+
const active = $derived(isFieldActive(field, values));
|
|
43
|
+
const label = $derived(fieldLabel(field));
|
|
44
|
+
|
|
45
|
+
// The active-state summary shown after the label (`Label: <this>`).
|
|
46
|
+
const summary = $derived.by(() => {
|
|
47
|
+
if (field.type === "select") {
|
|
48
|
+
const v = values[field.key];
|
|
49
|
+
return field.options.find((o) => String(o.value) === String(v))?.label ?? String(v);
|
|
50
|
+
}
|
|
51
|
+
if (field.type === "daterange") {
|
|
52
|
+
const from = values[`${field.key}From`];
|
|
53
|
+
const to = values[`${field.key}To`];
|
|
54
|
+
return `${from || "…"} → ${to || "…"}`;
|
|
55
|
+
}
|
|
56
|
+
return values[field.key] ?? "";
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function clear() {
|
|
60
|
+
for (const k of fieldKeys(field)) values[k] = "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Tell the parent when the editor closes so an abandoned "just added" pill
|
|
64
|
+
// (opened but left blank) can be dropped from the visible set.
|
|
65
|
+
let wasOpen = false;
|
|
66
|
+
$effect(() => {
|
|
67
|
+
if (wasOpen && !open) onClose?.();
|
|
68
|
+
wasOpen = open;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Date-range quick presets. Browser runtime, so reading the clock is fine.
|
|
72
|
+
const DAY_MS = 86_400_000;
|
|
73
|
+
const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
74
|
+
function setRange(days: number) {
|
|
75
|
+
if (field.type !== "daterange") return;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
values[`${field.key}From`] = isoDay(now - days * DAY_MS);
|
|
78
|
+
values[`${field.key}To`] = isoDay(now);
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<!--
|
|
83
|
+
Split pill: the body opens the editor (Popover trigger); the trailing ✕ is a
|
|
84
|
+
separate sibling button that clears the field. Keeping ✕ outside the Popover
|
|
85
|
+
trigger avoids nesting one interactive control inside another.
|
|
86
|
+
-->
|
|
87
|
+
<div
|
|
88
|
+
class="inline-flex items-center rounded-[3px] border text-sm {active
|
|
89
|
+
? 'border-primary-soft-border bg-primary-soft text-primary-soft-text'
|
|
90
|
+
: 'border-outline bg-surface-raised text-on-surface-muted hover:border-outline-hover hover:text-on-surface'}"
|
|
91
|
+
>
|
|
92
|
+
<Popover bind:open placement="bottom-start" mobileSheet>
|
|
93
|
+
{#snippet trigger()}
|
|
94
|
+
<span class="inline-flex cursor-pointer items-center gap-1.5 px-3 py-2">
|
|
95
|
+
<span class="truncate">
|
|
96
|
+
{label}{#if active}<span class="font-medium">: {summary}</span>{/if}
|
|
97
|
+
</span>
|
|
98
|
+
{#if !active}<ChevronDown size={14} class="opacity-60" />{/if}
|
|
99
|
+
</span>
|
|
100
|
+
{/snippet}
|
|
101
|
+
|
|
102
|
+
{#if field.type === "select"}
|
|
103
|
+
<div class="flex w-52 flex-col gap-0.5">
|
|
104
|
+
{#each field.options as opt (String(opt.value))}
|
|
105
|
+
{@const selected = String(values[field.key] ?? "") === String(opt.value)}
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="flex items-center justify-between gap-2 rounded-[3px] px-2.5 py-1.5 text-left text-sm hover:bg-surface-sunken {selected
|
|
109
|
+
? 'text-on-surface'
|
|
110
|
+
: 'text-on-surface-muted'}"
|
|
111
|
+
onclick={() => {
|
|
112
|
+
values[field.key] = String(opt.value);
|
|
113
|
+
open = false;
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<span class="truncate">{opt.label}</span>
|
|
117
|
+
{#if selected}<Check size={15} class="shrink-0 text-primary" />{/if}
|
|
118
|
+
</button>
|
|
119
|
+
{/each}
|
|
120
|
+
</div>
|
|
121
|
+
{:else if field.type === "number"}
|
|
122
|
+
<div class="w-44">
|
|
123
|
+
<Input
|
|
124
|
+
type="number"
|
|
125
|
+
value={values[field.key] ?? ""}
|
|
126
|
+
placeholder={field.placeholder ?? field.label}
|
|
127
|
+
autofocus
|
|
128
|
+
onchange={(e) => (values[field.key] = e.currentTarget.value)}
|
|
129
|
+
onkeydown={(e) => {
|
|
130
|
+
if (e.key === "Enter") open = false;
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
{:else if field.type === "daterange"}
|
|
135
|
+
<div class="flex w-64 flex-col gap-3">
|
|
136
|
+
<div class="flex items-center gap-2">
|
|
137
|
+
<label class="flex-1">
|
|
138
|
+
<span class="mb-1 block text-xs text-on-surface-faint">From</span>
|
|
139
|
+
<Input type="date" bind:value={values[`${field.key}From`]} size="sm" />
|
|
140
|
+
</label>
|
|
141
|
+
<label class="flex-1">
|
|
142
|
+
<span class="mb-1 block text-xs text-on-surface-faint">To</span>
|
|
143
|
+
<Input type="date" bind:value={values[`${field.key}To`]} size="sm" />
|
|
144
|
+
</label>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="flex flex-wrap gap-1.5">
|
|
147
|
+
{#each [{ label: "Today", days: 0 }, { label: "7 days", days: 7 }, { label: "30 days", days: 30 }] as p (p.days)}
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
class="rounded-[3px] border border-outline bg-surface-raised px-2 py-1 text-xs text-on-surface-muted hover:border-outline-hover hover:text-on-surface"
|
|
151
|
+
onclick={() => setRange(p.days)}
|
|
152
|
+
>
|
|
153
|
+
{p.label}
|
|
154
|
+
</button>
|
|
155
|
+
{/each}
|
|
156
|
+
{#if !isBlank(values[`${field.key}From`]) || !isBlank(values[`${field.key}To`])}
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
class="ml-auto rounded-[3px] px-2 py-1 text-xs text-on-surface-muted hover:text-on-surface"
|
|
160
|
+
onclick={() => clear()}
|
|
161
|
+
>
|
|
162
|
+
Clear
|
|
163
|
+
</button>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
{/if}
|
|
168
|
+
</Popover>
|
|
169
|
+
|
|
170
|
+
{#if active}
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
class="self-stretch rounded-r-[3px] border-l border-primary-soft-border px-2 hover:bg-primary/15"
|
|
174
|
+
onclick={clear}
|
|
175
|
+
aria-label="Clear {label} filter"
|
|
176
|
+
>
|
|
177
|
+
<X size={13} />
|
|
178
|
+
</button>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<!--
|
|
5
|
+
Header bell + unread badge + popover shell. The app owns the store and the
|
|
6
|
+
list items (they're domain-specific); it passes them in as `children`.
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import type { Snippet } from "svelte";
|
|
11
|
+
import { Bell } from "@lucide/svelte";
|
|
12
|
+
import Popover from "./Popover.svelte";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
unread: number;
|
|
16
|
+
/** Badge colour: "danger" when an unread item is an error. */
|
|
17
|
+
tone?: "primary" | "danger";
|
|
18
|
+
open?: boolean;
|
|
19
|
+
/** Panel title; the right side of the header takes `actions`. */
|
|
20
|
+
title?: string;
|
|
21
|
+
actions?: Snippet;
|
|
22
|
+
children: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
unread,
|
|
28
|
+
tone = "primary",
|
|
29
|
+
open = $bindable(false),
|
|
30
|
+
title = "Notifications",
|
|
31
|
+
actions,
|
|
32
|
+
children,
|
|
33
|
+
class: className = "",
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
let badge = $derived(unread > 99 ? "99+" : String(unread));
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<Popover bind:open placement="bottom-end" mobileSheet class="flex {className}">
|
|
40
|
+
{#snippet trigger()}
|
|
41
|
+
<span
|
|
42
|
+
class="relative flex h-9 w-9 items-center justify-center rounded-[3px] border border-transparent text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary"
|
|
43
|
+
title="Notifications"
|
|
44
|
+
>
|
|
45
|
+
<Bell size={18} aria-hidden="true" />
|
|
46
|
+
<span class="sr-only">{title}</span>
|
|
47
|
+
{#if unread > 0}
|
|
48
|
+
<span
|
|
49
|
+
class="absolute -top-0.5 -right-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full px-1 font-mono text-2xs font-bold tabular-nums {tone ===
|
|
50
|
+
'danger'
|
|
51
|
+
? 'bg-danger-surface text-on-danger'
|
|
52
|
+
: 'bg-primary text-on-primary'}"
|
|
53
|
+
aria-label={`${badge} unread`}
|
|
54
|
+
>
|
|
55
|
+
{badge}
|
|
56
|
+
</span>
|
|
57
|
+
{/if}
|
|
58
|
+
</span>
|
|
59
|
+
{/snippet}
|
|
60
|
+
|
|
61
|
+
<div role="dialog" aria-label={title} class="w-96 max-w-[90vw]">
|
|
62
|
+
<div class="mb-2 flex items-center justify-between gap-2">
|
|
63
|
+
<h2 class="font-mono text-sm font-semibold text-on-surface">{title}</h2>
|
|
64
|
+
{#if actions}
|
|
65
|
+
<div class="flex items-center gap-3 text-xs">{@render actions()}</div>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
{@render children()}
|
|
69
|
+
</div>
|
|
70
|
+
</Popover>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<!--
|
|
5
|
+
Debounced search box for FilterBar. `value` is the committed term the parent
|
|
6
|
+
filters on; keystrokes are debounced so client re-filtering / server refetch
|
|
7
|
+
fires once the user pauses, not per character. `Esc` and the trailing ✕ clear
|
|
8
|
+
immediately. Exposes its raw <input> via `element` so FilterBar's `/` shortcut
|
|
9
|
+
can focus it.
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import Input from "./Input.svelte";
|
|
14
|
+
import { Search, X } from "@lucide/svelte";
|
|
15
|
+
import { untrack, onDestroy } from "svelte";
|
|
16
|
+
import { debounce } from "../utils/debounce.js";
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
value = $bindable(""),
|
|
20
|
+
placeholder = "Search...",
|
|
21
|
+
element = $bindable<HTMLInputElement | null>(null),
|
|
22
|
+
}: {
|
|
23
|
+
value?: string | undefined;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
element?: HTMLInputElement | null;
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
// `raw` is the live text; `value` is the debounced, committed term. Seeded
|
|
29
|
+
// once from `value` so a URL-restored search survives mount undebounced.
|
|
30
|
+
let raw = $state(value);
|
|
31
|
+
let wrapperEl = $state<HTMLDivElement | null>(null);
|
|
32
|
+
|
|
33
|
+
const commit = debounce((v: string) => (value = v), 250);
|
|
34
|
+
onDestroy(() => commit.cancel());
|
|
35
|
+
|
|
36
|
+
// Expose the real input for FilterBar's `/`-to-focus shortcut.
|
|
37
|
+
$effect(() => {
|
|
38
|
+
element = wrapperEl?.querySelector("input") ?? null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// External writes to `value` (Reset / clear-all) win immediately: adopt them
|
|
42
|
+
// and drop any pending debounce. Depends only on `value` (raw is read
|
|
43
|
+
// untracked), so typing never re-enters here. Tiny race: a reset that leaves
|
|
44
|
+
// `value` unchanged ("" -> "") can't cancel a first-keystroke debounce —
|
|
45
|
+
// negligible at 250ms. ponytail: guarded two-way sync, fine for one field.
|
|
46
|
+
$effect(() => {
|
|
47
|
+
const v = value;
|
|
48
|
+
untrack(() => {
|
|
49
|
+
if (v !== raw) {
|
|
50
|
+
commit.cancel();
|
|
51
|
+
raw = v;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function clear() {
|
|
57
|
+
commit.cancel();
|
|
58
|
+
raw = "";
|
|
59
|
+
value = "";
|
|
60
|
+
wrapperEl?.querySelector("input")?.focus();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onKeydown(e: KeyboardEvent) {
|
|
64
|
+
if (e.key === "Escape" && raw) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
clear();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div bind:this={wrapperEl} class="relative w-full sm:w-64">
|
|
72
|
+
<Input
|
|
73
|
+
bind:value={raw}
|
|
74
|
+
{placeholder}
|
|
75
|
+
oninput={() => commit(raw)}
|
|
76
|
+
onkeydown={onKeydown}
|
|
77
|
+
style={raw ? "padding-right:2.25rem" : undefined}
|
|
78
|
+
>
|
|
79
|
+
{#snippet leadingIcon()}<Search size={16} />{/snippet}
|
|
80
|
+
</Input>
|
|
81
|
+
{#if raw}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-[3px] p-1 text-on-surface-faint hover:bg-surface-sunken hover:text-on-surface"
|
|
85
|
+
onclick={clear}
|
|
86
|
+
aria-label="Clear search"
|
|
87
|
+
>
|
|
88
|
+
<X size={14} />
|
|
89
|
+
</button>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts" module>
|
|
5
|
+
export type StatusDotTone = "primary" | "success" | "warning" | "danger" | "muted";
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
interface Props {
|
|
10
|
+
tone?: StatusDotTone;
|
|
11
|
+
/** Radar ping around the dot — use for "live" states only. */
|
|
12
|
+
pulse?: boolean;
|
|
13
|
+
size?: "sm" | "md" | "lg";
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { tone = "primary", pulse = false, size = "sm", class: className = "" }: Props = $props();
|
|
18
|
+
|
|
19
|
+
const TONES: Record<StatusDotTone, string> = {
|
|
20
|
+
primary: "bg-primary",
|
|
21
|
+
success: "bg-success-surface",
|
|
22
|
+
warning: "bg-warning-surface",
|
|
23
|
+
danger: "bg-danger-surface",
|
|
24
|
+
muted: "bg-on-surface-faint",
|
|
25
|
+
};
|
|
26
|
+
const SIZES = { sm: "h-2 w-2", md: "h-2.5 w-2.5", lg: "h-3.5 w-3.5" };
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<span class="relative flex shrink-0 {SIZES[size]} {className}" aria-hidden="true">
|
|
30
|
+
{#if pulse}
|
|
31
|
+
<span
|
|
32
|
+
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 motion-reduce:animate-none {TONES[
|
|
33
|
+
tone
|
|
34
|
+
]}"
|
|
35
|
+
></span>
|
|
36
|
+
{/if}
|
|
37
|
+
<span class="relative inline-flex h-full w-full rounded-full {TONES[tone]}"></span>
|
|
38
|
+
</span>
|
|
@@ -2,46 +2,41 @@
|
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
|
-
import { Moon, Sun } from "@lucide/svelte";
|
|
5
|
+
import { ChevronDown, Monitor, Moon, Sun } from "@lucide/svelte";
|
|
6
|
+
import Dropdown from "./Dropdown.svelte";
|
|
7
|
+
import { themeStore, type ThemePreference } from "../utils/theme.svelte.js";
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
|
-
storageKey?: string;
|
|
9
10
|
class?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
let {
|
|
13
|
+
let { class: className = "" }: Props = $props();
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const options: { value: ThemePreference; label: string; icon: typeof Monitor }[] = [
|
|
16
|
+
{ value: "auto", label: "Auto", icon: Monitor },
|
|
17
|
+
{ value: "light", label: "Light", icon: Sun },
|
|
18
|
+
{ value: "dark", label: "Dark", icon: Moon },
|
|
19
|
+
];
|
|
16
20
|
|
|
17
|
-
$
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
+
let TriggerIcon = $derived(
|
|
22
|
+
themeStore.preference === "auto" ? Monitor : themeStore.resolved === "dark" ? Moon : Sun,
|
|
23
|
+
);
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
}
|
|
25
|
+
let items = $derived(
|
|
26
|
+
options.map((o) => ({
|
|
27
|
+
label: o.label,
|
|
28
|
+
icon: o.icon,
|
|
29
|
+
selected: themeStore.preference === o.value,
|
|
30
|
+
onClick: () => themeStore.set(o.value),
|
|
31
|
+
})),
|
|
32
|
+
);
|
|
32
33
|
</script>
|
|
33
34
|
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
>
|
|
42
|
-
{#if mounted && isDark}
|
|
43
|
-
<Sun size={16} />
|
|
44
|
-
{:else}
|
|
45
|
-
<Moon size={16} />
|
|
46
|
-
{/if}
|
|
47
|
-
</button>
|
|
35
|
+
<Dropdown {items} align="right" triggerLabel="Theme" class={className}>
|
|
36
|
+
{#snippet trigger()}
|
|
37
|
+
<span class="flex items-center gap-1">
|
|
38
|
+
<TriggerIcon size={18} />
|
|
39
|
+
<ChevronDown size={14} class="opacity-60" />
|
|
40
|
+
</span>
|
|
41
|
+
{/snippet}
|
|
42
|
+
</Dropdown>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Declarative filter spec. A `FilterField[]` + one bound `FilterValues` object
|
|
5
|
+
// drives <FilterBar>, the client engine (`applyFilters`), and the URL sync
|
|
6
|
+
// helper — the same way DataGrid's `Column[]` drives the table. Add a field
|
|
7
|
+
// type here, and every table that adopts it renders identically.
|
|
8
|
+
|
|
9
|
+
import type { SelectOption } from "./Select.svelte";
|
|
10
|
+
|
|
11
|
+
// `path` (where present) is the dot-path read on the row for client-side
|
|
12
|
+
// matching; it defaults to `key`. `key` is also the slot in `FilterValues` and
|
|
13
|
+
// the URL query param. Search fields test `fields` (dot-paths) instead.
|
|
14
|
+
export type FilterField =
|
|
15
|
+
| {
|
|
16
|
+
type: "search";
|
|
17
|
+
key: string;
|
|
18
|
+
fields: string[];
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
primary?: boolean;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "select";
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
options: SelectOption[];
|
|
27
|
+
path?: string;
|
|
28
|
+
primary?: boolean;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
type: "number";
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
path?: string;
|
|
36
|
+
primary?: boolean;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
// Stored as two values: `${key}From` / `${key}To` (yyyy-mm-dd).
|
|
40
|
+
type: "daterange";
|
|
41
|
+
key: string;
|
|
42
|
+
label: string;
|
|
43
|
+
path?: string;
|
|
44
|
+
primary?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// All values are strings — DOM inputs are string-valued and URL params are
|
|
48
|
+
// strings; the client engine coerces (e.g. Number()) where a field needs it.
|
|
49
|
+
export type FilterValues = Record<string, string>;
|
|
50
|
+
|
|
51
|
+
// The full state a list emits via onQueryChange: FilterBar values + sort +
|
|
52
|
+
// pagination. The host mirrors this to the URL; server-mode lists also
|
|
53
|
+
// map it to REST query params.
|
|
54
|
+
export interface TableQuery {
|
|
55
|
+
values: FilterValues;
|
|
56
|
+
sortKey?: string;
|
|
57
|
+
sortDir?: "asc" | "desc";
|
|
58
|
+
page: number;
|
|
59
|
+
pageSize: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A select value is "inactive" (matches everything) when blank, undefined, or
|
|
63
|
+
// the conventional "all" sentinel some existing option lists use.
|
|
64
|
+
export function isBlank(v: string | undefined): boolean {
|
|
65
|
+
return !v || v === "all";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Which value keys a field owns — daterange owns two, everything else one.
|
|
69
|
+
// Used by chips, active-state detection, clear, and URL (de)serialization so
|
|
70
|
+
// none of them has to special-case the range type.
|
|
71
|
+
export function fieldKeys(field: FilterField): string[] {
|
|
72
|
+
return field.type === "daterange" ? [`${field.key}From`, `${field.key}To`] : [field.key];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isFieldActive(field: FilterField, values: FilterValues): boolean {
|
|
76
|
+
return fieldKeys(field).some((k) => !isBlank(values[k]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Human label for a field. Every type but `search` carries one; search has no
|
|
80
|
+
// label (its box speaks for itself), so fall back to the key for the narrower.
|
|
81
|
+
export function fieldLabel(field: FilterField): string {
|
|
82
|
+
return "label" in field ? field.label : field.key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Seed a values object with every field's keys present (blank), overlaid with
|
|
86
|
+
// any initial values (e.g. from the URL). Keeping all keys present up front
|
|
87
|
+
// keeps the emitted query reactive to each field and the bindings simple.
|
|
88
|
+
export function seedValues(fields: FilterField[], initial: FilterValues = {}): FilterValues {
|
|
89
|
+
const values: FilterValues = {};
|
|
90
|
+
for (const f of fields) {
|
|
91
|
+
for (const k of fieldKeys(f)) values[k] = initial[k] ?? "";
|
|
92
|
+
}
|
|
93
|
+
return values;
|
|
94
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { default as FormField } from "./components/FormField.svelte";
|
|
|
33
33
|
export { default as Input } from "./components/Input.svelte";
|
|
34
34
|
export { default as Radio } from "./components/Radio.svelte";
|
|
35
35
|
export { default as RadioGroup } from "./components/RadioGroup.svelte";
|
|
36
|
-
export { default as Select } from "./components/Select.svelte";
|
|
36
|
+
export { default as Select, type SelectOption } from "./components/Select.svelte";
|
|
37
37
|
export { default as Textarea } from "./components/Textarea.svelte";
|
|
38
38
|
export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
|
|
39
39
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
@@ -47,9 +47,25 @@ export { default as Avatar } from "./components/Avatar.svelte";
|
|
|
47
47
|
export { default as Badge } from "./components/Badge.svelte";
|
|
48
48
|
export { default as Card } from "./components/Card.svelte";
|
|
49
49
|
export { default as DataGrid } from "./components/DataGrid.svelte";
|
|
50
|
+
export type { Column, DataGridProps } from "./components/DataGrid.svelte";
|
|
51
|
+
export { default as FilterBar } from "./components/FilterBar.svelte";
|
|
52
|
+
export { default as FilterPill } from "./components/FilterPill.svelte";
|
|
53
|
+
export { default as SearchInput } from "./components/SearchInput.svelte";
|
|
54
|
+
export {
|
|
55
|
+
fieldKeys,
|
|
56
|
+
fieldLabel,
|
|
57
|
+
isBlank,
|
|
58
|
+
isFieldActive,
|
|
59
|
+
seedValues,
|
|
60
|
+
type FilterField,
|
|
61
|
+
type FilterValues,
|
|
62
|
+
type TableQuery,
|
|
63
|
+
} from "./components/filter-spec.js";
|
|
64
|
+
export { applyFilters, matchesQuery, resolvePath } from "./utils/filter.js";
|
|
50
65
|
export { default as Progress } from "./components/Progress.svelte";
|
|
51
66
|
export { default as Skeleton } from "./components/Skeleton.svelte";
|
|
52
67
|
export { default as Sparkline } from "./components/Sparkline.svelte";
|
|
68
|
+
export { default as StatusDot, type StatusDotTone } from "./components/StatusDot.svelte";
|
|
53
69
|
export { default as StatusIndicator } from "./components/StatusIndicator.svelte";
|
|
54
70
|
export { default as Table } from "./components/Table.svelte";
|
|
55
71
|
export { default as TableBody } from "./components/TableBody.svelte";
|
|
@@ -64,9 +80,11 @@ export { default as Dropdown } from "./components/Dropdown.svelte";
|
|
|
64
80
|
export { default as EmptyState } from "./components/EmptyState.svelte";
|
|
65
81
|
export { default as ErrorState } from "./components/ErrorState.svelte";
|
|
66
82
|
export { default as Modal } from "./components/Modal.svelte";
|
|
83
|
+
export { default as NotificationBell } from "./components/NotificationBell.svelte";
|
|
67
84
|
export { default as Popover } from "./components/Popover.svelte";
|
|
68
85
|
export { default as ToastContainer } from "./components/ToastContainer.svelte";
|
|
69
86
|
export { default as Tooltip } from "./components/Tooltip.svelte";
|
|
87
|
+
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
70
88
|
|
|
71
89
|
export { default as CodeBlock } from "./components/CodeBlock.svelte";
|
|
72
90
|
export { default as TaskCard } from "./components/TaskCard.svelte";
|
|
@@ -118,6 +136,12 @@ export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-fo
|
|
|
118
136
|
export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
|
|
119
137
|
|
|
120
138
|
export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
|
|
139
|
+
export {
|
|
140
|
+
themeStore,
|
|
141
|
+
THEME_STORAGE_KEY,
|
|
142
|
+
type ThemePreference,
|
|
143
|
+
type ResolvedTheme,
|
|
144
|
+
} from "./utils/theme.svelte.js";
|
|
121
145
|
export { extractErrorMessage } from "./utils/error.js";
|
|
122
146
|
export {
|
|
123
147
|
formatBytes,
|
|
@@ -128,4 +152,5 @@ export {
|
|
|
128
152
|
formatFullDateTime,
|
|
129
153
|
} from "./utils/format.js";
|
|
130
154
|
export { formatShortId } from "./utils/id.js";
|
|
155
|
+
export { debounce } from "./utils/debounce.js";
|
|
131
156
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { debounce } from "./debounce.js";
|
|
6
|
+
|
|
7
|
+
describe("debounce", () => {
|
|
8
|
+
beforeEach(() => vi.useFakeTimers());
|
|
9
|
+
afterEach(() => vi.useRealTimers());
|
|
10
|
+
|
|
11
|
+
it("collapses rapid calls into one trailing call with the last args", () => {
|
|
12
|
+
const fn = vi.fn();
|
|
13
|
+
const d = debounce(fn, 20);
|
|
14
|
+
d(1);
|
|
15
|
+
d(2);
|
|
16
|
+
d(3);
|
|
17
|
+
expect(fn).not.toHaveBeenCalled();
|
|
18
|
+
vi.advanceTimersByTime(20);
|
|
19
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
20
|
+
expect(fn).toHaveBeenCalledWith(3);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("cancel drops the pending call", () => {
|
|
24
|
+
const fn = vi.fn();
|
|
25
|
+
const d = debounce(fn, 20);
|
|
26
|
+
d();
|
|
27
|
+
d.cancel();
|
|
28
|
+
vi.advanceTimersByTime(40);
|
|
29
|
+
expect(fn).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Trailing-edge debounce. Returns a wrapper that defers `fn` until `ms` have
|
|
5
|
+
// elapsed since the last call; `.cancel()` drops a pending call (used when an
|
|
6
|
+
// external write must win immediately, e.g. Reset clearing a debounced field).
|
|
7
|
+
export function debounce<A extends unknown[]>(
|
|
8
|
+
fn: (...args: A) => void,
|
|
9
|
+
ms: number,
|
|
10
|
+
): ((...args: A) => void) & { cancel: () => void } {
|
|
11
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
12
|
+
const wrapped = (...args: A) => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
timer = setTimeout(() => {
|
|
15
|
+
fn(...args);
|
|
16
|
+
}, ms);
|
|
17
|
+
};
|
|
18
|
+
wrapped.cancel = () => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
};
|
|
21
|
+
return wrapped;
|
|
22
|
+
}
|