@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.
@@ -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 { storageKey = "runwisp:theme", class: className = "" }: Props = $props();
13
+ let { class: className = "" }: Props = $props();
13
14
 
14
- let isDark = $state(false);
15
- let mounted = $state(false);
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
- $effect(() => {
18
- isDark = document.documentElement.classList.contains("dark");
19
- mounted = true;
20
- });
21
+ let TriggerIcon = $derived(
22
+ themeStore.preference === "auto" ? Monitor : themeStore.resolved === "dark" ? Moon : Sun,
23
+ );
21
24
 
22
- function toggle(): void {
23
- const next = !isDark;
24
- isDark = next;
25
- document.documentElement.classList.toggle("dark", next);
26
- try {
27
- localStorage.setItem(storageKey, next ? "dark" : "light");
28
- } catch {
29
- // localStorage may be unavailable (private mode, embedded contexts) — fall through.
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
- <button
35
- type="button"
36
- onclick={toggle}
37
- aria-pressed={isDark}
38
- aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
39
- title={isDark ? "Switch to light theme" : "Switch to dark theme"}
40
- class="inline-flex h-9 w-9 items-center justify-center rounded-[3px] border border-outline bg-surface-raised text-on-surface-muted hover:border-outline-hover hover:text-on-surface {className}"
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
+ }