@runwisp/ui 1.0.0 → 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.
Files changed (31) hide show
  1. package/package.json +4 -4
  2. package/src/astro/SiteFooter.astro +149 -31
  3. package/src/lib/components/AlertDialog.svelte +63 -25
  4. package/src/lib/components/Badge.svelte +51 -33
  5. package/src/lib/components/Button.svelte +1 -1
  6. package/src/lib/components/DataGrid.svelte +151 -90
  7. package/src/lib/components/FilterBar.svelte +156 -0
  8. package/src/lib/components/FilterPill.svelte +180 -0
  9. package/src/lib/components/NotificationBell.svelte +70 -0
  10. package/src/lib/components/SearchInput.svelte +91 -0
  11. package/src/lib/components/StatusDot.svelte +38 -0
  12. package/src/lib/components/ThemeToggle.svelte +28 -33
  13. package/src/lib/components/Tooltip.svelte +5 -2
  14. package/src/lib/components/dashboard/run-filters.test.ts +2 -2
  15. package/src/lib/components/dashboard/run-filters.ts +5 -5
  16. package/src/lib/components/dashboard/run-helpers.test.ts +1 -1
  17. package/src/lib/components/dashboard/run-helpers.ts +1 -1
  18. package/src/lib/components/filter-spec.ts +94 -0
  19. package/src/lib/index.ts +26 -1
  20. package/src/lib/utils/debounce.test.ts +31 -0
  21. package/src/lib/utils/debounce.ts +22 -0
  22. package/src/lib/utils/filter.test.ts +172 -0
  23. package/src/lib/utils/filter.ts +79 -0
  24. package/src/lib/utils/theme.svelte.test.ts +142 -0
  25. package/src/lib/utils/theme.svelte.ts +71 -0
  26. package/src/stories/DataGrid.stories.svelte +64 -1
  27. package/src/stories/FilterBar.stories.svelte +61 -0
  28. package/src/stories/Welcome.mdx +1 -1
  29. package/src/astro/CTABand.astro +0 -39
  30. package/src/astro/Hero.astro +0 -58
  31. package/src/astro/SiteHeader.astro +0 -46
@@ -3,8 +3,10 @@
3
3
 
4
4
  <script module lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import { resolvePath } from "../utils/filter.js";
6
7
 
7
8
  export interface Column<T> {
9
+ /** Field shown and sorted on. Dot-paths ("user.name") read nested fields. */
8
10
  key: keyof T | string;
9
11
  label: string;
10
12
  sortable?: boolean;
@@ -20,7 +22,11 @@
20
22
  selectedRows?: T[];
21
23
  sortKey?: string;
22
24
  sortDirection?: "asc" | "desc";
25
+ /** Take over sorting (e.g. server-side). When set, the grid only reports
26
+ * the clicked key and never reorders `data` itself. */
23
27
  onSort?: (key: string) => void;
28
+ /** Built-in fuzzy search box above the table. For typed filters use
29
+ * `FilterBar` + `applyFilters` outside the grid instead. */
24
30
  filterable?: boolean;
25
31
  filterPlaceholder?: string;
26
32
  filterKeys?: string[];
@@ -29,6 +35,8 @@
29
35
  page?: number;
30
36
  pageSize?: number;
31
37
  pageSizeOptions?: number[];
38
+ /** Server mode: the full row count. `data` is then the current page as
39
+ * delivered, so the grid skips its own filter, sort, and slicing. */
32
40
  total?: number;
33
41
  onPageChange?: (page: number) => void;
34
42
  onPageSizeChange?: (pageSize: number) => void;
@@ -43,9 +51,35 @@
43
51
  onRowClick?: (row: T) => void;
44
52
  loading?: boolean;
45
53
  stickyHeader?: boolean;
54
+ /** Hide the header row entirely — for single-column feeds (e.g. an
55
+ * inbox) where a column label would be noise. */
56
+ showHeader?: boolean;
57
+ /** Rendered below the table body, inside the frame — e.g. a cursor
58
+ * "Load more" button for feeds that don't use offset pagination. */
59
+ footer?: Snippet;
60
+ /** Drop the outer border/shadow/bg so the grid sits flush inside a Card
61
+ * or panel that already supplies its own frame. */
62
+ bare?: boolean;
63
+ /** Extra classes per row — e.g. a left accent bar for a failing row.
64
+ * Rows always carry `group`, so a `render`/`rowAction` snippet can use
65
+ * `group-hover:` to reveal on-hover controls. */
66
+ rowClass?: (row: T) => string;
46
67
  class?: string;
47
68
  wrapperClass?: string;
48
69
  }
