@pienter/ui 0.16.0 → 0.18.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 CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.18.0 - 2026-09-16
6
+
7
+ ### Changed
8
+
9
+ - `NumberField` takes `steppers` (default `true`); `false` renders a plain
10
+ number input without the +/− column.
11
+
12
+ ### Fixed
13
+
14
+ - `Checkbox`, `RadioGroup` and `Switch` inputs are their declared em size
15
+ again. The base `input, textarea, select` rule padded them by `--space-s`,
16
+ which inflated a 1.15em checkbox to 35px; all three reset `padding` to 0.
17
+
18
+ ### Added
19
+
20
+ - `FilterCheckbox`, `FilterSelect`, `FilterMultiSelect` and `FilterRange`
21
+ (`@pienter/ui/components/Filter*.vue`), the filter controls an index
22
+ definition declares. Each binds to the index that owns the query — the
23
+ state `Index` provides to its `filters` slot, the one a definition provides
24
+ with `provideIndexQuery`, or an explicit `state` prop — and reads and
25
+ writes only its own filter names; mounted without a state it warns once
26
+ and does nothing. The checkbox writes `'true'` or deletes; the select
27
+ prepends an `emptyLabel` option that deletes; the multi-select is a
28
+ fieldset of checkboxes whose legend reads `Label (n)` with a Clear while
29
+ `n > 0`, optional per-option counts, an optional client-side search, and
30
+ values joined on the comma convention; the range is two NumberFields
31
+ without steppers writing `<name>_min` and `<name>_max` on change, each
32
+ cleared on its own.
33
+ `injectIndexQuery()` joins the composable exports for a control that needs
34
+ the provided state without throwing when there is none.
35
+
36
+ ## 0.17.0 - 2026-09-15
37
+
38
+ ### Added
39
+
40
+ - `splitFilterValues` and `joinFilterValues` in `@pienter/ui/utils/cms`, for
41
+ the multi-value filter convention the backend contract now documents: a
42
+ filter such as `filter[type]=1,2` carries its values as one comma-separated
43
+ string of identifiers or enum tokens, and a numeric range is two ordinary
44
+ allowlisted filters, `<name>_min` and `<name>_max`. Both stay one string per
45
+ `filter[name]`, so `decodeListQuery`, `encodeListQuery`, the URL sync and
46
+ the allowlist are unchanged. `splitFilterValues('')` is `[]` and
47
+ `joinFilterValues([])` is `undefined`, so clearing deletes the parameter
48
+ rather than sending an empty string. See ADR 0009.
49
+ - `useIndexQuery`, `provideIndexQuery` and `useIndexFilters` in
50
+ `@pienter/ui/composables/useIndexQuery`. `useIndexQuery` is the query state
51
+ `Index` has always owned — query, rows, meta, loading, errors, the setters,
52
+ `reload` and `reset` — exported so an index definition can create it, pass
53
+ it to `Index` through the new `state` prop and share it with filter
54
+ controls rendered outside `Index`, such as a page sidebar. `Index` provides
55
+ its state to its subtree, and `useIndexFilters()` gives a control the small
56
+ surface it needs: `filters`, `filterNames`, `loading`, `setFilter`,
57
+ `setFilters`, `clearFilters`. Without `state` nothing changes; `load` and
58
+ `queryOptions` are required unless `state` is passed.
59
+ - `Index` shows its active filters as a chips row between the toolbar and the
60
+ table: one chip per filter value (a comma-separated multi-value filter is
61
+ one chip per value), each with a "Remove <label> <value>" button, and a
62
+ Clear all that empties `filters` only. New `filterLabels` and
63
+ `filterValueLabels` props name the chips; the raw name and value are the
64
+ fallback.
65
+
5
66
  ## 0.16.0 - 2026-09-15
6
67
 
7
68
  ### Breaking
package/CONVENTIONS.md CHANGED
@@ -60,6 +60,7 @@ Current consumers:
60
60
  - `components/form/select/` — `Select` + `Segmented` (the segmented control is a tab-like single-select visual; shares form-primitive scaffolding with Select; will be retrofit in Phase 4)
