@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.
@@ -19,7 +19,7 @@
19
19
  <div
20
20
  v-if="
21
21
  !invalidQuery &&
22
- (searchable || $slots.filters || queryOptions.sorts.length)
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="queryOptions.sorts.length" class="pui-index__sort">
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 type {
174
- ApiError,
175
- ListQuery,
176
- ListLoader,
177
- QueryOptions,
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 { useIndex } from './useIndex.js';
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: ListLoader<T>;
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
- queryOptions: QueryOptions;
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
- const unsupportedSortColumns = computed(() =>
272
- props.columns.filter(
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
- `[pui] Index sortable columns must be allowed by queryOptions.sorts: ${unsupported.map((column) => column.key).join(', ')}.`,
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
- validateSortColumns(unsupportedSortColumns.value);
285
- watch(unsupportedSortColumns, validateSortColumns);
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
- } = useIndex<T>(props);
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
- ...props.queryOptions.sorts.flatMap((key) => {
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 <= (props.queryOptions.maxPageSize ?? 100),
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 { computed, inject, onScopeDispose, ref, shallowRef, watch } from 'vue';
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 Options<T> {
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: boolean | string;
25
- searchDebounce: number;
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 useIndex<T>(props: Options<T>) {
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 = () => props.syncQuery !== false;
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>