@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
|
@@ -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
|
-
|
|
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
|
|
161
|
-
if (!paginate) return filteredData;
|
|
213
|
+
if (typeof total === "number" || !paginate) return sortedData;
|
|
162
214
|
const start = (page - 1) * pageSize;
|
|
163
|
-
return
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
<
|
|
313
|
+
<ArrowUpDown
|
|
314
|
+
size={14}
|
|
315
|
+
class="text-on-surface-faint opacity-0 group-hover:opacity-100"
|
|
316
|
+
/>
|
|
262
317
|
{/if}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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="
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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="
|
|
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>
|