70
+
71
+ // Type-aware comparator. Nullish sorts last; numbers numerically; dates by
72
+ // epoch; everything else by locale with numeric-aware string compare.
73
+ // Returns 0 for non-comparable/equal, so a column whose key maps to no real
74
+ // field (a display-only key) leaves order untouched — sort stays inert there.
75
+ function compareValues(a: unknown, b: unknown): number {
76
+ if (a == null && b == null) return 0;
77
+ if (a == null) return 1;
78
+ if (b == null) return -1;
79
+ if (typeof a === "number" && typeof b === "number") return a - b;
80
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
81
+ return String(a).localeCompare(String(b), undefined, { numeric: true });
82
+ }
49
83
  </script>
50
84
 
51
85
  <script lang="ts" generics="T extends object">
@@ -53,6 +87,7 @@
53
87
  import Checkbox from "./Checkbox.svelte";
54
88
  import Input from "./Input.svelte";
55
89
  import Pagination from "./Pagination.svelte";
90
+ import Spinner from "./Spinner.svelte";
56
91
  import Fuse from "fuse.js";
57
92
  import { selectionState } from "./data-grid-selection.js";
58
93
 
@@ -86,6 +121,10 @@
86
121
  onRowClick,
87
122
  loading = false,
88
123
  stickyHeader = false,
124
+ showHeader = true,
125
+ footer,
126
+ bare = false,
127
+ rowClass,
89
128
  class: className = "",
90
129
  wrapperClass,
91
130
  }: DataGridProps<T> = $props();
@@ -147,7 +186,21 @@
147
186
  return fuse.search(filterQuery).map((r) => r.item);
148
187
  });
149
188
 
150
- const totalItems = $derived(total ?? filteredData.length);
189
+ // Client-side sort. Off in server mode (`total` set) or when the parent
190
+ // drives sorting via `onSort`; otherwise reorder by the active key so a
191
+ // sortable header actually sorts. Stable + inert on display-only keys.
192
+ const sortedData = $derived.by(() => {
193
+ if (onSort || typeof total === "number" || !sortKey || !sortDirection) {
194
+ return filteredData;
195
+ }
196
+ const dir = sortDirection === "desc" ? -1 : 1;
197
+ const key = sortKey;
198
+ return [...filteredData].sort(
199
+ (a, b) => dir * compareValues(resolvePath(a, key), resolvePath(b, key)),
200
+ );
201
+ });
202
+
203
+ const totalItems = $derived(total ?? sortedData.length);
151
204
  const totalPages = $derived(Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize))));
152
205
 
153
206
  $effect(() => {
@@ -157,19 +210,25 @@
157
210
  });
158
211
 
159
212
  const pagedData = $derived.by(() => {
160
- if (typeof total === "number") return filteredData;
161
- if (!paginate) return filteredData;
213
+ if (typeof total === "number" || !paginate) return sortedData;
162
214
  const start = (page - 1) * pageSize;
163
- return filteredData.slice(start, start + pageSize);
215
+ return sortedData.slice(start, start + pageSize);
164
216
  });
165
217
 
166
218
  const selection = $derived(selectionState(pagedData, selectedRows, rowKey));
167
219
  let allSelected = $derived(selection.allSelected);
168
220
  let someSelected = $derived(selection.someSelected);
221
+
222
+ // Cell rhythm — airy by default, tight when compact. Header + body share it.
223
+ const cellPad = $derived(compact ? "px-3 py-2" : "px-5 py-3.5");
224
+ // Hover reveals a teal accent rail on the leftmost cell (see .group on <tr>).
225
+ const railHover = "group-hover:shadow-[inset_3px_0_0_var(--color-primary)]";
169
226
  </script>
170
227
 
171
228
  <div
172
- class="relative flex flex-col rounded-[4px] border border-outline bg-surface-raised shadow-sm {className}"
229
+ class="relative flex flex-col {bare
230
+ ? ''
231
+ : 'rounded-[4px] border border-outline bg-surface-raised shadow-sm'} {className}"
173
232
  >