61
61
  - `components/action/button/` — `Button` + `IconButton` (the icon-only control is a square button. `button.css` shares the base/variant/size/state/focus rules across both via a `.pui-btn, .pui-icon-btn` selector group; a `.pui-btn`-only block holds the label, link variant, block layout, and loading fade that must not reach the icon button; a `.pui-icon-btn` rule adds the square box)
62
62
  - `components/feedback/toast/` — `Toast` + `ToastHost` (Toast renders one notification; ToastHost orchestrates the visible family and owns the shared `pui-toast-region` block in `toast.css`)
63
+ - `components/layout/index-filters/` — `FilterCheckbox` + `FilterSelect` + `FilterMultiSelect` + `FilterRange` (the filter controls of an index definition; one binding composable and the fieldset chrome the two grouped controls share in `filters.css`)
63
64
 
64
65
  Rules:
65
66
 
@@ -1253,59 +1254,63 @@ Each component keeps its own audit next to its source, so a decision and the
1253
1254
  code it governs move in the same diff. Add an entry when you add a component;
1254
1255
  follow the shape of an existing one.
1255
1256
 
1256
- | Component | Audit |
1257
- | ------------ | ------------------------------------------------------------------------------------------ |
1258
- | Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
1259
- | Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
1260
- | AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
1261
- | AppLayout | [`components/layout/app-layout/AUDIT.md`](./components/layout/app-layout/AUDIT.md) |
1262
- | Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1263
- | AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1264
- | Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
1265
- | Breadcrumb | [`components/navigation/breadcrumb/AUDIT.md`](./components/navigation/breadcrumb/AUDIT.md) |
1266
- | Button | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1267
- | Card | [`components/layout/card/AUDIT.md`](./components/layout/card/AUDIT.md) |
1268
- | Checkbox | [`components/form/checkbox/AUDIT.md`](./components/form/checkbox/AUDIT.md) |
1269
- | Collapsible | [`components/layout/collapsible/AUDIT.md`](./components/layout/collapsible/AUDIT.md) |
1270
- | Combobox | [`components/form/combobox/AUDIT.md`](./components/form/combobox/AUDIT.md) |
1271
- | Command | [`components/overlay/command/AUDIT.md`](./components/overlay/command/AUDIT.md) |
1272
- | DataTable | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1273
- | DateInput | [`components/form/date-input/AUDIT.md`](./components/form/date-input/AUDIT.md) |
1274
- | DropdownMenu | [`components/overlay/dropdown-menu/AUDIT.md`](./components/overlay/dropdown-menu/AUDIT.md) |
1275
- | Empty | [`components/display/empty/AUDIT.md`](./components/display/empty/AUDIT.md) |
1276
- | Form | [`components/form/form/AUDIT.md`](./components/form/form/AUDIT.md) |
1277
- | Icon | [`components/display/icon/AUDIT.md`](./components/display/icon/AUDIT.md) |
1278
- | IconButton | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1279
- | InputOTP | [`components/form/input-otp/AUDIT.md`](./components/form/input-otp/AUDIT.md) |
1280
- | Label | [`components/form/label/AUDIT.md`](./components/form/label/AUDIT.md) |
1281
- | Modal | [`components/overlay/modal/AUDIT.md`](./components/overlay/modal/AUDIT.md) |
1282
- | Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
1283
- | NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
1284
- | PageAside | [`components/layout/page-aside/AUDIT.md`](./components/layout/page-aside/AUDIT.md) |
1285
- | Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
1286
- | Panel | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
1287
- | Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
1288
- | Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
1289
- | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1290
- | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1291
- | Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1292
- | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
1293
- | Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
1294
- | Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
1295
- | Skeleton | [`components/feedback/skeleton/AUDIT.md`](./components/feedback/skeleton/AUDIT.md) |
1296
- | Slider | [`components/form/slider/AUDIT.md`](./components/form/slider/AUDIT.md) |
1297
- | Spinner | [`components/feedback/spinner/AUDIT.md`](./components/feedback/spinner/AUDIT.md) |
1298
- | Switch | [`components/form/switch/AUDIT.md`](./components/form/switch/AUDIT.md) |
1299
- | Table | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1300
- | Tabs | [`components/navigation/tabs/AUDIT.md`](./components/navigation/tabs/AUDIT.md) |
1301
- | TagsInput | [`components/form/tags-input/AUDIT.md`](./components/form/tags-input/AUDIT.md) |
1302
- | Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
1303
- | TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
1304
- | Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1305
- | ToastHost | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1306
- | Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
1307
- | ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
1308
- | Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
1257
+ | Component | Audit |
1258
+ | ----------------- | ------------------------------------------------------------------------------------------ |
1259
+ | Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
1260
+ | Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
1261
+ | AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
1262
+ | AppLayout | [`components/layout/app-layout/AUDIT.md`](./components/layout/app-layout/AUDIT.md) |
1263
+ | Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1264
+ | AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1265
+ | Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
1266
+ | Breadcrumb | [`components/navigation/breadcrumb/AUDIT.md`](./components/navigation/breadcrumb/AUDIT.md) |
1267
+ | Button | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1268
+ | Card | [`components/layout/card/AUDIT.md`](./components/layout/card/AUDIT.md) |
1269
+ | Checkbox | [`components/form/checkbox/AUDIT.md`](./components/form/checkbox/AUDIT.md) |
1270
+ | Collapsible | [`components/layout/collapsible/AUDIT.md`](./components/layout/collapsible/AUDIT.md) |
1271
+ | Combobox | [`components/form/combobox/AUDIT.md`](./components/form/combobox/AUDIT.md) |
1272
+ | Command | [`components/overlay/command/AUDIT.md`](./components/overlay/command/AUDIT.md) |
1273
+ | DataTable | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1274
+ | DateInput | [`components/form/date-input/AUDIT.md`](./components/form/date-input/AUDIT.md) |
1275
+ | DropdownMenu | [`components/overlay/dropdown-menu/AUDIT.md`](./components/overlay/dropdown-menu/AUDIT.md) |
1276
+ | Empty | [`components/display/empty/AUDIT.md`](./components/display/empty/AUDIT.md) |
1277
+ | FilterCheckbox | [`components/layout/index-filters/AUDIT.md`](./components/layout/index-filters/AUDIT.md) |
1278
+ | FilterMultiSelect | [`components/layout/index-filters/AUDIT.md`](./components/layout/index-filters/AUDIT.md) |
1279
+ | FilterRange | [`components/layout/index-filters/AUDIT.md`](./components/layout/index-filters/AUDIT.md) |
1280
+ | FilterSelect | [`components/layout/index-filters/AUDIT.md`](./components/layout/index-filters/AUDIT.md) |
1281
+ | Form | [`components/form/form/AUDIT.md`](./components/form/form/AUDIT.md) |
1282
+ | Icon | [`components/display/icon/AUDIT.md`](./components/display/icon/AUDIT.md) |
1283
+ | IconButton | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1284
+ | InputOTP | [`components/form/input-otp/AUDIT.md`](./components/form/input-otp/AUDIT.md) |
1285
+ | Label | [`components/form/label/AUDIT.md`](./components/form/label/AUDIT.md) |
1286
+ | Modal | [`components/overlay/modal/AUDIT.md`](./components/overlay/modal/AUDIT.md) |
1287
+ | Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
1288
+ | NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
1289
+ | PageAside | [`components/layout/page-aside/AUDIT.md`](./components/layout/page-aside/AUDIT.md) |
1290
+ | Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
1291
+ | Panel | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
1292
+ | Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
1293
+ | Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
1294
+ | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1295
+ | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1296
+ | Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1297
+ | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
1298
+ | Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
1299
+ | Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
1300
+ | Skeleton | [`components/feedback/skeleton/AUDIT.md`](./components/feedback/skeleton/AUDIT.md) |
1301
+ | Slider | [`components/form/slider/AUDIT.md`](./components/form/slider/AUDIT.md) |
1302
+ | Spinner | [`components/feedback/spinner/AUDIT.md`](./components/feedback/spinner/AUDIT.md) |
1303
+ | Switch | [`components/form/switch/AUDIT.md`](./components/form/switch/AUDIT.md) |
1304
+ | Table | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1305
+ | Tabs | [`components/navigation/tabs/AUDIT.md`](./components/navigation/tabs/AUDIT.md) |
1306
+ | TagsInput | [`components/form/tags-input/AUDIT.md`](./components/form/tags-input/AUDIT.md) |
1307
+ | Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
1308
+ | TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
1309
+ | Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1310
+ | ToastHost | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1311
+ | Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
1312
+ | ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
1313
+ | Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
1309
1314
 
