@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,172 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { applyFilters, matchesQuery, resolvePath } from "./filter.js";
|
|
6
|
+
import {
|
|
7
|
+
type FilterField,
|
|
8
|
+
fieldKeys,
|
|
9
|
+
fieldLabel,
|
|
10
|
+
isBlank,
|
|
11
|
+
isFieldActive,
|
|
12
|
+
seedValues,
|
|
13
|
+
} from "../components/filter-spec.js";
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
id: string;
|
|
17
|
+
status: string;
|
|
18
|
+
exitCode: number;
|
|
19
|
+
roles: string[];
|
|
20
|
+
user: { name: string };
|
|
21
|
+
startedAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rows: Row[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "a",
|
|
27
|
+
status: "ok",
|
|
28
|
+
exitCode: 0,
|
|
29
|
+
roles: ["owner"],
|
|
30
|
+
user: { name: "Ada" },
|
|
31
|
+
startedAt: new Date("2026-01-10"),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "b",
|
|
35
|
+
status: "err",
|
|
36
|
+
exitCode: 1,
|
|
37
|
+
roles: ["viewer"],
|
|
38
|
+
user: { name: "Bob" },
|
|
39
|
+
startedAt: new Date("2026-02-20"),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "c",
|
|
43
|
+
status: "ok",
|
|
44
|
+
exitCode: 2,
|
|
45
|
+
roles: ["owner", "viewer"],
|
|
46
|
+
user: { name: "Cy" },
|
|
47
|
+
startedAt: new Date("2026-03-30"),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const range: FilterField = { type: "daterange", key: "startedAt", label: "Date" };
|
|
52
|
+
const search: FilterField = { type: "search", key: "q", fields: ["id", "user.name"] };
|
|
53
|
+
const status: FilterField = { type: "select", key: "status", label: "Status", options: [] };
|
|
54
|
+
|
|
55
|
+
const fields: FilterField[] = [
|
|
56
|
+
search,
|
|
57
|
+
status,
|
|
58
|
+
{ type: "select", key: "role", label: "Role", path: "roles", options: [] },
|
|
59
|
+
{ type: "number", key: "exitCode", label: "Exit" },
|
|
60
|
+
range,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const ids = (r: Row[]) => r.map((x) => x.id);
|
|
64
|
+
|
|
65
|
+
describe("applyFilters", () => {
|
|
66
|
+
it("passes everything through when values are empty", () => {
|
|
67
|
+
expect(applyFilters(rows, fields, {})).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("searches dot-paths case-insensitively", () => {
|
|
71
|
+
expect(ids(applyFilters(rows, fields, { q: "ad" }))).toEqual(["a"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("matches select by equality and treats 'all' as inactive", () => {
|
|
75
|
+
expect(ids(applyFilters(rows, fields, { status: "ok" }))).toEqual(["a", "c"]);
|
|
76
|
+
expect(applyFilters(rows, fields, { status: "all" })).toHaveLength(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("matches select against an array cell by membership", () => {
|
|
80
|
+
expect(ids(applyFilters(rows, fields, { role: "viewer" }))).toEqual(["b", "c"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("coerces number values", () => {
|
|
84
|
+
expect(ids(applyFilters(rows, fields, { exitCode: "1" }))).toEqual(["b"]);
|
|
85
|
+
expect(ids(applyFilters(rows, fields, { exitCode: "0" }))).toEqual(["a"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("filters a date range inclusively on both ends", () => {
|
|
89
|
+
expect(
|
|
90
|
+
ids(
|
|
91
|
+
applyFilters(rows, fields, {
|
|
92
|
+
startedAtFrom: "2026-02-01",
|
|
93
|
+
startedAtTo: "2026-03-30",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
).toEqual(["b", "c"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("ANDs combined filters", () => {
|
|
100
|
+
expect(ids(applyFilters(rows, fields, { status: "ok", role: "viewer" }))).toEqual(["c"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("coerces non-string select cells (number, boolean, date) and skips the rest", () => {
|
|
104
|
+
interface Flagged {
|
|
105
|
+
id: string;
|
|
106
|
+
flags: unknown[];
|
|
107
|
+
}
|
|
108
|
+
const flaggedRows: Flagged[] = [
|
|
109
|
+
{ id: "a", flags: [1, true, new Date("2026-01-01T00:00:00.000Z"), {}] },
|
|
110
|
+
{ id: "b", flags: [0, false] },
|
|
111
|
+
];
|
|
112
|
+
const flagField: FilterField = {
|
|
113
|
+
type: "select",
|
|
114
|
+
key: "flag",
|
|
115
|
+
label: "Flag",
|
|
116
|
+
path: "flags",
|
|
117
|
+
options: [],
|
|
118
|
+
};
|
|
119
|
+
const flagIds = (r: Flagged[]) => r.map((x) => x.id);
|
|
120
|
+
expect(flagIds(applyFilters(flaggedRows, [flagField], { flag: "1" }))).toEqual(["a"]);
|
|
121
|
+
expect(flagIds(applyFilters(flaggedRows, [flagField], { flag: "true" }))).toEqual(["a"]);
|
|
122
|
+
expect(
|
|
123
|
+
flagIds(
|
|
124
|
+
applyFilters(flaggedRows, [flagField], {
|
|
125
|
+
flag: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
).toEqual(["a"]);
|
|
129
|
+
expect(applyFilters(flaggedRows, [flagField], { flag: "no-such-value" })).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("filter helpers", () => {
|
|
134
|
+
it("matchesQuery skips nullish fields and matches empty queries", () => {
|
|
135
|
+
expect(matchesQuery("", null)).toBe(true);
|
|
136
|
+
expect(matchesQuery("x", null, undefined)).toBe(false);
|
|
137
|
+
expect(matchesQuery("OB", "bob")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("resolvePath reads nested fields and misses gracefully", () => {
|
|
141
|
+
expect(resolvePath(rows[0], "user.name")).toBe("Ada");
|
|
142
|
+
expect(resolvePath(rows[0], "user.missing.deep")).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("isBlank treats blank, undefined, and 'all' as inactive", () => {
|
|
146
|
+
expect(isBlank(undefined)).toBe(true);
|
|
147
|
+
expect(isBlank("")).toBe(true);
|
|
148
|
+
expect(isBlank("all")).toBe(true);
|
|
149
|
+
expect(isBlank("0")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("fieldLabel uses the label, falling back to the key for search fields", () => {
|
|
153
|
+
expect(fieldLabel(status)).toBe("Status");
|
|
154
|
+
expect(fieldLabel(search)).toBe("q");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("daterange owns two value keys", () => {
|
|
158
|
+
expect(fieldKeys(range)).toEqual(["startedAtFrom", "startedAtTo"]);
|
|
159
|
+
expect(isFieldActive(range, { startedAtTo: "2026-01-01" })).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("seedValues fills every key and overlays initial values", () => {
|
|
163
|
+
expect(seedValues(fields, { status: "ok", extra: "dropped" })).toEqual({
|
|
164
|
+
q: "",
|
|
165
|
+
status: "ok",
|
|
166
|
+
role: "",
|
|
167
|
+
exitCode: "",
|
|
168
|
+
startedAtFrom: "",
|
|
169
|
+
startedAtTo: "",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { type FilterField, type FilterValues, isBlank } from "../components/filter-spec.js";
|
|
5
|
+
|
|
6
|
+
// Case-insensitive substring match against any of the given fields. Empty
|
|
7
|
+
// query matches everything; nullish fields are skipped.
|
|
8
|
+
export function matchesQuery(query: string, ...fields: (string | null | undefined)[]): boolean {
|
|
9
|
+
if (!query) return true;
|
|
10
|
+
const q = query.toLowerCase();
|
|
11
|
+
return fields.some((f) => f?.toLowerCase().includes(q) === true);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Dot-path aware field read: resolves "user.name" against nested objects.
|
|
15
|
+
// Shared by DataGrid's sort comparator and the filter engine below so both
|
|
16
|
+
// read fields the same way.
|
|
17
|
+
export function resolvePath(obj: unknown, path: string): unknown {
|
|
18
|
+
return path
|
|
19
|
+
.split(".")
|
|
20
|
+
.reduce<unknown>(
|
|
21
|
+
(acc, k): unknown =>
|
|
22
|
+
typeof acc === "object" && acc !== null ? Reflect.get(acc, k) : undefined,
|
|
23
|
+
obj,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scalar cell → the string a filter value is compared against. Objects and
|
|
28
|
+
// nullish cells have no text form, so they never match a search or select.
|
|
29
|
+
function toText(v: unknown): string | null {
|
|
30
|
+
if (typeof v === "string") return v;
|
|
31
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
32
|
+
if (v instanceof Date) return v.toISOString();
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Array cell (e.g. member roles) → membership; scalar → equality.
|
|
37
|
+
function matchSelect(cell: unknown, v: string): boolean {
|
|
38
|
+
return Array.isArray(cell) ? cell.some((c) => toText(c) === v) : toText(cell) === v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inclusive yyyy-mm-dd range. The ISO date portion sorts lexically, so a
|
|
42
|
+
// string compare is enough.
|
|
43
|
+
function matchDateRange(cell: unknown, from: string | undefined, to: string | undefined): boolean {
|
|
44
|
+
if (isBlank(from) && isBlank(to)) return true;
|
|
45
|
+
const d = cell instanceof Date ? cell : new Date(toText(cell) ?? "");
|
|
46
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
47
|
+
const day = d.toISOString().slice(0, 10);
|
|
48
|
+
return (isBlank(from) || day >= (from ?? "")) && (isBlank(to) || day <= (to ?? ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchField(row: unknown, field: FilterField, values: FilterValues): boolean {
|
|
52
|
+
switch (field.type) {
|
|
53
|
+
case "search":
|
|
54
|
+
return matchesQuery(
|
|
55
|
+
values[field.key] ?? "",
|
|
56
|
+
...field.fields.map((p) => toText(resolvePath(row, p))),
|
|
57
|
+
);
|
|
58
|
+
case "select": {
|
|
59
|
+
const v = values[field.key];
|
|
60
|
+
return isBlank(v) || matchSelect(resolvePath(row, field.path ?? field.key), v ?? "");
|
|
61
|
+
}
|
|
62
|
+
case "number": {
|
|
63
|
+
const v = values[field.key];
|
|
64
|
+
return isBlank(v) || Number(resolvePath(row, field.path ?? field.key)) === Number(v);
|
|
65
|
+
}
|
|
66
|
+
case "daterange":
|
|
67
|
+
return matchDateRange(
|
|
68
|
+
resolvePath(row, field.path ?? field.key),
|
|
69
|
+
values[`${field.key}From`],
|
|
70
|
+
values[`${field.key}To`],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Client-side filter engine: a row survives when every field matches.
|
|
76
|
+
// Server-mode tables map the same `values` to query params instead.
|
|
77
|
+
export function applyFilters<T>(rows: T[], fields: FilterField[], values: FilterValues): T[] {
|
|
78
|
+
return rows.filter((row) => fields.every((f) => matchField(row, f, values)));
|
|
79
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
|
|
6
|
+
interface BrowserStub {
|
|
7
|
+
media: { matches: boolean };
|
|
8
|
+
classList: Set<string>;
|
|
9
|
+
fireMediaChange: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stubBrowser(
|
|
13
|
+
options: {
|
|
14
|
+
matches?: boolean;
|
|
15
|
+
storage?: Record<string, string>;
|
|
16
|
+
throwOnStorage?: boolean;
|
|
17
|
+
} = {},
|
|
18
|
+
): BrowserStub {
|
|
19
|
+
const { matches = false, storage = {}, throwOnStorage = false } = options;
|
|
20
|
+
const listeners: (() => void)[] = [];
|
|
21
|
+
const media = { matches };
|
|
22
|
+
const classList = new Set<string>();
|
|
23
|
+
|
|
24
|
+
vi.stubGlobal("window", {});
|
|
25
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
26
|
+
get matches() {
|
|
27
|
+
return media.matches;
|
|
28
|
+
},
|
|
29
|
+
addEventListener: (_event: string, cb: () => void) => {
|
|
30
|
+
listeners.push(cb);
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.stubGlobal("localStorage", {
|
|
34
|
+
getItem: (key: string) => {
|
|
35
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
36
|
+
return storage[key] ?? null;
|
|
37
|
+
},
|
|
38
|
+
setItem: (key: string, value: string) => {
|
|
39
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
40
|
+
storage[key] = value;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
vi.stubGlobal("document", {
|
|
44
|
+
documentElement: {
|
|
45
|
+
classList: {
|
|
46
|
+
toggle: (cls: string, on: boolean) => {
|
|
47
|
+
if (on) classList.add(cls);
|
|
48
|
+
else classList.delete(cls);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
media,
|
|
56
|
+
classList,
|
|
57
|
+
fireMediaChange: () => {
|
|
58
|
+
listeners.forEach((cb) => {
|
|
59
|
+
cb();
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("themeStore", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.resetModules();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("degrades outside a browser", async () => {
|
|
74
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
75
|
+
expect(themeStore.preference).toBe("auto");
|
|
76
|
+
expect(themeStore.resolved).toBe("light");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resolves auto to the OS preference", async () => {
|
|
80
|
+
stubBrowser({ matches: true });
|
|
81
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
82
|
+
expect(themeStore.resolved).toBe("dark");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reads a stored preference over auto", async () => {
|
|
86
|
+
stubBrowser({ storage: { "runwisp:theme": "dark" } });
|
|
87
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
88
|
+
expect(themeStore.preference).toBe("dark");
|
|
89
|
+
expect(themeStore.resolved).toBe("dark");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to the legacy storage key", async () => {
|
|
93
|
+
stubBrowser({ storage: { "runwisp-theme": "light" } });
|
|
94
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
95
|
+
expect(themeStore.preference).toBe("light");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("ignores an invalid stored value", async () => {
|
|
99
|
+
stubBrowser({ storage: { "runwisp:theme": "purple" } });
|
|
100
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
101
|
+
expect(themeStore.preference).toBe("auto");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("re-resolves live when the OS preference changes while in auto", async () => {
|
|
105
|
+
const env = stubBrowser({ matches: false });
|
|
106
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
107
|
+
expect(themeStore.resolved).toBe("light");
|
|
108
|
+
env.media.matches = true;
|
|
109
|
+
env.fireMediaChange();
|
|
110
|
+
expect(themeStore.resolved).toBe("dark");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ignores OS changes once a manual preference is set", async () => {
|
|
114
|
+
const env = stubBrowser({ matches: false });
|
|
115
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
116
|
+
themeStore.set("light");
|
|
117
|
+
env.media.matches = true;
|
|
118
|
+
env.fireMediaChange();
|
|
119
|
+
expect(themeStore.resolved).toBe("light");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("persists an explicit preference and toggles the dark class", async () => {
|
|
123
|
+
const env = stubBrowser();
|
|
124
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
125
|
+
themeStore.set("dark");
|
|
126
|
+
expect(themeStore.resolved).toBe("dark");
|
|
127
|
+
expect(env.classList.has("dark")).toBe(true);
|
|
128
|
+
expect(localStorage.getItem("runwisp:theme")).toBe("dark");
|
|
129
|
+
|
|
130
|
+
themeStore.set("light");
|
|
131
|
+
expect(env.classList.has("dark")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("still applies the preference for this page when storage write throws", async () => {
|
|
135
|
+
stubBrowser({ throwOnStorage: true });
|
|
136
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
137
|
+
expect(() => {
|
|
138
|
+
themeStore.set("dark");
|
|
139
|
+
}).not.toThrow();
|
|
140
|
+
expect(themeStore.resolved).toBe("dark");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export type ThemePreference = "auto" | "light" | "dark";
|
|
5
|
+
export type ResolvedTheme = "light" | "dark";
|
|
6
|
+
|
|
7
|
+
// Shared by every RunWisp surface. Keep byte-for-byte in sync with the
|
|
8
|
+
// no-flash <head> scripts in each app's app.html / layout.
|
|
9
|
+
export const THEME_STORAGE_KEY = "runwisp:theme";
|
|
10
|
+
// The daemon dashboard used this key before the store moved here.
|
|
11
|
+
const LEGACY_STORAGE_KEY = "runwisp-theme";
|
|
12
|
+
|
|
13
|
+
function isPreference(value: string | null): value is ThemePreference {
|
|
14
|
+
return value === "auto" || value === "light" || value === "dark";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStored(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return localStorage.getItem(THEME_STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
20
|
+
} catch {
|
|
21
|
+
return null; // private mode / embedded contexts
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createThemeStore() {
|
|
26
|
+
const browser = typeof window !== "undefined";
|
|
27
|
+
let preference = $state<ThemePreference>("auto");
|
|
28
|
+
let resolved = $state<ResolvedTheme>("light");
|
|
29
|
+
|
|
30
|
+
const media = browser ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
31
|
+
|
|
32
|
+
function apply(): void {
|
|
33
|
+
if (!browser) return;
|
|
34
|
+
if (preference === "auto") {
|
|
35
|
+
resolved = media?.matches === true ? "dark" : "light";
|
|
36
|
+
} else {
|
|
37
|
+
resolved = preference;
|
|
38
|
+
}
|
|
39
|
+
document.documentElement.classList.toggle("dark", resolved === "dark");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (browser) {
|
|
43
|
+
const stored = readStored();
|
|
44
|
+
if (isPreference(stored)) preference = stored;
|
|
45
|
+
apply();
|
|
46
|
+
// Re-resolve live when the OS preference changes while in `auto`.
|
|
47
|
+
media?.addEventListener("change", () => {
|
|
48
|
+
if (preference === "auto") apply();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
get preference(): ThemePreference {
|
|
54
|
+
return preference;
|
|
55
|
+
},
|
|
56
|
+
get resolved(): ResolvedTheme {
|
|
57
|
+
return resolved;
|
|
58
|
+
},
|
|
59
|
+
set(pref: ThemePreference): void {
|
|
60
|
+
preference = pref;
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(THEME_STORAGE_KEY, pref);
|
|
63
|
+
} catch {
|
|
64
|
+
// not persisted; still applied for this page
|
|
65
|
+
}
|
|
66
|
+
apply();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const themeStore = createThemeStore();
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import DataGrid from "$lib/components/DataGrid.svelte";
|
|
7
7
|
import StatusIndicator from "$lib/components/StatusIndicator.svelte";
|
|
8
8
|
import Dropdown from "$lib/components/Dropdown.svelte";
|
|
9
|
+
import FilterBar from "$lib/components/FilterBar.svelte";
|
|
10
|
+
import { seedValues } from "$lib/components/filter-spec.js";
|
|
11
|
+
import { applyFilters } from "$lib/utils/filter.js";
|
|
9
12
|
import { Play, Pause, Trash2, SquarePen } from "@lucide/svelte";
|
|
10
13
|
|
|
11
14
|
const { Story } = defineMeta({
|
|
@@ -14,8 +17,10 @@
|
|
|
14
17
|
tags: ["autodocs"],
|
|
15
18
|
});
|
|
16
19
|
|
|
20
|
+
const nameColumn = { key: "name", label: "Task Name", sortable: true };
|
|
21
|
+
|
|
17
22
|
const columns = [
|
|
18
|
-
|
|
23
|
+
nameColumn,
|
|
19
24
|
{ key: "schedule", label: "Schedule" },
|
|
20
25
|
{ key: "lastRun", label: "Last Run", sortable: true },
|
|
21
26
|
{ key: "status", label: "Status" },
|
|
@@ -72,6 +77,31 @@
|
|
|
72
77
|
{ divider: true },
|
|
73
78
|
{ label: "Delete", icon: Trash2, danger: true },
|
|
74
79
|
];
|
|
80
|
+
|
|
81
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField[]} */
|
|
82
|
+
const filterFields = [
|
|
83
|
+
{ type: "search", key: "q", fields: ["name", "schedule"], placeholder: "Search tasks..." },
|
|
84
|
+
{
|
|
85
|
+
type: "select",
|
|
86
|
+
key: "status",
|
|
87
|
+
label: "Status",
|
|
88
|
+
primary: true,
|
|
89
|
+
options: [
|
|
90
|
+
{ value: "all", label: "All" },
|
|
91
|
+
{ value: "success", label: "Success" },
|
|
92
|
+
{ value: "failed", label: "Failed" },
|
|
93
|
+
{ value: "running", label: "Running" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<script>
|
|
100
|
+
let filterValues = $state(seedValues(filterFields));
|
|
101
|
+
/** @type {any[]} */
|
|
102
|
+
let selected = $state([]);
|
|
103
|
+
let clicked = $state("");
|
|
104
|
+
const filtered = $derived(applyFilters(tasks, filterFields, filterValues));
|
|
75
105
|
</script>
|
|
76
106
|
|
|
77
107
|
{#snippet statusCell(/** @type {any} */ row)}
|
|
@@ -115,6 +145,39 @@
|
|
|
115
145
|
|
|
116
146
|
<Story name="Paginated" args={{ columns, data: tasks, paginate: true, pageSize: 2 }} />
|
|
117
147
|
|
|
148
|
+
<Story name="Filters, Sort, Selection, Pagination" asChild>
|
|
149
|
+
<div class="flex flex-col gap-3">
|
|
150
|
+
<FilterBar fields={filterFields} bind:values={filterValues} />
|
|
151
|
+
<DataGrid
|
|
152
|
+
{columns}
|
|
153
|
+
data={filtered}
|
|
154
|
+
selectable
|
|
155
|
+
bind:selectedRows={selected}
|
|
156
|
+
sortKey="name"
|
|
157
|
+
sortDirection="asc"
|
|
158
|
+
paginate
|
|
159
|
+
pageSize={3}
|
|
160
|
+
pageSizeOptions={[3, 5, 10]}
|
|
161
|
+
onRowClick={(row) => (clicked = row.name)}
|
|
162
|
+
emptyMessage="No tasks match these filters"
|
|
163
|
+
/>
|
|
164
|
+
<p class="font-mono text-xs text-on-surface-muted">
|
|
165
|
+
selected: {selected.map((r) => r.name).join(", ") || "none"} · last row click: {clicked ||
|
|
166
|
+
"none"}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
</Story>
|
|
170
|
+
|
|
171
|
+
<Story name="Bare With Footer, No Header" asChild>
|
|
172
|
+
<div class="rounded-[4px] border border-outline">
|
|
173
|
+
<DataGrid columns={[nameColumn]} data={tasks} bare showHeader={false}>
|
|
174
|
+
{#snippet footer()}
|
|
175
|
+
<button class="font-mono text-xs text-primary hover:underline">Load more</button>
|
|
176
|
+
{/snippet}
|
|
177
|
+
</DataGrid>
|
|
178
|
+
</div>
|
|
179
|
+
</Story>
|
|
180
|
+
|
|
118
181
|
<Story name="Striped" args={{ columns, data: tasks, striped: true }} />
|
|
119
182
|
|
|
120
183
|
<Story name="Compact" args={{ columns, data: tasks, compact: true }} />
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script module>
|
|
5
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
6
|
+
import FilterBar from "$lib/components/FilterBar.svelte";
|
|
7
|
+
import { seedValues } from "$lib/components/filter-spec.js";
|
|
8
|
+
|
|
9
|
+
const { Story } = defineMeta({
|
|
10
|
+
title: "Data/FilterBar",
|
|
11
|
+
component: FilterBar,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField} */
|
|
16
|
+
const searchField = {
|
|
17
|
+
type: "search",
|
|
18
|
+
key: "q",
|
|
19
|
+
fields: ["name"],
|
|
20
|
+
placeholder: "Search tasks...",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField[]} */
|
|
24
|
+
const fields = [
|
|
25
|
+
searchField,
|
|
26
|
+
{
|
|
27
|
+
type: "select",
|
|
28
|
+
key: "status",
|
|
29
|
+
label: "Status",
|
|
30
|
+
primary: true,
|
|
31
|
+
options: [
|
|
32
|
+
{ value: "all", label: "All" },
|
|
33
|
+
{ value: "success", label: "Success" },
|
|
34
|
+
{ value: "failed", label: "Failed" },
|
|
35
|
+
{ value: "running", label: "Running" },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{ type: "number", key: "exitCode", label: "Exit code" },
|
|
39
|
+
{ type: "daterange", key: "lastRun", label: "Last run" },
|
|
40
|
+
];
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
let values = $state(seedValues(fields));
|
|
45
|
+
let preset = $state(seedValues(fields, { status: "failed", exitCode: "1" }));
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<Story name="Default" asChild>
|
|
49
|
+
<div class="flex flex-col gap-4">
|
|
50
|
+
<FilterBar {fields} bind:values />
|
|
51
|
+
<pre class="font-mono text-xs text-on-surface-muted">{JSON.stringify(values, null, 2)}</pre>
|
|
52
|
+
</div>
|
|
53
|
+
</Story>
|
|
54
|
+
|
|
55
|
+
<Story name="Pre-filled (URL restore)" asChild>
|
|
56
|
+
<FilterBar {fields} bind:values={preset} />
|
|
57
|
+
</Story>
|
|
58
|
+
|
|
59
|
+
<Story name="Search Only" asChild>
|
|
60
|
+
<FilterBar fields={[searchField]} values={seedValues([searchField])} />
|
|
61
|
+
</Story>
|
package/src/stories/Welcome.mdx
CHANGED
|
@@ -185,7 +185,7 @@ import Logo from "../../static/runwisp_c.svg";
|
|
|
185
185
|
</div>
|
|
186
186
|
<div className="rw-card">
|
|
187
187
|
<h3>Data</h3>
|
|
188
|
-
<p>DataGrid, Avatar, StatusIndicator, StatCard, EmptyState</p>
|
|
188
|
+
<p>DataGrid, FilterBar, Avatar, StatusIndicator, StatCard, EmptyState</p>
|
|
189
189
|
</div>
|
|
190
190
|
<div className="rw-card">
|
|
191
191
|
<h3>Navigation</h3>
|
package/src/astro/CTABand.astro
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
3
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
heading: string;
|
|
7
|
-
sub?: string;
|
|
8
|
-
tone?: "default" | "primary";
|
|
9
|
-
class?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const { heading, sub, tone = "default", class: className = "" } = Astro.props;
|
|
13
|
-
|
|
14
|
-
const surface =
|
|
15
|
-
tone === "primary" ? "bg-primary text-on-primary" : "bg-surface-raised text-on-surface";
|
|
16
|
-
const subTone = tone === "primary" ? "text-on-primary/80" : "text-on-surface-muted";
|
|
17
|
-
const hasCtas = Astro.slots.has("ctas");
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
<section class:list={[className]}>
|
|
21
|
-
<div class="mx-auto max-w-5xl px-6 py-16 sm:py-20">
|
|
22
|
-
<div
|
|
23
|
-
class:list={[
|
|
24
|
-
surface,
|
|
25
|
-
"rounded-[4px] border border-outline px-6 py-12 text-center shadow-sm sm:px-10",
|
|
26
|
-
]}
|
|
27
|
-
>
|
|
28
|
-
<h2 class="font-mono text-3xl font-extrabold sm:text-4xl">{heading}</h2>
|
|
29
|
-
{sub && <p class:list={[subTone, "mt-4 font-sans text-lg"]}>{sub}</p>}
|
|
30
|
-
{
|
|
31
|
-
hasCtas && (
|
|
32
|
-
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
33
|
-
<slot name="ctas" />
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
</section>
|