174
233
  {#if filterable}
175
234
  <div class="border-b border-outline p-3">
@@ -198,85 +257,76 @@
198
257
  <div
199
258
  class="flex items-center gap-3 rounded-full border border-outline bg-surface-raised px-4 py-2 shadow-md"
200
259
  >
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>
260
+ <Spinner size="sm" class="text-primary" />
216
261
  <span class="font-mono text-sm text-on-surface-muted">Loading...</span>
217
262
  </div>
218
263
  </div>
219
264
  {/if}
220
265
 
221
266
  <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-mono text-xs tracking-wide text-on-surface-faint uppercase
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 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" />
267
+ {#if showHeader}
268
+ <thead
269
+ class={stickyHeader
270
+ ? "sticky top-0 z-10 bg-surface-raised/95 backdrop-blur-sm"
271
+ : ""}
272
+ >
273
+ <tr class="border-b border-outline text-left">
274
+ {#if selectable}
275
+ <th class="w-12 {cellPad}">
276
+ <Checkbox
277
+ checked={allSelected}
278
+ indeterminate={someSelected}
279
+ onchange={toggleAll}
280
+ size="sm"
281
+ />
282
+ </th>
283
+ {/if}
284
+ {#each columns as column (column.key)}
285
+ <th
286
+ class="
287
+ group {cellPad}
288
+ font-mono text-xs tracking-[0.08em] text-on-surface-faint uppercase
289
+ {column.align === 'center' ? 'text-center' : ''}
290
+ {column.align === 'right' ? 'text-right' : ''}
291
+ {sortKey === column.key
292
+ ? 'text-on-surface shadow-[inset_0_-2px_0_var(--color-primary)]'
293
+ : ''}
294
+ "
295
+ style={column.width ? `width: ${column.width}` : ""}
296
+ >
297
+ {#if column.sortable}
298
+ <button
299
+ onclick={() => handleSort(String(column.key))}
300
+ class="
301
+ inline-flex items-center gap-1 group-hover:text-on-surface
302
+ {sortKey === column.key ? 'text-on-surface' : ''}
303
+ "
304
+ >
305
+ {column.label}
306
+ {#if sortKey === column.key}
307
+ {#if sortDirection === "asc"}
308
+ <ArrowUp size={14} class="text-primary" />
309
+ {:else}
310
+ <ArrowDown size={14} class="text-primary" />
311
+ {/if}
260
312
  {:else}
261
- <ArrowDown size={14} class="text-primary" />
313
+ <ArrowUpDown
314
+ size={14}
315
+ class="text-on-surface-faint opacity-0 group-hover:opacity-100"
316
+ />
262
317
  {/if}
263
- {:else}
264
- <ArrowUpDown
265
- size={14}
266
- class="text-on-surface-faint opacity-0 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>
318
+ </button>
319
+ {:else}
320
+ {column.label}
321
+ {/if}
322
+ </th>
323
+ {/each}
324
+ {#if rowAction}
325
+ <th class="w-16 {cellPad}"></th>
326
+ {/if}
327
+ </tr>
328
+ </thead>
329
+ {/if}
280
330
  <tbody>
281
331
  {#if pagedData.length === 0 && !loading}
282
332
  <tr>
@@ -295,18 +345,22 @@
295
345
  {#each pagedData as row, idx (row[rowKey])}
296
346
  <tr
297
347
  class="
298
- border-b border-outline 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
- "
348
+ group border-b border-outline-faint last:border-b-0
349
+ {striped && idx % 2 === 1 ? 'bg-surface-sunken/30' : ''}
350
+ {hoverable ? 'hover:bg-surface-sunken' : ''}
351
+ {isSelected(row) ? 'bg-primary-soft/30' : ''}
352
+ {onRowClick ? 'cursor-pointer' : ''}
353
+ {rowClass ? rowClass(row) : ''}
354
+ "
304
355
  onclick={() => onRowClick?.(row)}
305
356
  >
306
357
  {#if selectable}
358
+ <!-- stopPropagation: a selection click must never also
359
+ fire onRowClick (which usually navigates away). -->
307
360
  <td
308
- class="px-4 {compact ? 'py-2' : 'py-3'} cursor-pointer"
361
+ class="{cellPad} cursor-pointer {hoverable ? railHover : ''}"
309
362
  onclick={(e) => {
363
+ e.stopPropagation();
310
364
  if (!(e.target instanceof HTMLInputElement)) toggleRow(row);
311
365
  }}
312
366
  >
@@ -317,13 +371,14 @@
317
371
  />
318
372
  </td>
319
373
  {/if}
320
- {#each columns as column (column.key)}
374
+ {#each columns as column, ci (column.key)}
321
375
  <td
322
376
  class="
323
- px-4 {compact ? 'py-2' : 'py-3'} text-on-surface-muted
324
- {column.align === 'center' ? 'text-center' : ''}
325
- {column.align === 'right' ? 'text-right' : ''}
326
- "
377
+ {cellPad} text-on-surface-muted
378
+ {column.align === 'center' ? 'text-center' : ''}
379
+ {column.align === 'right' ? 'text-right' : ''}
380
+ {ci === 0 && !selectable && hoverable ? railHover : ''}
381
+ "
327
382
  >
328
383
  {#if column.render}
329
384
  {@render column.render(row)}
@@ -331,13 +386,13 @@
331
386
  <!-- Raw field value: a token, so mono. Snippet-rendered
332
387
  cells choose their own voice. -->
333
388
  <span class="font-mono"
334
- >{row[column.key as keyof T] ?? "-"}</span
389
+ >{resolvePath(row, String(column.key)) ?? "-"}</span
335
390
  >
336
391
  {/if}
337
392
  </td>
338
393
  {/each}
339
394
  {#if rowAction}
340
- <td class="px-4 {compact ? 'py-2' : 'py-3'} text-right">
395
+ <td class="{cellPad} text-right">
341
396
  {@render rowAction(row)}
342
397
  </td>
343
398
  {/if}
@@ -348,6 +403,12 @@
348
403
  </table>
349
404
  </div>
350
405
 
406
+ {#if footer}
407
+ <div class="border-t border-outline p-3">
408
+ {@render footer()}
409
+ </div>
410
+ {/if}
411
+
351
412
  {#if paginate}
352
413
  <div class="border-t border-outline p-3">
353
414
  <Pagination
@@ -0,0 +1,156 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <!--
5
+ One filter bar for every table. Driven by a declarative `FilterField[]` +
6
+ a bound `values` object (see filter-spec.ts), the same way DataGrid is driven
7
+ by `Column[]`.
8
+
9
+ Progressive disclosure (Linear/Sentry-style): the row is a search box + a set
10
+ of filter pills + a "+ Add filter" menu + Reset. `primary` fields are always
11
+ shown as pills; every other field stays behind "+ Add filter" until the user
12
+ adds it, then lives as a pill. Each pill is self-contained (label + value +
13
+ clear in one element, see FilterPill) so activating a filter never adds a
14
+ second row — the table below never jumps. Data-mode agnostic: the parent feeds
15
+ `values` to applyFilters (client) or maps it to query params (server).
16
+ -->
17
+
18
+ <script lang="ts">
19
+ import { SvelteSet } from "svelte/reactivity";
20
+ import SearchInput from "./SearchInput.svelte";
21
+ import FilterPill from "./FilterPill.svelte";
22
+ import Popover from "./Popover.svelte";
23
+ import { Plus, RotateCcw } from "@lucide/svelte";
24
+ import {
25
+ type FilterField,
26
+ type FilterValues,
27
+ fieldKeys,
28
+ fieldLabel,
29
+ isFieldActive,
30
+ } from "./filter-spec.js";
31
+
32
+ let {
33
+ fields,
34
+ values = $bindable({}),
35
+ class: className = "",
36
+ }: {
37
+ fields: FilterField[];
38
+ values?: FilterValues;
39
+ class?: string;
40
+ } = $props();
41
+
42
+ const searchField = $derived(
43
+ fields.find((f): f is Extract<FilterField, { type: "search" }> => f.type === "search"),
44
+ );
45
+ const pillFields = $derived(fields.filter((f) => f.type !== "search"));
46
+ const isPrimary = (f: FilterField) => f.primary === true;
47
+
48
+ // Non-primary fields surface as pills only once active or explicitly added.
49
+ // `added` keeps a just-added-but-still-blank field visible while its editor
50
+ // is open; it's dropped again if the user closes the editor without a value.
51
+ const added = new SvelteSet<string>();
52
+ const isShown = (f: FilterField) =>
53
+ isPrimary(f) || isFieldActive(f, values) || added.has(f.key);
54
+ const shownPills = $derived(pillFields.filter(isShown));
55
+ const addable = $derived(pillFields.filter((f) => !isShown(f)));
56
+
57
+ const anyActive = $derived(fields.some((f) => isFieldActive(f, values)));
58
+
59
+ let justAdded = $state<string | null>(null);
60
+ let addOpen = $state(false);
61
+ let searchEl = $state<HTMLInputElement | null>(null);
62
+
63
+ function addFilter(f: FilterField) {
64
+ added.add(f.key);
65
+ justAdded = f.key;
66
+ addOpen = false;
67
+ }
68
+
69
+ function onPillClose(f: FilterField) {
70
+ if (justAdded === f.key) justAdded = null;
71
+ if (!isPrimary(f) && !isFieldActive(f, values)) added.delete(f.key);
72
+ }
73
+
74
+ function clearField(f: FilterField) {
75
+ for (const k of fieldKeys(f)) values[k] = "";
76
+ }
77
+
78
+ function reset() {
79
+ for (const f of fields) clearField(f);
80
+ added.clear();
81
+ }
82
+
83
+ // `/` focuses the search box, unless the user is already typing somewhere.
84
+ function onWindowKeydown(e: KeyboardEvent) {
85
+ if (e.key !== "/" || !searchEl) return;
86
+ const t = e.target;
87
+ if (
88
+ t instanceof HTMLElement &&
89
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
90
+ )
91
+ return;
92
+ e.preventDefault();
93
+ searchEl.focus();
94
+ }
95
+ </script>
96
+
97
+ <svelte:window onkeydown={onWindowKeydown} />
98
+
99
+ <div class="flex flex-wrap items-center gap-2 {className}">
100
+ {#if searchField}
101
+ <SearchInput
102
+ bind:value={values[searchField.key]}
103
+ bind:element={searchEl}
104
+ placeholder={searchField.placeholder ?? "Search..."}
105
+ />
106
+ {/if}
107
+
108
+ {#each shownPills as field (field.key)}
109
+ <FilterPill
110
+ {field}
111
+ bind:values
112
+ autoOpen={justAdded === field.key}
113
+ onClose={() => onPillClose(field)}
114
+ />
115
+ {/each}
116
+
117
+ {#if addable.length > 0}
118
+ <Popover bind:open={addOpen} placement="bottom-start" mobileSheet>
119
+ {#snippet trigger()}
120
+ <span
121
+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-[3px] border border-dashed border-outline-hover bg-transparent px-3 py-2 text-sm text-on-surface-muted hover:border-solid hover:bg-surface-raised hover:text-on-surface"
122
+ >
123
+ <Plus size={14} />
124
+ {shownPills.length > 0 ? "Filter" : "Add filter"}
125
+ </span>
126
+ {/snippet}
127
+ <div class="flex w-48 flex-col gap-0.5">
128
+ {#each addable as field (field.key)}
129
+ <button
130
+ type="button"
131
+ class="rounded-[3px] px-2.5 py-1.5 text-left text-sm text-on-surface-muted hover:bg-surface-sunken hover:text-on-surface"
132
+ onclick={(e) => {
133
+ // Stop the click reaching window: the pill we're about
134
+ // to add auto-opens its editor, and this same click would
135
+ // otherwise trip that fresh Popover's outside-click close.
136
+ e.stopPropagation();
137
+ addFilter(field);
138
+ }}
139
+ >
140
+ {fieldLabel(field)}
141
+ </button>
142
+ {/each}
143
+ </div>
144
+ </Popover>
145
+ {/if}
146
+
147
+ {#if anyActive}
148
+ <button
149
+ class="inline-flex items-center gap-1.5 rounded-[3px] px-2.5 py-2 text-sm font-medium text-on-surface-muted hover:bg-surface-sunken hover:text-on-surface"
150
+ onclick={reset}
151
+ >
152
+ <RotateCcw size={14} />
153
+ Reset
154
+ </button>
155
+ {/if}
156
+ </div>