@pienter/ui 0.17.0 → 0.19.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/CHANGELOG.md +66 -0
- package/CONVENTIONS.md +89 -53
- package/components/feedback/toast/Toast.vue +4 -1
- package/components/feedback/toast/ToastHost.vue +5 -0
- package/components/form/checkbox/checkbox.css +4 -3
- package/components/form/number-field/NumberField.vue +15 -3
- package/components/form/number-field/number-field.css +3 -2
- package/components/form/radio-group/radio-group.css +1 -0
- package/components/form/switch/switch.css +1 -0
- package/components/layout/index/Index.vue +109 -27
- package/components/layout/index/index.css +18 -0
- package/components/layout/index/{useIndex.ts → useIndexQuery.ts} +62 -8
- package/components/layout/index-filters/FilterCheckbox.vue +29 -0
- package/components/layout/index-filters/FilterMultiSelect.vue +110 -0
- package/components/layout/index-filters/FilterRange.vue +80 -0
- package/components/layout/index-filters/FilterSelect.vue +38 -0
- package/components/layout/index-filters/filters.css +65 -0
- package/components/layout/index-filters/useFilterControl.ts +40 -0
- package/components/layout/table/DataTable.vue +10 -0
- package/components/layout/table/Table.vue +4 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +9 -4
- package/components/navigation/pagination/Pagination.vue +12 -3
- package/package.json +6 -1
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<div
|
|
20
20
|
v-if="
|
|
21
21
|
!invalidQuery &&
|
|
22
|
-
(searchable || $slots.filters ||
|
|
22
|
+
(searchable || $slots.filters || vocabulary.sorts.length)
|
|
23
23
|
"
|
|
24
24
|
class="pui-index__toolbar"
|
|
25
25
|
>
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
:set-filters="setFilters"
|
|
39
39
|
:set-filter="setFilter"
|
|
40
40
|
/>
|
|
41
|
-
<div v-if="
|
|
41
|
+
<div v-if="vocabulary.sorts.length" class="pui-index__sort">
|
|
42
42
|
<Select
|
|
43
43
|
:label="sortLabel"
|
|
44
44
|
:aria-label="sortLabel"
|
|
@@ -51,6 +51,34 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
</div>
|
|
53
53
|
|
|
54
|
+
<div
|
|
55
|
+
v-if="!invalidQuery && activeFilters.length"
|
|
56
|
+
class="pui-index__filters"
|
|
57
|
+
>
|
|
58
|
+
<ul class="pui-index__chips" aria-label="Active filters">
|
|
59
|
+
<li
|
|
60
|
+
v-for="chip in activeFilters"
|
|
61
|
+
:key="`${chip.name}=${chip.value}`"
|
|
62
|
+
>
|
|
63
|
+
<Badge>
|
|
64
|
+
{{ chip.label }}: {{ chip.valueLabel }}
|
|
65
|
+
<IconButton
|
|
66
|
+
name="x"
|
|
67
|
+
size="sm"
|
|
68
|
+
variant="ghost"
|
|
69
|
+
:label="`Remove ${chip.label} ${chip.valueLabel}`"
|
|
70
|
+
@click="
|
|
71
|
+
removeFilterValue(chip.name, chip.value)
|
|
72
|
+
"
|
|
73
|
+
/>
|
|
74
|
+
</Badge>
|
|
75
|
+
</li>
|
|
76
|
+
</ul>
|
|
77
|
+
<Button size="sm" variant="ghost" @click="clearFilters()"
|
|
78
|
+
>Clear all</Button
|
|
79
|
+
>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
54
82
|
<div class="pui-index__selection" role="status">
|
|
55
83
|
<slot
|
|
56
84
|
v-if="selectable && selected.length && $slots.selection"
|
|
@@ -170,15 +198,24 @@ import Select from '../../form/select/Select.vue';
|
|
|
170
198
|
import Alert from '../../feedback/alert/Alert.vue';
|
|
171
199
|
import Pagination from '../../navigation/pagination/Pagination.vue';
|
|
172
200
|
import PaginationFooter from '../../navigation/pagination/PaginationFooter.vue';
|
|
173
|
-
import
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
201
|
+
import {
|
|
202
|
+
joinFilterValues,
|
|
203
|
+
splitFilterValues,
|
|
204
|
+
type ApiError,
|
|
205
|
+
type ListQuery,
|
|
206
|
+
type ListLoader,
|
|
207
|
+
type QueryOptions,
|
|
178
208
|
} from '../../../utils/cms/index.js';
|
|
179
209
|
import Button from '../../action/button/Button.vue';
|
|
210
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
211
|
+
import Badge from '../../display/badge/Badge.vue';
|
|
180
212
|
import { generateId } from '../../../utils/a11y/id.js';
|
|
181
|
-
import {
|
|
213
|
+
import {
|
|
214
|
+
provideIndexQuery,
|
|
215
|
+
useIndexQuery,
|
|
216
|
+
type IndexQuery,
|
|
217
|
+
type IndexQueryOptions,
|
|
218
|
+
} from './useIndexQuery.js';
|
|
182
219
|
import {
|
|
183
220
|
formatSort,
|
|
184
221
|
parseSort,
|
|
@@ -187,10 +224,13 @@ import {
|
|
|
187
224
|
|
|
188
225
|
const props = withDefaults(
|
|
189
226
|
defineProps<{
|
|
190
|
-
/** Returns the matching page using the shared backend response contract. */
|
|
191
|
-
load
|
|
227
|
+
/** Returns the matching page using the shared backend response contract. Required unless `state` is passed. */
|
|
228
|
+
load?: ListLoader<T>;
|
|
192
229
|
columns: Column[];
|
|
193
|
-
|
|
230
|
+
/** Required unless `state` is passed. */
|
|
231
|
+
queryOptions?: QueryOptions;
|
|
232
|
+
/** Query state from `useIndexQuery`, when the definition owns it; `load`, `queryOptions`, `syncQuery` and `searchDebounce` then come from there. */
|
|
233
|
+
state?: IndexQuery<T>;
|
|
194
234
|
/** `module` is the page itself (flat, h1); `related` sits inside a record's detail page (one bordered region, h2). */
|
|
195
235
|
placement?: 'module' | 'related';
|
|
196
236
|
/** Synchronize list parameters with the current Vue Router route; a string prefixes every key (`contacts.page`). */
|
|
@@ -207,6 +247,10 @@ const props = withDefaults(
|
|
|
207
247
|
sortLabel?: string;
|
|
208
248
|
/** Labels for sort keys, including fields without a visible column. */
|
|
209
249
|
sortLabels?: Record<string, string>;
|
|
250
|
+
/** Labels for filter names in the active-filters row; the raw name otherwise. */
|
|
251
|
+
filterLabels?: Record<string, string>;
|
|
252
|
+
/** Labels for filter values in the active-filters row, per filter name; the raw value otherwise. */
|
|
253
|
+
filterValueLabels?: Record<string, Record<string, string>>;
|
|
210
254
|
/** An empty array hides the page-size control. */
|
|
211
255
|
pageSizes?: number[];
|
|
212
256
|
pageSizeLabel?: string;
|
|
@@ -216,6 +260,9 @@ const props = withDefaults(
|
|
|
216
260
|
clickable?: boolean;
|
|
217
261
|
}>(),
|
|
218
262
|
{
|
|
263
|
+
load: undefined,
|
|
264
|
+
queryOptions: undefined,
|
|
265
|
+
state: undefined,
|
|
219
266
|
placement: 'module',
|
|
220
267
|
syncQuery: false,
|
|
221
268
|
searchDebounce: 250,
|
|
@@ -227,6 +274,8 @@ const props = withDefaults(
|
|
|
227
274
|
searchPlaceholder: undefined,
|
|
228
275
|
sortLabel: 'Sort by',
|
|
229
276
|
sortLabels: () => ({}),
|
|
277
|
+
filterLabels: () => ({}),
|
|
278
|
+
filterValueLabels: () => ({}),
|
|
230
279
|
pageSizes: () => [10, 20, 50],
|
|
231
280
|
pageSizeLabel: 'Rows per page',
|
|
232
281
|
rowKey: 'id',
|
|
@@ -268,23 +317,17 @@ const slots = defineSlots<{
|
|
|
268
317
|
[name: `cell:${string}`]: (scope: { row: T; value: unknown }) => unknown;
|
|
269
318
|
}>();
|
|
270
319
|
|
|
271
|
-
|
|
272
|
-
props.
|
|
273
|
-
(column) =>
|
|
274
|
-
column.sortable && !props.queryOptions.sorts.includes(column.key),
|
|
275
|
-
),
|
|
276
|
-
);
|
|
277
|
-
function validateSortColumns(unsupported: Column[]) {
|
|
278
|
-
if (unsupported.length) {
|
|
320
|
+
function ownState() {
|
|
321
|
+
if (!props.load || !props.queryOptions)
|
|
279
322
|
throw new Error(
|
|
280
|
-
|
|
323
|
+
'[pui] Index needs load and queryOptions, or a state from useIndexQuery.',
|
|
281
324
|
);
|
|
282
|
-
|
|
325
|
+
return useIndexQuery<T>(props as IndexQueryOptions<T>);
|
|
283
326
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
327
|
+
const state = props.state ?? ownState();
|
|
328
|
+
provideIndexQuery(state);
|
|
287
329
|
const {
|
|
330
|
+
queryOptions: vocabulary,
|
|
288
331
|
query,
|
|
289
332
|
search,
|
|
290
333
|
rows,
|
|
@@ -298,9 +341,27 @@ const {
|
|
|
298
341
|
setPage,
|
|
299
342
|
setFilters,
|
|
300
343
|
setFilter,
|
|
344
|
+
clearFilters,
|
|
301
345
|
reload,
|
|
302
346
|
reset,
|
|
303
|
-
} =
|
|
347
|
+
} = state;
|
|
348
|
+
|
|
349
|
+
const unsupportedSortColumns = computed(() =>
|
|
350
|
+
props.columns.filter(
|
|
351
|
+
(column) =>
|
|
352
|
+
column.sortable && !vocabulary.value.sorts.includes(column.key),
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
function validateSortColumns(unsupported: Column[]) {
|
|
356
|
+
if (unsupported.length) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`[pui] Index sortable columns must be allowed by queryOptions.sorts: ${unsupported.map((column) => column.key).join(', ')}.`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
validateSortColumns(unsupportedSortColumns.value);
|
|
363
|
+
watch(unsupportedSortColumns, validateSortColumns);
|
|
364
|
+
|
|
304
365
|
defineExpose({ reload });
|
|
305
366
|
const titleId = generateId('index-title');
|
|
306
367
|
const total = computed(() => meta.value.total);
|
|
@@ -311,7 +372,7 @@ const showResults = computed(
|
|
|
311
372
|
);
|
|
312
373
|
const sortOptions = computed(() => [
|
|
313
374
|
{ value: '', label: 'Default order' },
|
|
314
|
-
...
|
|
375
|
+
...vocabulary.value.sorts.flatMap((key) => {
|
|
315
376
|
const label =
|
|
316
377
|
props.sortLabels[key] ??
|
|
317
378
|
props.columns.find((column) => column.key === key)?.label ??
|
|
@@ -331,6 +392,27 @@ const sortOptions = computed(() => [
|
|
|
331
392
|
}),
|
|
332
393
|
]);
|
|
333
394
|
|
|
395
|
+
const activeFilters = computed(() =>
|
|
396
|
+
Object.entries(query.value.filters).flatMap(([name, raw]) =>
|
|
397
|
+
splitFilterValues(raw).map((value) => ({
|
|
398
|
+
name,
|
|
399
|
+
value,
|
|
400
|
+
label: props.filterLabels[name] ?? name,
|
|
401
|
+
valueLabel: props.filterValueLabels[name]?.[value] ?? value,
|
|
402
|
+
})),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
function removeFilterValue(name: string, value: string) {
|
|
406
|
+
setFilter(
|
|
407
|
+
name,
|
|
408
|
+
joinFilterValues(
|
|
409
|
+
splitFilterValues(query.value.filters[name]).filter(
|
|
410
|
+
(other) => other !== value,
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
334
416
|
const cellSlots = computed(() =>
|
|
335
417
|
props.columns
|
|
336
418
|
.map((column) => `cell:${column.key}` as const)
|
|
@@ -353,7 +435,7 @@ const pageSizeOptions = computed(() =>
|
|
|
353
435
|
(size) =>
|
|
354
436
|
Number.isInteger(size) &&
|
|
355
437
|
size > 0 &&
|
|
356
|
-
size <= (
|
|
438
|
+
size <= (vocabulary.value.maxPageSize ?? 100),
|
|
357
439
|
)
|
|
358
440
|
.sort((a, b) => a - b)
|
|
359
441
|
.map((size) => ({ value: String(size), label: String(size) })),
|
|
@@ -39,6 +39,22 @@
|
|
|
39
39
|
max-inline-size: 100%;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
.pui-index__filters {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-wrap: wrap;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: var(--space-2xs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.pui-index__chips {
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-wrap: wrap;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--space-2xs);
|
|
54
|
+
list-style: none;
|
|
55
|
+
padding: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
.pui-index__results {
|
|
43
59
|
min-inline-size: 0;
|
|
44
60
|
}
|
|
@@ -76,6 +92,7 @@
|
|
|
76
92
|
selection region is always rendered, so it spaces itself only when filled. */
|
|
77
93
|
.pui-index[data-placement='module'] {
|
|
78
94
|
& .pui-index__toolbar,
|
|
95
|
+
& .pui-index__filters,
|
|
79
96
|
& .pui-index__selection:not(:empty),
|
|
80
97
|
& .pui-index__error {
|
|
81
98
|
margin-block-end: var(--space-s);
|
|
@@ -102,6 +119,7 @@
|
|
|
102
119
|
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
& .pui-index__filters,
|
|
105
123
|
& .pui-index__selection:not(:empty),
|
|
106
124
|
& .pui-index__error {
|
|
107
125
|
padding: var(--space-s);
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
inject,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
provide,
|
|
6
|
+
ref,
|
|
7
|
+
shallowRef,
|
|
8
|
+
watch,
|
|
9
|
+
type InjectionKey,
|
|
10
|
+
} from 'vue';
|
|
2
11
|
import {
|
|
3
12
|
routeLocationKey,
|
|
4
13
|
routerKey,
|
|
@@ -17,12 +26,50 @@ import {
|
|
|
17
26
|
type QueryOptions,
|
|
18
27
|
} from '../../../utils/cms/index.js';
|
|
19
28
|
|
|
20
|
-
interface
|
|
29
|
+
export interface IndexQueryOptions<T> {
|
|
30
|
+
/** Returns the matching page using the shared backend response contract. */
|
|
21
31
|
load: ListLoader<T>;
|
|
22
32
|
queryOptions: QueryOptions;
|
|
23
|
-
/** `true` syncs the bare keys; a string syncs `<prefix>.<key>` instead. */
|
|
24
|
-
syncQuery
|
|
25
|
-
|
|
33
|
+
/** `true` syncs the bare keys; a string syncs `<prefix>.<key>` instead. Default `false`. */
|
|
34
|
+
syncQuery?: boolean | string;
|
|
35
|
+
/** Delay in milliseconds before committing typed search text. Default 250. */
|
|
36
|
+
searchDebounce?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The query state of one index, shared by `Index` and any filter control bound to it. */
|
|
40
|
+
export type IndexQuery<T> = ReturnType<typeof useIndexQuery<T>>;
|
|
41
|
+
|
|
42
|
+
const indexQueryKey: InjectionKey<IndexQuery<unknown>> =
|
|
43
|
+
Symbol('pui-index-query');
|
|
44
|
+
|
|
45
|
+
/** Makes `state` the index every `useIndexFilters()` below reads; `Index` does this for its own subtree. */
|
|
46
|
+
export function provideIndexQuery<T>(state: IndexQuery<T>) {
|
|
47
|
+
provide(indexQueryKey, state);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The state provided above the caller, or `undefined` outside an Index and without a provider. */
|
|
51
|
+
export function injectIndexQuery(): IndexQuery<unknown> | undefined {
|
|
52
|
+
return inject(indexQueryKey, undefined);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a filter control needs from the index it belongs to: the provided state, or an explicit one. */
|
|
56
|
+
export function useIndexFilters(state?: IndexQuery<unknown>) {
|
|
57
|
+
const index = state ?? injectIndexQuery();
|
|
58
|
+
if (!index)
|
|
59
|
+
throw new Error(
|
|
60
|
+
'[pui] useIndexFilters needs an Index above it or a state from useIndexQuery.',
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
filters: computed<Readonly<ListQuery['filters']>>(
|
|
64
|
+
() => index.query.value.filters,
|
|
65
|
+
),
|
|
66
|
+
/** Allowed filter names, from `queryOptions.filters`. */
|
|
67
|
+
filterNames: computed(() => index.queryOptions.value.filters),
|
|
68
|
+
loading: computed(() => index.loading.value),
|
|
69
|
+
setFilter: index.setFilter,
|
|
70
|
+
setFilters: index.setFilters,
|
|
71
|
+
clearFilters: index.clearFilters,
|
|
72
|
+
};
|
|
26
73
|
}
|
|
27
74
|
|
|
28
75
|
function isListParameter(key: string): boolean {
|
|
@@ -83,10 +130,11 @@ interface Work {
|
|
|
83
130
|
finish: () => void;
|
|
84
131
|
}
|
|
85
132
|
|
|
86
|
-
export function
|
|
133
|
+
export function useIndexQuery<T>(props: IndexQueryOptions<T>) {
|
|
87
134
|
const route = inject(routeLocationKey, undefined);
|
|
88
135
|
const router = inject(routerKey, undefined);
|
|
89
|
-
const syncing = () =>
|
|
136
|
+
const syncing = () =>
|
|
137
|
+
props.syncQuery !== undefined && props.syncQuery !== false;
|
|
90
138
|
const prefix = () =>
|
|
91
139
|
typeof props.syncQuery === 'string' && props.syncQuery
|
|
92
140
|
? `${props.syncQuery}.`
|
|
@@ -334,7 +382,7 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
334
382
|
() => {
|
|
335
383
|
void commit({ ...query.value, search: value, page: 1 }, true);
|
|
336
384
|
},
|
|
337
|
-
Math.max(0, props.searchDebounce),
|
|
385
|
+
Math.max(0, props.searchDebounce ?? 250),
|
|
338
386
|
);
|
|
339
387
|
}
|
|
340
388
|
|
|
@@ -379,6 +427,10 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
379
427
|
setFilters(filters);
|
|
380
428
|
}
|
|
381
429
|
|
|
430
|
+
function clearFilters() {
|
|
431
|
+
setFilters({});
|
|
432
|
+
}
|
|
433
|
+
|
|
382
434
|
onScopeDispose(() => {
|
|
383
435
|
disposed = true;
|
|
384
436
|
navigation++;
|
|
@@ -388,6 +440,7 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
388
440
|
});
|
|
389
441
|
|
|
390
442
|
return {
|
|
443
|
+
queryOptions: computed(() => props.queryOptions),
|
|
391
444
|
query,
|
|
392
445
|
search,
|
|
393
446
|
rows,
|
|
@@ -401,6 +454,7 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
401
454
|
setPage,
|
|
402
455
|
setFilters,
|
|
403
456
|
setFilter,
|
|
457
|
+
clearFilters,
|
|
404
458
|
reload,
|
|
405
459
|
reset,
|
|
406
460
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Checkbox
|
|
3
|
+
:label="label"
|
|
4
|
+
:model-value="filters[name] === 'true'"
|
|
5
|
+
@update:model-value="setFilter(name, $event ? 'true' : undefined)"
|
|
6
|
+
/>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
import Checkbox from '../../form/checkbox/Checkbox.vue';
|
|
11
|
+
import type { IndexQuery } from '../index/useIndexQuery.js';
|
|
12
|
+
import { useFilterControl } from './useFilterControl.js';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
/** The allowlisted filter name: `'true'` while checked, deleted otherwise. */
|
|
17
|
+
name: string;
|
|
18
|
+
label: string;
|
|
19
|
+
/** The index state, for a control rendered outside an Index without a provider above it. */
|
|
20
|
+
state?: IndexQuery<unknown>;
|
|
21
|
+
}>(),
|
|
22
|
+
{ state: undefined },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const { filters, setFilter } = useFilterControl(
|
|
26
|
+
() => [props.name],
|
|
27
|
+
props.state,
|
|
28
|
+
);
|
|
29
|
+
</script>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<fieldset class="pui-field pui-filter-multi-select">
|
|
3
|
+
<legend class="pui-field__label">
|
|
4
|
+
{{ label }} ({{ selected.length }})
|
|
5
|
+
</legend>
|
|
6
|
+
<Button
|
|
7
|
+
v-if="selected.length"
|
|
8
|
+
class="pui-filter-multi-select__clear"
|
|
9
|
+
size="sm"
|
|
10
|
+
variant="ghost"
|
|
11
|
+
:aria-label="`Clear ${label}`"
|
|
12
|
+
@click="setFilter(name, undefined)"
|
|
13
|
+
>Clear</Button
|
|
14
|
+
>
|
|
15
|
+
<input
|
|
16
|
+
v-if="searchable"
|
|
17
|
+
v-model="search"
|
|
18
|
+
class="pui-input pui-filter-multi-select__search"
|
|
19
|
+
type="search"
|
|
20
|
+
:aria-label="`Search ${label}`"
|
|
21
|
+
:placeholder="searchPlaceholder ?? `Search ${label}`"
|
|
22
|
+
/>
|
|
23
|
+
<div class="pui-filter-multi-select__options">
|
|
24
|
+
<label
|
|
25
|
+
v-for="option in visibleOptions"
|
|
26
|
+
:key="option.value"
|
|
27
|
+
class="pui-filter-multi-select__option"
|
|
28
|
+
>
|
|
29
|
+
<input
|
|
30
|
+
class="pui-checkbox"
|
|
31
|
+
type="checkbox"
|
|
32
|
+
:name="name"
|
|
33
|
+
:value="option.value"
|
|
34
|
+
:checked="selected.includes(option.value)"
|
|
35
|
+
:aria-checked="
|
|
36
|
+
selected.includes(option.value) ? 'true' : 'false'
|
|
37
|
+
"
|
|
38
|
+
@change="
|
|
39
|
+
toggle(
|
|
40
|
+
option.value,
|
|
41
|
+
($event.target as HTMLInputElement).checked,
|
|
42
|
+
)
|
|
43
|
+
"
|
|
44
|
+
/>
|
|
45
|
+
<span class="pui-filter-multi-select__label">{{
|
|
46
|
+
option.label
|
|
47
|
+
}}</span>
|
|
48
|
+
<span
|
|
49
|
+
v-if="typeof option.count === 'number'"
|
|
50
|
+
class="pui-filter-multi-select__count"
|
|
51
|
+
>({{ option.count }})</span
|
|
52
|
+
>
|
|
53
|
+
</label>
|
|
54
|
+
<p v-if="!visibleOptions.length" class="pui-field__hint">
|
|
55
|
+
No matching options.
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
</fieldset>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script setup lang="ts">
|
|
62
|
+
import { computed, ref } from 'vue';
|
|
63
|
+
import Button from '../../action/button/Button.vue';
|
|
64
|
+
import {
|
|
65
|
+
joinFilterValues,
|
|
66
|
+
splitFilterValues,
|
|
67
|
+
} from '../../../utils/cms/index.js';
|
|
68
|
+
import type { IndexQuery } from '../index/useIndexQuery.js';
|
|
69
|
+
import { useFilterControl } from './useFilterControl.js';
|
|
70
|
+
|
|
71
|
+
const props = withDefaults(
|
|
72
|
+
defineProps<{
|
|
73
|
+
/** The allowlisted filter name; values join as one comma-separated string (ADR 0009). */
|
|
74
|
+
name: string;
|
|
75
|
+
label: string;
|
|
76
|
+
/** `count` renders muted after the label when it is a number. */
|
|
77
|
+
options: { value: string; label: string; count?: number }[];
|
|
78
|
+
/** Shows a search box that narrows the options by case-insensitive substring of the label. */
|
|
79
|
+
searchable?: boolean;
|
|
80
|
+
searchPlaceholder?: string;
|
|
81
|
+
/** The index state, for a control rendered outside an Index without a provider above it. */
|
|
82
|
+
state?: IndexQuery<unknown>;
|
|
83
|
+
}>(),
|
|
84
|
+
{ searchable: false, searchPlaceholder: undefined, state: undefined },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const { filters, setFilter } = useFilterControl(
|
|
88
|
+
() => [props.name],
|
|
89
|
+
props.state,
|
|
90
|
+
);
|
|
91
|
+
const search = ref('');
|
|
92
|
+
const selected = computed(() => splitFilterValues(filters.value[props.name]));
|
|
93
|
+
const visibleOptions = computed(() => {
|
|
94
|
+
const needle = props.searchable ? search.value.trim().toLowerCase() : '';
|
|
95
|
+
return needle
|
|
96
|
+
? props.options.filter((option) =>
|
|
97
|
+
option.label.toLowerCase().includes(needle),
|
|
98
|
+
)
|
|
99
|
+
: props.options;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function toggle(value: string, checked: boolean) {
|
|
103
|
+
const rest = selected.value.filter((other) => other !== value);
|
|
104
|
+
setFilter(props.name, joinFilterValues(checked ? [...rest, value] : rest));
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
@import './filters.css';
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<fieldset class="pui-field pui-filter-range">
|
|
3
|
+
<legend class="pui-field__label">{{ label }}</legend>
|
|
4
|
+
<div class="pui-filter-range__bounds">
|
|
5
|
+
<NumberField
|
|
6
|
+
label="Min"
|
|
7
|
+
layout="inline"
|
|
8
|
+
:steppers="false"
|
|
9
|
+
:min="min"
|
|
10
|
+
:max="max"
|
|
11
|
+
:step="step"
|
|
12
|
+
:model-value="bound(minName)"
|
|
13
|
+
@change="
|
|
14
|
+
commit(
|
|
15
|
+
minName,
|
|
16
|
+
($event.target as HTMLInputElement).valueAsNumber,
|
|
17
|
+
)
|
|
18
|
+
"
|
|
19
|
+
/>
|
|
20
|
+
<NumberField
|
|
21
|
+
label="Max"
|
|
22
|
+
layout="inline"
|
|
23
|
+
:steppers="false"
|
|
24
|
+
:min="min"
|
|
25
|
+
:max="max"
|
|
26
|
+
:step="step"
|
|
27
|
+
:model-value="bound(maxName)"
|
|
28
|
+
@change="
|
|
29
|
+
commit(
|
|
30
|
+
maxName,
|
|
31
|
+
($event.target as HTMLInputElement).valueAsNumber,
|
|
32
|
+
)
|
|
33
|
+
"
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
</fieldset>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup lang="ts">
|
|
40
|
+
import { computed } from 'vue';
|
|
41
|
+
import NumberField from '../../form/number-field/NumberField.vue';
|
|
42
|
+
import type { IndexQuery } from '../index/useIndexQuery.js';
|
|
43
|
+
import { useFilterControl } from './useFilterControl.js';
|
|
44
|
+
|
|
45
|
+
const props = withDefaults(
|
|
46
|
+
defineProps<{
|
|
47
|
+
/** The range name; the bounds are the allowlisted filters `<name>_min` and `<name>_max` (ADR 0009). */
|
|
48
|
+
name: string;
|
|
49
|
+
label: string;
|
|
50
|
+
min?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
step?: number | string;
|
|
53
|
+
/** The index state, for a control rendered outside an Index without a provider above it. */
|
|
54
|
+
state?: IndexQuery<unknown>;
|
|
55
|
+
}>(),
|
|
56
|
+
{ min: undefined, max: undefined, step: undefined, state: undefined },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const minName = computed(() => `${props.name}_min`);
|
|
60
|
+
const maxName = computed(() => `${props.name}_max`);
|
|
61
|
+
const { filters, setFilter } = useFilterControl(
|
|
62
|
+
() => [minName.value, maxName.value],
|
|
63
|
+
props.state,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
function bound(name: string): number | undefined {
|
|
67
|
+
const value = filters.value[name];
|
|
68
|
+
const number = Number(value);
|
|
69
|
+
return value && Number.isFinite(number) ? number : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function commit(name: string, value: number) {
|
|
73
|
+
const next = Number.isFinite(value) ? String(value) : undefined;
|
|
74
|
+
if (next !== filters.value[name]) setFilter(name, next);
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
@import './filters.css';
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SelectField
|
|
3
|
+
:label="label"
|
|
4
|
+
:options="allOptions"
|
|
5
|
+
:model-value="filters[name] ?? ''"
|
|
6
|
+
@update:model-value="setFilter(name, $event || undefined)"
|
|
7
|
+
/>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
import { computed } from 'vue';
|
|
12
|
+
import SelectField from '../../form/select/Select.vue';
|
|
13
|
+
import type { IndexQuery } from '../index/useIndexQuery.js';
|
|
14
|
+
import { useFilterControl } from './useFilterControl.js';
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
/** The allowlisted filter name; the empty option deletes it. */
|
|
19
|
+
name: string;
|
|
20
|
+
label: string;
|
|
21
|
+
options: { value: string; label: string }[];
|
|
22
|
+
/** Label of the first option, which clears the filter. */
|
|
23
|
+
emptyLabel?: string;
|
|
24
|
+
/** The index state, for a control rendered outside an Index without a provider above it. */
|
|
25
|
+
state?: IndexQuery<unknown>;
|
|
26
|
+
}>(),
|
|
27
|
+
{ emptyLabel: 'All', state: undefined },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const { filters, setFilter } = useFilterControl(
|
|
31
|
+
() => [props.name],
|
|
32
|
+
props.state,
|
|
33
|
+
);
|
|
34
|
+
const allOptions = computed(() => [
|
|
35
|
+
{ value: '', label: props.emptyLabel },
|
|
36
|
+
...props.options,
|
|
37
|
+
]);
|
|
38
|
+
</script>
|