1310
1315
  ## Form submit response contract
1311
1316
 
@@ -1470,6 +1475,34 @@ value is a key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
1470
1475
  `contacts.search`, `contacts.filter[...]` and so on, so a related index shares
1471
1476
  its parent page's URL without touching the page's own parameters.
1472
1477
 
1478
+ That query state is a composable, `useIndexQuery({ load, queryOptions,
1479
+ syncQuery, searchDebounce })` from `@pienter/ui/composables/useIndexQuery`.
1480
+ Index calls it when given `load` and `queryOptions`; an index definition
1481
+ whose filter controls sit outside Index's subtree (a page sidebar) calls it
1482
+ itself, passes the result as `state`, and shares it with
1483
+ `provideIndexQuery(state)`. A control reads `useIndexFilters()` — `filters`,
1484
+ `filterNames`, `loading`, `setFilter`, `setFilters`, `clearFilters` — from
1485
+ the injected state, so the same control works in the `filters` slot, where
1486
+ Index provides its own state, or anywhere the definition provides one. The
1487
+ `filters` slot stays for the toolbar. Index shows the active filters as a
1488
+ chips row above the table: one chip per value, split on the comma convention
1489
+ (ADR 0009), each with a remove button, plus Clear all, which empties
1490
+ `filters` and nothing else. `filterLabels` and `filterValueLabels` name the
1491
+ chips the way `sortLabels` names the sort select.
1492
+
1493
+ The controls a definition declares are `FilterCheckbox` (`name`, `label`;
1494
+ `'true'` or deleted), `FilterSelect` (`name`, `label`, `options`, an
1495
+ `emptyLabel` option that deletes), `FilterMultiSelect` (`name`, `label`,
1496
+ `options` with an optional `count`, `searchable`; a fieldset whose legend
1497
+ counts the selected values, with a Clear, joining values on the comma
1498
+ convention) and `FilterRange` (`name`, `label`, `min`, `max`, `step`; two
1499
+ NumberFields writing `<name>_min` and `<name>_max` on change). Each binds
1500
+ through `useIndexFilters` to the provided state, or to a `state` prop, and
1501
+ touches only its own names; without a state it warns and does nothing. They
1502
+ sit in the `filters` slot or anywhere the definition provides the state; a
1503
+ column of them is a consumer grid with a spacing token for its gap. Options
1504
+ and counts are the consumer's (`CONTEXT.md`: Facet).
1505
+
1473
1506
  `placement` selects the chrome for the two CMS placements (`CONTEXT.md`, ADR
1474
1507
  0008), exposed as `data-placement` on `.pui-index`. `module` (default) is the
