@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 +61 -0
- package/CONVENTIONS.md +86 -53
- package/components/form/checkbox/checkbox.css +4 -3
- package/components/form/number-field/NumberField.vue +7 -1
- 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/package.json +6 -1
- package/utils/cms/index.ts +13 -0
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
|
|
1257
|
-
|
|
|
1258
|
-
| Accordion
|
|
1259
|
-
| Alert
|
|
1260
|
-
| AlertDialog
|
|
1261
|
-
| AppLayout
|
|
1262
|
-
| Avatar
|
|
1263
|
-
| AvatarStack
|
|
1264
|
-
| Badge
|
|
1265
|
-
| Breadcrumb
|
|
1266
|
-
| Button
|
|
1267
|
-
| Card
|
|
1268
|
-
| Checkbox
|
|
1269
|
-
| Collapsible
|
|
1270
|
-
| Combobox
|
|
1271
|
-
| Command
|
|
1272
|
-
| DataTable
|
|
1273
|
-
| DateInput
|
|
1274
|
-
| DropdownMenu
|
|
1275
|
-
| Empty
|
|
1276
|
-
|
|
|
1277
|
-
|
|
|
1278
|
-
|
|
|
1279
|
-
|
|
|
1280
|
-
|
|
|
1281
|
-
|
|
|
1282
|
-
|
|
|
1283
|
-
|
|
|
1284
|
-
|
|
|
1285
|
-
|
|
|
1286
|
-
|
|
|
1287
|
-
|
|
|
1288
|
-
|
|
|
1289
|
-
|
|
|
1290
|
-
|
|
|
1291
|
-
|
|
|
1292
|
-
|
|
|
1293
|
-
|
|
|
1294
|
-
|
|
|
1295
|
-
|
|
|
1296
|
-
|
|
|
1297
|
-
|
|
|
1298
|
-
|
|
|
1299
|
-
|
|
|
1300
|
-
|
|
|
1301
|
-
|
|
|
1302
|
-
|
|
|
1303
|
-
|
|
|
1304
|
-
|
|
|
1305
|
-
|
|
|
1306
|
-
|
|
|
1307
|
-
|
|
|
1308
|
-
|
|
|
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
|
|
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
|
-
/*
|
|
46
|
-
.pui-field
|
|
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
|
}
|
|
@@ -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>
|
|
@@ -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.
|
|
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",
|
package/utils/cms/index.ts
CHANGED
|
@@ -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
|
}
|