1475
1508
  page itself: an `h1` header above a divider, an unboxed toolbar, the table with
@@ -3,6 +3,7 @@
3
3
  appearance: none;
4
4
  flex: none;
5
5
  margin: 0;
6
+ padding: 0;
6
7
  width: 1.15em;
7
8
  height: 1.15em;
8
9
  border: var(--stroke-md) solid var(--border-clr-strong);
@@ -60,18 +61,18 @@
60
61
  }
61
62
 
62
63
  /* The label is the click target for the box, so it reads as one. */
63
- .pui-field:has(.pui-checkbox) .pui-field__label {
64
+ .pui-field:has(> .pui-checkbox) > .pui-field__label {
64
65
  cursor: pointer;
65
66
  user-select: none;
66
67
  }
67
68
 
68
69
  /* Mute the label + cursor when the input is disabled. */
69
- .pui-field:has(.pui-checkbox:disabled) .pui-field__label {
70
+ .pui-field:has(> .pui-checkbox:disabled) > .pui-field__label {
70
71
  color: var(--text-clr-muted);
71
72
  cursor: not-allowed;
72
73
  }
73
74
 
74
- .pui-field[data-readonly='true']:has(.pui-checkbox) .pui-field__label {
75
+ .pui-field[data-readonly='true']:has(> .pui-checkbox) > .pui-field__label {
75
76
  cursor: default;
76
77
  }
77
78
  }
@@ -35,7 +35,10 @@
35
35
  @input="onInput"
36
36
  @blur="emit('blur')"
37
37
  />
38
- <div v-if="!readonly" class="pui-number-field__steppers">
38
+ <div
39
+ v-if="!readonly && steppers"
40
+ class="pui-number-field__steppers"
41
+ >
39
42
  <button
40
43
  type="button"
41
44
  class="pui-number-field__step"
@@ -95,6 +98,8 @@ const props = withDefaults(
95
98
  disabled?: boolean;
96
99
  /** Read-only: the value shows in the field chrome without steppers, stays focusable and cannot be edited. */
97
100
  readonly?: boolean;
101
+ /** The +/− buttons beside the input; off, the field is a plain number input. */
102
+ steppers?: boolean;
98
103
  hint?: string;
99
104
  errors?: string[];
100
105
  status?: 'error' | 'success';
@@ -112,6 +117,7 @@ const props = withDefaults(
112
117
  required: false,
113
118
  disabled: false,
114
119
  readonly: false,
120
+ steppers: true,
115
121
  hint: undefined,
116
122
  errors: () => [],
117
123
  status: undefined,
@@ -42,8 +42,9 @@
42
42
  margin: 0;
43
43
  }
44
44
 
45
- /* Read-only renders no stepper column, so the input keeps its own corners. */
46
- .pui-field[data-readonly='true'] .pui-number-field__input {
45
+ /* Without a stepper column the input keeps its own corners. */
46
+ .pui-number-field:not(:has(.pui-number-field__steppers))
47
+ .pui-number-field__input {
47
48
  border-top-right-radius: var(--radius-sm);
48
49
  border-bottom-right-radius: var(--radius-sm);
49
50
  }
@@ -66,6 +66,7 @@
66
66
  appearance: none;
67
67
  flex: none;
68
68
  margin: 0;
69
+ padding: 0;
69
70
  width: 1.15em;
70
71
  height: 1.15em;
71
72
  border: var(--stroke-md) solid var(--border-clr-strong);
@@ -3,6 +3,7 @@
3
3
  appearance: none;
4
4
  flex: none;
5
5
  margin: 0;
6
+ padding: 0;
6
7
  width: 2.4em;
7
8
  height: 1.4em;
8
9
  background: var(--border-clr-base);
@@ -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>
@@ -0,0 +1,65 @@
1
+ @layer components {
2
+ /* The two grouped controls share one fieldset chrome; the legend takes
3
+ the small control height so a Clear button beside it lines up. */
4
+ .pui-filter-multi-select,
5
+ .pui-filter-range {
6
+ position: relative;
7
+
8
+ & > .pui-field__label {
9
+ line-height: var(--control-height-sm);
10
+ }
11
+ }
12
+
13
+ .pui-filter-multi-select {
14
+ & > .pui-field__label {
15
+ padding-inline-end: var(--space-2xl);
16
+ }
17
+ }
18
+
19
+ /* A rendered legend is not a grid item, so the Clear sits beside it by
20
+ position rather than by a grid row. */
21
+ .pui-filter-multi-select__clear {
22
+ position: absolute;
23
+ inset-block-start: 0;
24
+ inset-inline-end: 0;
25
+ }
26
+
27
+ /* About six option rows, then the list scrolls; the padding keeps the
28
+ focus ring inside the scrollport. */
29
+ .pui-filter-multi-select__options {
30
+ display: grid;
31
+ align-content: start;
32
+ gap: var(--space-2xs);
33
+ max-block-size: calc(6 * var(--space-l));
34
+ padding: var(--space-3xs);
35
+ overflow-y: auto;
36
+ }
37
+
38
+ .pui-filter-multi-select__option {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: var(--space-2xs);
42
+ min-inline-size: 0;
43
+ margin-block-end: 0;
44
+ font-size: var(--step--1);
45
+ font-weight: var(--fw-regular);
46
+ cursor: pointer;
47
+ }
48
+
49
+ .pui-filter-multi-select__label {
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ }
54
+
55
+ .pui-filter-multi-select__count {
56
+ flex: none;
57
+ color: var(--text-clr-muted);
58
+ }
59
+
60
+ .pui-filter-range__bounds {
61
+ display: grid;
62
+ grid-template-columns: repeat(2, minmax(0, 1fr));
63
+ gap: var(--space-xs);
64
+ }
65
+ }
@@ -0,0 +1,40 @@
1
+ import { computed, watch, type ComputedRef } from 'vue';
2
+ import type { ListQuery } from '../../../utils/cms/index.js';
3
+ import {
4
+ injectIndexQuery,
5
+ useIndexFilters,
6
+ type IndexQuery,
7
+ } from '../index/useIndexQuery.js';
8
+
9
+ export interface FilterBinding {
10
+ filters: ComputedRef<Readonly<ListQuery['filters']>>;
11
+ setFilter: (name: string, value: string | undefined) => void;
12
+ }
13
+
14
+ /** Binds a control to its index; without one it warns and every write is a no-op. */
15
+ export function useFilterControl(
16
+ names: () => readonly string[],
17
+ state?: IndexQuery<unknown>,
18
+ ): FilterBinding {
19
+ const index = state ?? injectIndexQuery();
20
+ if (!index) {
21
+ console.warn(
22
+ `[pui] Filter "${names().join('", "')}" has no index: render it inside an Index or pass the state from useIndexQuery.`,
23
+ );
24
+ return { filters: computed(() => ({})), setFilter: () => {} };
25
+ }
26
+ const { filters, filterNames, setFilter } = useIndexFilters(index);
27
+ let warned = false;
28
+ watch(
29
+ () => names().filter((name) => !filterNames.value.includes(name)),
30
+ (missing) => {
31
+ if (!missing.length || warned) return;
32
+ warned = true;
33
+ console.warn(
34
+ `[pui] Filter "${missing.join('", "')}" is not in queryOptions.filters; the index rejects it.`,
35
+ );
36
+ },
37
+ { immediate: true },
38
+ );
39
+ return { filters, setFilter };
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -86,6 +86,7 @@
86
86
  "./components/Sheet.vue": "./components/overlay/sheet/Sheet.vue",
87
87
  "./components/Tooltip.vue": "./components/overlay/tooltip/Tooltip.vue",
88
88
  "./composables/useDialog": "./composables/useDialog.ts",
89
+ "./composables/useIndexQuery": "./components/layout/index/useIndexQuery.ts",
89
90
  "./composables/useDisclosure": "./composables/useDisclosure.ts",
90
91
  "./composables/useMenu": "./composables/useMenu.ts",
91
92
  "./composables/usePopover": "./composables/usePopover.ts",
@@ -98,6 +99,10 @@
98
99
  "./toast": "./components/feedback/toast/toast.ts",
99
100
  "./utils/a11y": "./utils/a11y/index.ts",
100
101
  "./components/Index.vue": "./components/layout/index/Index.vue",
102
+ "./components/FilterCheckbox.vue": "./components/layout/index-filters/FilterCheckbox.vue",
103
+ "./components/FilterSelect.vue": "./components/layout/index-filters/FilterSelect.vue",
104
+ "./components/FilterMultiSelect.vue": "./components/layout/index-filters/FilterMultiSelect.vue",
105
+ "./components/FilterRange.vue": "./components/layout/index-filters/FilterRange.vue",
101
106
  "./components/RecordForm.vue": "./components/form/record-form/RecordForm.vue",
102
107
  "./components/RecordForm.types": "./components/form/record-form/types.ts",
103
108
  "./components/RecordDetails.vue": "./components/display/record-details/RecordDetails.vue",
@@ -205,6 +205,19 @@ export function encodeListQuery(query: ListQuery): URLSearchParams {
205
205
  return params;
206
206
  }
207
207
 
208
+ /** Values of a multi-value filter, from its comma-separated wire string. */
209
+ export function splitFilterValues(value: string | undefined): string[] {
210
+ return value ? value.split(',').filter(Boolean) : [];
211
+ }
212
+
213
+ /** The wire string of a multi-value filter; `undefined` clears the parameter. */
214
+ export function joinFilterValues(
215
+ values: readonly string[],
216
+ ): string | undefined {
217
+ const joined = values.filter(Boolean).join(',');
218
+ return joined || undefined;
219
+ }
220
+
208
221
  export function cloneRecord<T>(record: T): T {
209
222
  return JSON.parse(JSON.stringify(record)) as T;
210
223
  }