@zeyos/zx 4.3.0 → 4.4.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.
Files changed (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -0,0 +1,964 @@
1
+ // @ts-check
2
+ import { Component } from '../../core/component.js';
3
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
4
+ import { printf } from '../../core/i18n.js';
5
+ import { uid } from '../../core/util.js';
6
+ import { Checklist } from '../checklist/checklist.js';
7
+ import { NumberField, parseNumber } from '../number-field/number-field.js';
8
+
9
+ /**
10
+ * Number of options past which the select filter's `Checklist` gets its search box. Below it the
11
+ * box is a tab stop that filters a list already visible in one glance.
12
+ */
13
+ const SEARCH_THRESHOLD = 8;
14
+
15
+ /** Epoch numbers below this are read as seconds, above it as milliseconds. */
16
+ const SECONDS_CUTOFF = 1e11;
17
+
18
+ /** Step a `float` filter uses when the field metadata names none. */
19
+ const FLOAT_STEP = 0.01;
20
+
21
+ /** @typedef {'from'|'to'} FilterPanelEdge */
22
+ /** @typedef {'text'|'select'|'date'|'number'} FilterPanelKind */
23
+ /** @typedef {'draft'|'live'} FilterPanelMode */
24
+ /** @typedef {'seconds'|'ms'|'iso'|((date: Date, edge: FilterPanelEdge) => unknown)} FilterPanelSerialize */
25
+
26
+ /**
27
+ * @typedef {Object} FilterPanelOption
28
+ * @property {unknown} value Option value, emitted unchanged.
29
+ * @property {string} [label] Visible label; defaults to the value.
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} FilterPanelField
34
+ * @property {string} type One of `text`, `select`, `date`, `date:range`, `int`, `float`, `number`,
35
+ * `int:range`, `float:range`, `number:range`. Any other type renders as a labelled placeholder
36
+ * naming it and is left out of the value.
37
+ * @property {string} [label] Visible filter name; defaults to the field key.
38
+ * @property {Array<FilterPanelOption|Record<string, unknown>|string|number>} [options] Select
39
+ * options. `{value, label}` and `{ID, name}` are both understood, as are bare primitives.
40
+ * @property {number|null} [min] Lowest accepted number, applied to both range edges.
41
+ * @property {number|null} [max] Highest accepted number, applied to both range edges.
42
+ * @property {number} [step] Number increment; defaults to 1 for `int` and 0.01 for `float`.
43
+ * @property {string} [placeholder] Placeholder for the text and number inputs.
44
+ * @property {boolean} [search] Forces the select filter's search box on or off, overriding the
45
+ * eight-option threshold.
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} FilterPanelSpec
50
+ * @property {FilterPanelKind} kind Control family the type belongs to.
51
+ * @property {boolean} range Whether the type carries a `from`/`to` pair.
52
+ * @property {boolean} integer Whether a number type is whole-valued.
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} FilterPanelApplyDetail
57
+ * @property {Record<string, unknown>} value Committed filter values, pruned and serialized.
58
+ */
59
+
60
+ /**
61
+ * @typedef {Object} FilterPanelChangeDetail
62
+ * @property {Record<string, unknown>} value The draft as it would be applied.
63
+ * @property {string|null} field Field that moved, or null when the whole value was replaced.
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} FilterPanelOptions
68
+ * @property {Record<string, FilterPanelField>} [fields={}] Field metadata exactly as a list
69
+ * response publishes it. An empty map is the ordinary case, not an error: it renders `emptyText`.
70
+ * @property {string[]|null} [order=null] Display order by field key; omitted keeps object order.
71
+ * Keys the map does not declare are ignored, and fields the order omits follow in map order.
72
+ * @property {Record<string, unknown>} [value={}] Applied filters. The draft starts as a copy.
73
+ * @property {FilterPanelMode} [mode='draft'] `'draft'` collects edits until `apply()`; `'live'`
74
+ * applies every committed edit.
75
+ * @property {FilterPanelSerialize} [serialize='seconds'] How a date leaves the component. A range's
76
+ * lower bound takes the start of its day and its upper bound the end, so a one-day range matches.
77
+ * @property {string|null} [emptyText=null] Text shown when `fields` is empty; null resolves
78
+ * `filterPanel.empty`.
79
+ * @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
80
+ * @property {(event: CustomEvent<FilterPanelApplyDetail>) => void} [onapply] Apply listener.
81
+ * @property {(event: CustomEvent<FilterPanelChangeDetail>) => void} [onchange] Change listener.
82
+ */
83
+
84
+ /**
85
+ * Declarative filter form built from the field metadata a list endpoint publishes, producing the
86
+ * query object that endpoint expects back.
87
+ *
88
+ * It is neither of the other two filter components: `DataFilter` filters rows that are already in
89
+ * the browser, and `Filter` builds an operator AST. This one turns `{key: {type, label, options}}`
90
+ * into controls and gives back `{key: value}` — pruned to the constraints that are actually set,
91
+ * with `0` counted as one of them.
92
+ *
93
+ * Draft mode is the default because a control that applies on change reflows the list underneath
94
+ * the panel being read, and costs a request per edit. `'live'` is the deliberate opt-in.
95
+ *
96
+ * Each control family commits at its own natural granularity: text filters on every keystroke,
97
+ * dates when the picker produces a complete date, checkboxes immediately, and numbers when
98
+ * `NumberField` commits them (blur, Enter, or a step) rather than half-typed.
99
+ *
100
+ * @fires FilterPanel#apply
101
+ * @fires FilterPanel#change
102
+ * @extends {Component<FilterPanelOptions>}
103
+ */
104
+ export class FilterPanel extends Component {
105
+ static cssName = 'filter-panel';
106
+
107
+ /** @type {FilterPanelOptions} */
108
+ static defaults = {
109
+ fields: {},
110
+ order: null,
111
+ value: {},
112
+ mode: 'draft',
113
+ serialize: 'seconds',
114
+ emptyText: null
115
+ };
116
+
117
+ /** @returns {HTMLElement} Panel root. */
118
+ render() {
119
+ // render() runs inside the base constructor, before class-field initializers would run.
120
+ this._createdRoot = this.el === null;
121
+ this._snapshot = this._createdRoot ? null : snapshotTarget(/** @type {Element} */ (this.el));
122
+ this._restored = false;
123
+ this._entries = [];
124
+ this._children = [];
125
+ this._writing = false;
126
+ this._draft = {};
127
+ this._applied = {};
128
+ this._appliedDraft = '';
129
+
130
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
131
+ this.el = root;
132
+
133
+ this._fields = orderFields(this.options.fields, this.options.order);
134
+ this._draft = toDraft(this.options.value, this.options.fields);
135
+ this._applied = serializeFilterValue(this._draft, this.options.fields, this.options.serialize);
136
+ this._appliedDraft = draftKey(this._draft);
137
+
138
+ const children = [];
139
+ if (this._fields.length === 0) {
140
+ children.push(h('p', {
141
+ ref: 'empty',
142
+ class: 'zx-filter-panel__empty',
143
+ role: 'status'
144
+ }, this.options.emptyText == null
145
+ ? this._message('filterPanel.empty', 'No filters are available here.')
146
+ : String(this.options.emptyText)));
147
+ } else {
148
+ const host = h('div', { ref: 'fields', class: 'zx-filter-panel__fields' });
149
+ for (const { key, field, spec } of this._fields) host.append(this._buildField(key, field, spec));
150
+ children.push(host, this._buildActions());
151
+ }
152
+
153
+ root.replaceChildren(...children);
154
+ root.setAttribute('role', 'group');
155
+ root.setAttribute('aria-label', this._message('filterPanel.label', 'Filters'));
156
+ root.setAttribute('data-mode', this.options.mode === 'live' ? 'live' : 'draft');
157
+
158
+ // Enter anywhere in the form is "apply", the keyboard equivalent of the button. Buttons are
159
+ // excluded: Enter on one already activates it, and applying twice would emit twice.
160
+ this.listen(root, 'keydown', (event) => {
161
+ const key = /** @type {KeyboardEvent} */ (event).key;
162
+ if (key !== 'Enter' || this.options.mode === 'live') return;
163
+ const target = /** @type {HTMLElement|null} */ (event.target);
164
+ if (!target?.matches?.('input:not([type="checkbox"]):not([type="radio"])')) return;
165
+ event.preventDefault();
166
+ this.apply();
167
+ });
168
+
169
+ this._syncActions();
170
+ return root;
171
+ }
172
+
173
+ /**
174
+ * Returns the applied value: only the constraints that are set, with dates serialized through
175
+ * `serialize`. Unknown field types are never part of it.
176
+ * @returns {Record<string, unknown>}
177
+ */
178
+ getValue() {
179
+ return cloneValue(this._applied);
180
+ }
181
+
182
+ /**
183
+ * Replaces both the applied value and the draft the controls show. Accepts what `getValue()`
184
+ * returns as well as raw input values, so a value that survived a URL round-trip goes back in.
185
+ * @param {Record<string, unknown>} value Filter values keyed by field.
186
+ * @returns {this}
187
+ * @fires FilterPanel#change
188
+ */
189
+ setValue(value) {
190
+ this._draft = toDraft(value, this.options.fields);
191
+ this._commit(this._draftValue());
192
+ this._writeControls();
193
+ this._syncActions();
194
+ this.emit('change', { value: this.getValue(), field: null });
195
+ return this;
196
+ }
197
+
198
+ /**
199
+ * Commits the draft. In live mode every committed edit already does this; calling it directly is
200
+ * how a host forces a reload with the values that are on screen.
201
+ * @returns {this}
202
+ * @fires FilterPanel#apply
203
+ */
204
+ apply() {
205
+ const value = this._draftValue();
206
+ this._commit(value);
207
+ this._syncActions(value);
208
+ this.emit('apply', { value: this.getValue() });
209
+ return this;
210
+ }
211
+
212
+ /**
213
+ * Restores the controls to the `value` the panel was constructed with **without committing**, so
214
+ * the filters in force stay in force. This is the Cancel half of a sheet: the panel goes back to
215
+ * showing what is being filtered by. Contrast `clear()`, which commits. In live mode there is no
216
+ * uncommitted draft to hold back, so there it applies like any other edit.
217
+ * @returns {this}
218
+ * @fires FilterPanel#change
219
+ * @fires FilterPanel#apply
220
+ */
221
+ reset() {
222
+ return this._replaceDraft(toDraft(this.options.value, this.options.fields));
223
+ }
224
+
225
+ /**
226
+ * Empties every control **and commits**, in both modes. "Clear the filters" has one meaning, and
227
+ * leaving a list filtered behind a panel showing no filters is not it — the records look missing
228
+ * and nothing on screen explains why. Contrast `reset()`, which restores the controls without
229
+ * committing. The price is one unfiltered load at the moment the button is pressed, which is
230
+ * also the feedback that says the filters are gone.
231
+ * @returns {this}
232
+ * @fires FilterPanel#change
233
+ * @fires FilterPanel#apply
234
+ */
235
+ clear() {
236
+ return this._replaceDraft(toDraft({}, this.options.fields), { commit: true });
237
+ }
238
+
239
+ /** Destroys the composed controls and puts an enhanced target back as found. @returns {void} */
240
+ destroy() {
241
+ for (const child of this._children) child.destroy();
242
+ this._children = [];
243
+ if (!this._restored && !this._createdRoot) {
244
+ restoreTarget(/** @type {Element} */ (this.el), this._snapshot);
245
+ this._restored = true;
246
+ }
247
+ super.destroy();
248
+ }
249
+
250
+ /** @returns {HTMLElement} The actions row. */
251
+ _buildActions() {
252
+ const actions = h('div', { ref: 'actions', class: 'zx-filter-panel__actions' });
253
+ if (this.options.mode !== 'live') {
254
+ const apply = h('button', {
255
+ ref: 'apply',
256
+ class: 'zx-btn zx-filter-panel__apply',
257
+ type: 'button',
258
+ dataset: { kind: 'primary', size: 'md' }
259
+ }, h('span', { class: 'zx-btn__label' }, this._message('filterPanel.apply', 'Apply')));
260
+ this.listen(apply, 'click', () => this.apply());
261
+ actions.append(apply);
262
+ }
263
+ const clear = h('button', {
264
+ ref: 'clear',
265
+ class: 'zx-btn zx-filter-panel__clear',
266
+ type: 'button',
267
+ dataset: { kind: 'ghost', size: 'md' }
268
+ }, h('span', { class: 'zx-btn__label' }, this._message('filterPanel.clear', 'Clear all')));
269
+ this.listen(clear, 'click', () => this.clear());
270
+ actions.append(clear);
271
+ return actions;
272
+ }
273
+
274
+ /**
275
+ * @param {string} key Field key.
276
+ * @param {FilterPanelField} field Field metadata.
277
+ * @param {FilterPanelSpec|null} spec Resolved type, or null when the type is unknown.
278
+ * @returns {HTMLElement}
279
+ */
280
+ _buildField(key, field, spec) {
281
+ const label = String(field.label ?? key);
282
+ const wrapper = h('div', {
283
+ class: 'zx-filter-panel__field',
284
+ dataset: { field: key, type: String(field.type ?? '') }
285
+ });
286
+
287
+ if (!spec) {
288
+ // Never dropped silently and never guessed at: the placeholder names the type it could not
289
+ // render, so the gap is visible to whoever configured the instance.
290
+ wrapper.setAttribute('data-state', 'unsupported');
291
+ wrapper.append(
292
+ h('span', { class: 'zx-filter-panel__label' }, label),
293
+ h('p', { class: 'zx-filter-panel__unsupported', role: 'note' },
294
+ this._message('filterPanel.unsupported', 'Unsupported filter type: %1', String(field.type ?? '')))
295
+ );
296
+ return wrapper;
297
+ }
298
+
299
+ if (spec.kind === 'select') wrapper.append(this._buildSelect(key, field, label));
300
+ else if (spec.kind === 'text') wrapper.append(...this._buildText(key, field, label));
301
+ else if (spec.kind === 'date') wrapper.append(...this._buildDate(key, field, label, spec));
302
+ else wrapper.append(...this._buildNumber(key, field, label, spec));
303
+ return wrapper;
304
+ }
305
+
306
+ /**
307
+ * @param {string} key Field key.
308
+ * @param {FilterPanelField} field Field metadata.
309
+ * @param {string} label Visible filter name.
310
+ * @returns {Element} The checklist root.
311
+ */
312
+ _buildSelect(key, field, label) {
313
+ const options = normalizeOptions(field.options);
314
+ const chosen = /** @type {unknown[]} */ (this._draft[key] ?? []);
315
+ // `Checklist` renders its own heading and points `aria-labelledby` at it, so the group is named
316
+ // after the filter rather than after the component; a second label above it would print both.
317
+ const checklist = new Checklist(null, {
318
+ items: options.map((option) => ({
319
+ ...option,
320
+ on: chosen.some((value) => sameValue(value, option.value))
321
+ })),
322
+ valueKey: 'value',
323
+ labelKey: 'label',
324
+ search: typeof field.search === 'boolean' ? field.search : options.length > SEARCH_THRESHOLD,
325
+ height: 220,
326
+ msg: { 'checklist.label': label }
327
+ });
328
+ checklist.on('change', () => this._edited(key, checklist.getValues()));
329
+ this._isolate(checklist);
330
+ this._children.push(checklist);
331
+ this._entries.push({ key, write: (value) => checklist.setValues(/** @type {unknown[]} */ (value ?? [])) });
332
+ return checklist.toElement();
333
+ }
334
+
335
+ /**
336
+ * @param {string} key Field key.
337
+ * @param {FilterPanelField} field Field metadata.
338
+ * @param {string} label Visible filter name.
339
+ * @returns {Element[]}
340
+ */
341
+ _buildText(key, field, label) {
342
+ const id = uid('zx-filter-panel-text');
343
+ const input = h('input', {
344
+ id,
345
+ class: 'zx-filter-panel__input',
346
+ type: 'search',
347
+ autocomplete: 'off',
348
+ placeholder: String(field.placeholder ?? ''),
349
+ value: String(this._draft[key] ?? '')
350
+ });
351
+ this.listen(input, 'input', () => this._edited(key, /** @type {HTMLInputElement} */ (input).value));
352
+ this._entries.push({
353
+ key,
354
+ write: (value) => { /** @type {HTMLInputElement} */ (input).value = value == null ? '' : String(value); }
355
+ });
356
+ return [h('label', { class: 'zx-filter-panel__label', for: id }, label), input];
357
+ }
358
+
359
+ /**
360
+ * @param {string} key Field key.
361
+ * @param {FilterPanelField} field Field metadata.
362
+ * @param {string} label Visible filter name.
363
+ * @param {FilterPanelSpec} spec Resolved type.
364
+ * @returns {Element[]}
365
+ */
366
+ _buildDate(key, field, label, spec) {
367
+ if (!spec.range) {
368
+ const id = uid('zx-filter-panel-date');
369
+ const input = this._dateInput(String(this._draft[key] ?? ''), { id });
370
+ this.listen(input, 'change', () => this._edited(key, /** @type {HTMLInputElement} */ (input).value));
371
+ this._entries.push({
372
+ key,
373
+ write: (value) => { /** @type {HTMLInputElement} */ (input).value = value == null ? '' : String(value); }
374
+ });
375
+ return [h('label', { class: 'zx-filter-panel__label', for: id }, label), input];
376
+ }
377
+
378
+ const labelId = uid('zx-filter-panel-label');
379
+ const draft = /** @type {{from?: unknown, to?: unknown}} */ (this._draft[key] ?? {});
380
+ const inputs = {};
381
+ const edges = /** @type {FilterPanelEdge[]} */ (['from', 'to']).map((edge) => {
382
+ const id = uid('zx-filter-panel-date');
383
+ const input = this._dateInput(String(draft[edge] ?? ''), {
384
+ id,
385
+ // One `<label for>` cannot name two inputs, so each edge carries the composed name. The
386
+ // visible word is part of it, which is what keeps the spoken and the printed label agreed.
387
+ ariaLabel: this._edgeName(label, edge)
388
+ });
389
+ inputs[edge] = input;
390
+ this.listen(input, 'change', () => this._editedEdge(key, edge, /** @type {HTMLInputElement} */ (input).value));
391
+ return h('div', { class: 'zx-filter-panel__edge' },
392
+ h('label', { class: 'zx-filter-panel__edge-label', for: id }, this._edgeLabel(edge)),
393
+ input);
394
+ });
395
+
396
+ this._entries.push({
397
+ key,
398
+ write: (value) => {
399
+ const next = /** @type {{from?: unknown, to?: unknown}} */ (value ?? {});
400
+ for (const edge of ['from', 'to']) {
401
+ inputs[edge].value = next[edge] == null ? '' : String(next[edge]);
402
+ }
403
+ }
404
+ });
405
+ return [
406
+ h('span', { class: 'zx-filter-panel__label', id: labelId }, label),
407
+ h('div', { class: 'zx-filter-panel__range', role: 'group', ariaLabelledby: labelId }, edges)
408
+ ];
409
+ }
410
+
411
+ /**
412
+ * @param {string} value Initial `YYYY-MM-DD` value.
413
+ * @param {Record<string, unknown>} props Extra properties.
414
+ * @returns {HTMLInputElement}
415
+ */
416
+ _dateInput(value, props) {
417
+ // A native date input, deliberately: the value has to round-trip through a URL and the platform
418
+ // picker is the one keyboard path nobody has to test.
419
+ return /** @type {HTMLInputElement} */ (h('input', {
420
+ class: 'zx-filter-panel__input',
421
+ type: 'date',
422
+ autocomplete: 'off',
423
+ value,
424
+ ...props
425
+ }));
426
+ }
427
+
428
+ /**
429
+ * @param {string} key Field key.
430
+ * @param {FilterPanelField} field Field metadata.
431
+ * @param {string} label Visible filter name.
432
+ * @param {FilterPanelSpec} spec Resolved type.
433
+ * @returns {Element[]}
434
+ */
435
+ _buildNumber(key, field, label, spec) {
436
+ const labelId = uid('zx-filter-panel-label');
437
+ if (!spec.range) {
438
+ const control = this._numberField(field, spec, label, /** @type {number|null} */ (this._draft[key] ?? null));
439
+ control.on('change', () => this._edited(key, control.get()));
440
+ this._entries.push({ key, write: (value) => control.set(/** @type {number|null} */ (value ?? null), { silent: true }) });
441
+ return [
442
+ h('span', { class: 'zx-filter-panel__label', id: labelId }, label),
443
+ control.toElement()
444
+ ];
445
+ }
446
+
447
+ const draft = /** @type {{from?: unknown, to?: unknown}} */ (this._draft[key] ?? {});
448
+ const controls = {};
449
+ const edges = /** @type {FilterPanelEdge[]} */ (['from', 'to']).map((edge) => {
450
+ const control = this._numberField(field, spec, this._edgeName(label, edge),
451
+ /** @type {number|null} */ (draft[edge] ?? null));
452
+ controls[edge] = control;
453
+ control.on('change', () => this._editedEdge(key, edge, control.get()));
454
+ return h('div', { class: 'zx-filter-panel__edge' },
455
+ h('span', { class: 'zx-filter-panel__edge-label' }, this._edgeLabel(edge)),
456
+ control.toElement());
457
+ });
458
+
459
+ this._entries.push({
460
+ key,
461
+ write: (value) => {
462
+ const next = /** @type {{from?: unknown, to?: unknown}} */ (value ?? {});
463
+ for (const edge of ['from', 'to']) {
464
+ controls[edge].set(/** @type {number|null} */ (next[edge] ?? null), { silent: true });
465
+ }
466
+ }
467
+ });
468
+ return [
469
+ h('span', { class: 'zx-filter-panel__label', id: labelId }, label),
470
+ h('div', { class: 'zx-filter-panel__range', role: 'group', ariaLabelledby: labelId }, edges)
471
+ ];
472
+ }
473
+
474
+ /**
475
+ * @param {FilterPanelField} field Field metadata.
476
+ * @param {FilterPanelSpec} spec Resolved type.
477
+ * @param {string} name Accessible name for the spinbutton.
478
+ * @param {number|null} value Initial value.
479
+ * @returns {NumberField}
480
+ */
481
+ _numberField(field, spec, name, value) {
482
+ // `NumberField` snaps onto its step, so a float filter needs a fractional one or it would round
483
+ // every amount to a whole number. Field metadata that names a `step` always wins.
484
+ const step = Number.isFinite(Number(field.step)) && Number(field.step) > 0
485
+ ? Number(field.step)
486
+ : (spec.integer ? 1 : FLOAT_STEP);
487
+ const control = new NumberField(null, {
488
+ value,
489
+ min: field.min == null ? null : Number(field.min),
490
+ max: field.max == null ? null : Number(field.max),
491
+ step,
492
+ precision: spec.integer ? 0 : null,
493
+ placeholder: String(field.placeholder ?? ''),
494
+ label: name
495
+ });
496
+ this._isolate(control);
497
+ this._children.push(control);
498
+ return control;
499
+ }
500
+
501
+ /** @param {FilterPanelEdge} edge Range edge. @returns {string} */
502
+ _edgeLabel(edge) {
503
+ return edge === 'from' ? this._message('filterPanel.from', 'From') : this._message('filterPanel.to', 'To');
504
+ }
505
+
506
+ /** @param {string} label Filter name. @param {FilterPanelEdge} edge Range edge. @returns {string} */
507
+ _edgeName(label, edge) {
508
+ return edge === 'from'
509
+ ? this._message('filterPanel.rangeFrom', '%1 from', label)
510
+ : this._message('filterPanel.rangeTo', '%1 to', label);
511
+ }
512
+
513
+ /** @param {string} key Field key. @param {unknown} value Raw draft value. @returns {void} */
514
+ _edited(key, value) {
515
+ if (this._writing) return;
516
+ this._draft[key] = value;
517
+ this._afterEdit(key);
518
+ }
519
+
520
+ /**
521
+ * @param {string} key Field key.
522
+ * @param {FilterPanelEdge} edge Range edge.
523
+ * @param {unknown} value Raw edge value.
524
+ * @returns {void}
525
+ */
526
+ _editedEdge(key, edge, value) {
527
+ if (this._writing) return;
528
+ const current = this._draft[key];
529
+ const range = current !== null && typeof current === 'object' && !Array.isArray(current)
530
+ ? { .../** @type {Record<string, unknown>} */ (current) }
531
+ : {};
532
+ range[edge] = value;
533
+ this._draft[key] = range;
534
+ this._afterEdit(key);
535
+ }
536
+
537
+ /** @param {string} key Field key. @returns {void} */
538
+ _afterEdit(key) {
539
+ const value = this._draftValue();
540
+ this._syncActions(value);
541
+ this.emit('change', { value, field: key });
542
+ if (this.options.mode === 'live') this.apply();
543
+ }
544
+
545
+ /**
546
+ * Keeps a composed control's own bubbling DOM events off the panel root. `Checklist` and
547
+ * `NumberField` both emit `zx-change`, which is a name this panel uses with a different detail,
548
+ * so a host listening on the panel root would otherwise see two shapes of one event. They stay
549
+ * available on the control's own root, exactly where that control put them.
550
+ * @param {Component} child Composed control.
551
+ * @returns {void}
552
+ */
553
+ _isolate(child) {
554
+ for (const type of ['zx-change', 'zx-input']) {
555
+ this.listen(child.el, type, (event) => event.stopPropagation());
556
+ }
557
+ }
558
+
559
+ /**
560
+ * @param {Record<string, unknown>} draft Replacement draft.
561
+ * @param {{commit?: boolean}} [options={}] Set `commit` to apply the replacement as well.
562
+ * @returns {this}
563
+ */
564
+ _replaceDraft(draft, { commit = false } = {}) {
565
+ this._draft = draft;
566
+ this._writeControls();
567
+ const value = this._draftValue();
568
+ // Live mode holds no uncommitted draft, so every replacement commits there whatever the caller
569
+ // asked for. The commit lands before `change` is emitted so a listener reading the panel back
570
+ // sees the state it is being told about; the `apply` event still follows `change`.
571
+ const committing = commit || this.options.mode === 'live';
572
+ if (committing) this._commit(value);
573
+ this._syncActions(value);
574
+ this.emit('change', { value, field: null });
575
+ if (committing) this.emit('apply', { value: this.getValue() });
576
+ return this;
577
+ }
578
+
579
+ /** @param {Record<string, unknown>} value The draft value becoming the applied one. @returns {void} */
580
+ _commit(value) {
581
+ this._applied = value;
582
+ this._appliedDraft = draftKey(this._draft);
583
+ }
584
+
585
+ /** @returns {Record<string, unknown>} The draft, serialized and pruned. */
586
+ _draftValue() {
587
+ return serializeFilterValue(this._draft, this.options.fields, this.options.serialize);
588
+ }
589
+
590
+ /** Pushes the draft back into the controls without re-entering the edit path. @returns {void} */
591
+ _writeControls() {
592
+ this._writing = true;
593
+ try {
594
+ for (const entry of this._entries) entry.write(this._draft[entry.key]);
595
+ } finally {
596
+ this._writing = false;
597
+ }
598
+ }
599
+
600
+ /** @param {Record<string, unknown>} [value] The draft value, when it is already at hand. @returns {void} */
601
+ _syncActions(value = this._draftValue()) {
602
+ const active = Object.keys(value).length > 0;
603
+ const clear = /** @type {HTMLButtonElement|undefined} */ (this.refs.clear);
604
+ // Disabled rather than hidden: a control that disappears moves everything below it while the
605
+ // panel is being read.
606
+ if (clear) clear.disabled = !active;
607
+ if (this.options.mode === 'live' || this._fields.length === 0) return;
608
+ const dirty = draftKey(this._draft) !== this._appliedDraft;
609
+ if (dirty) this.el.setAttribute('data-state', 'dirty');
610
+ else this.el.removeAttribute('data-state');
611
+ }
612
+
613
+ /**
614
+ * Resolves a message through the host translator, falling back to the built-in English text.
615
+ * @param {string} key Message key.
616
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
617
+ * @param {...unknown} args Interpolation values.
618
+ * @returns {string}
619
+ */
620
+ _message(key, fallback, ...args) {
621
+ const message = this.msg(key, ...args);
622
+ return message === key ? printf(fallback, args) : message;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Committed filter values, ready to send to the list endpoint. In draft mode this is the Apply
628
+ * button or `apply()`; in live mode it is every committed edit.
629
+ * @event FilterPanel#apply
630
+ * @type {CustomEvent<FilterPanelApplyDetail>}
631
+ */
632
+
633
+ /**
634
+ * Any change to the draft, with the field that moved (null when the whole value was replaced).
635
+ * `value` is the draft as it would be applied.
636
+ * @event FilterPanel#change
637
+ * @type {CustomEvent<FilterPanelChangeDetail>}
638
+ */
639
+
640
+ /**
641
+ * Resolves a declared field type. Returns null for a type the panel cannot render, which is what
642
+ * makes an unknown type a visible placeholder rather than a guess.
643
+ * @param {unknown} type Declared type.
644
+ * @returns {FilterPanelSpec|null}
645
+ */
646
+ export function parseFieldType(type) {
647
+ switch (String(type ?? '').trim().toLowerCase()) {
648
+ case 'text': return { kind: 'text', range: false, integer: false };
649
+ case 'select': return { kind: 'select', range: false, integer: false };
650
+ case 'date': return { kind: 'date', range: false, integer: false };
651
+ case 'date:range': return { kind: 'date', range: true, integer: false };
652
+ case 'int': return { kind: 'number', range: false, integer: true };
653
+ case 'float':
654
+ case 'number': return { kind: 'number', range: false, integer: false };
655
+ case 'int:range': return { kind: 'number', range: true, integer: true };
656
+ case 'float:range':
657
+ case 'number:range': return { kind: 'number', range: true, integer: false };
658
+ default: return null;
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Whether a value is a constraint rather than an absent one. `0` is a value; `''`, `null`,
664
+ * `undefined`, `NaN`, `[]` and `{from: null, to: null}` are not.
665
+ * @param {unknown} value Candidate value.
666
+ * @returns {boolean}
667
+ */
668
+ export function isActiveValue(value) {
669
+ if (value === null || value === undefined) return false;
670
+ if (value instanceof Date) return !Number.isNaN(value.getTime());
671
+ if (Array.isArray(value)) return value.some((entry) => isActiveValue(entry));
672
+ if (typeof value === 'string') return value.trim() !== '';
673
+ if (typeof value === 'number') return Number.isFinite(value);
674
+ if (typeof value === 'boolean') return value;
675
+ if (typeof value === 'object') return Object.values(value).some((entry) => isActiveValue(entry));
676
+ return true;
677
+ }
678
+
679
+ /**
680
+ * Drops every entry that is not a constraint, and every inactive edge of a range, so an
681
+ * open-ended range emits only the bound it has.
682
+ * @param {Record<string, unknown>} value Candidate values keyed by field.
683
+ * @returns {Record<string, unknown>}
684
+ */
685
+ export function pruneValue(value) {
686
+ /** @type {Record<string, unknown>} */
687
+ const result = {};
688
+ if (!isPlainObject(value)) return result;
689
+ for (const [key, entry] of Object.entries(value)) {
690
+ if (isPlainObject(entry)) {
691
+ /** @type {Record<string, unknown>} */
692
+ const range = {};
693
+ for (const [edge, edgeValue] of Object.entries(entry)) {
694
+ if (isActiveValue(edgeValue)) range[edge] = edgeValue;
695
+ }
696
+ if (Object.keys(range).length > 0) result[key] = range;
697
+ continue;
698
+ }
699
+ if (isActiveValue(entry)) result[key] = entry;
700
+ }
701
+ return result;
702
+ }
703
+
704
+ /**
705
+ * Converts anything a calendar day can arrive as — a `YYYY-MM-DD` string, an ISO timestamp, a
706
+ * `Date`, Unix seconds or milliseconds — into the local calendar day a native date input holds.
707
+ * Numbers below 1e11 are read as seconds, which is the boundary between the two epochs in use.
708
+ * @param {unknown} value Candidate day.
709
+ * @returns {string} `YYYY-MM-DD`, or an empty string when there is no day in it.
710
+ */
711
+ export function toDateInput(value) {
712
+ if (value === null || value === undefined || value === '') return '';
713
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : localDay(value);
714
+ if (typeof value === 'number') {
715
+ if (!Number.isFinite(value)) return '';
716
+ return localDay(new Date(Math.abs(value) < SECONDS_CUTOFF ? value * 1000 : value));
717
+ }
718
+ const text = String(value).trim();
719
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
720
+ if (/^-?\d+(\.\d+)?$/.test(text)) return toDateInput(Number(text));
721
+ const parsed = new Date(text);
722
+ return Number.isNaN(parsed.getTime()) ? '' : localDay(parsed);
723
+ }
724
+
725
+ /**
726
+ * Serializes one calendar day at one edge of a range. The `from` edge takes the first instant of
727
+ * its local day and the `to` edge the last, so a range whose ends name the same day still matches
728
+ * everything recorded on it.
729
+ * @param {unknown} day Calendar day in any form `toDateInput()` accepts.
730
+ * @param {FilterPanelEdge} edge Which end of the range the day is.
731
+ * @param {FilterPanelSerialize} [serialize='seconds'] Output format, or a function receiving the
732
+ * edge instant as a `Date`.
733
+ * @returns {unknown} The serialized day, or null when there is no day.
734
+ */
735
+ export function serializeDate(day, edge, serialize = 'seconds') {
736
+ const text = toDateInput(day);
737
+ if (!text) return null;
738
+ const [year, month, date] = text.split('-').map(Number);
739
+ const instant = edge === 'to'
740
+ ? new Date(year, month - 1, date, 23, 59, 59, 999)
741
+ : new Date(year, month - 1, date, 0, 0, 0, 0);
742
+ if (typeof serialize === 'function') return serialize(instant, edge);
743
+ if (serialize === 'ms') return instant.getTime();
744
+ if (serialize === 'iso') return instant.toISOString();
745
+ return Math.floor(instant.getTime() / 1000);
746
+ }
747
+
748
+ /**
749
+ * Normalizes declared select options. `{value, label}`, `{ID, name}`, and bare primitives all
750
+ * arrive from one instance or another.
751
+ * @param {unknown} options Declared options.
752
+ * @returns {Array<{value: unknown, label: string}>}
753
+ */
754
+ export function normalizeOptions(options) {
755
+ if (!Array.isArray(options)) return [];
756
+ const normalized = [];
757
+ for (const option of options) {
758
+ if (option !== null && typeof option === 'object') {
759
+ const source = /** @type {Record<string, unknown>} */ (option);
760
+ const value = 'value' in source ? source.value : source.ID;
761
+ if (value === undefined) continue;
762
+ const label = source.label ?? source.name ?? source.title;
763
+ normalized.push({ value, label: label == null ? String(value) : String(label) });
764
+ continue;
765
+ }
766
+ if (option === undefined) continue;
767
+ normalized.push({ value: option, label: String(option) });
768
+ }
769
+ return normalized;
770
+ }
771
+
772
+ /**
773
+ * Converts incoming values into the internal draft shape the controls hold: trimmed text, canonical
774
+ * option values, `YYYY-MM-DD` days, and finite numbers. Fields with an unknown type are left out.
775
+ * Running it on a draft returns the same draft, so it is safe on either shape.
776
+ * @param {Record<string, unknown>|null|undefined} value Incoming values keyed by field.
777
+ * @param {Record<string, FilterPanelField>} fields Field metadata.
778
+ * @returns {Record<string, unknown>}
779
+ */
780
+ export function toDraft(value, fields) {
781
+ const source = isPlainObject(value) ? value : {};
782
+ /** @type {Record<string, unknown>} */
783
+ const draft = {};
784
+ for (const [key, declared] of Object.entries(fields ?? {})) {
785
+ const field = /** @type {FilterPanelField} */ (declared ?? {});
786
+ const spec = parseFieldType(field.type);
787
+ if (!spec) continue;
788
+ const raw = source[key];
789
+ if (spec.kind === 'text') {
790
+ draft[key] = raw == null ? '' : String(raw).trim();
791
+ } else if (spec.kind === 'select') {
792
+ draft[key] = selectionOf(raw, normalizeOptions(field.options));
793
+ } else if (spec.kind === 'date') {
794
+ draft[key] = spec.range
795
+ ? { from: toDateInput(readEdge(raw, 'from')), to: toDateInput(readEdge(raw, 'to')) }
796
+ : toDateInput(raw);
797
+ } else {
798
+ draft[key] = spec.range
799
+ ? { from: toNumberValue(readEdge(raw, 'from'), spec), to: toNumberValue(readEdge(raw, 'to'), spec) }
800
+ : toNumberValue(raw, spec);
801
+ }
802
+ }
803
+ return draft;
804
+ }
805
+
806
+ /**
807
+ * Turns values into the query object a list endpoint is sent: only the constraints that are set,
808
+ * dates serialized at the right edge, unknown field types excluded.
809
+ * @param {Record<string, unknown>|null|undefined} value Values keyed by field, raw or drafted.
810
+ * @param {Record<string, FilterPanelField>} fields Field metadata.
811
+ * @param {FilterPanelSerialize} [serialize='seconds'] Date output format.
812
+ * @returns {Record<string, unknown>}
813
+ */
814
+ export function serializeFilterValue(value, fields, serialize = 'seconds') {
815
+ const draft = toDraft(value, fields);
816
+ /** @type {Record<string, unknown>} */
817
+ const result = {};
818
+ for (const [key, declared] of Object.entries(fields ?? {})) {
819
+ const spec = parseFieldType(/** @type {FilterPanelField} */ (declared ?? {}).type);
820
+ if (!spec) continue;
821
+ const raw = draft[key];
822
+
823
+ if (spec.kind === 'text') {
824
+ if (raw !== '') result[key] = raw;
825
+ continue;
826
+ }
827
+ if (spec.kind === 'select') {
828
+ const chosen = /** @type {unknown[]} */ (raw);
829
+ if (chosen.length > 0) result[key] = chosen.slice();
830
+ continue;
831
+ }
832
+ if (spec.kind === 'date') {
833
+ if (!spec.range) {
834
+ // A single date names a day, so it serializes as that day's first instant.
835
+ if (raw) result[key] = serializeDate(raw, 'from', serialize);
836
+ continue;
837
+ }
838
+ const days = /** @type {{from: string, to: string}} */ (raw);
839
+ /** @type {Record<string, unknown>} */
840
+ const range = {};
841
+ if (days.from) range.from = serializeDate(days.from, 'from', serialize);
842
+ if (days.to) range.to = serializeDate(days.to, 'to', serialize);
843
+ if (Object.keys(range).length > 0) result[key] = range;
844
+ continue;
845
+ }
846
+ if (!spec.range) {
847
+ if (raw !== null) result[key] = raw;
848
+ continue;
849
+ }
850
+ const numbers = /** @type {{from: number|null, to: number|null}} */ (raw);
851
+ /** @type {Record<string, unknown>} */
852
+ const range = {};
853
+ if (numbers.from !== null) range.from = numbers.from;
854
+ if (numbers.to !== null) range.to = numbers.to;
855
+ if (Object.keys(range).length > 0) result[key] = range;
856
+ }
857
+ return result;
858
+ }
859
+
860
+ /**
861
+ * Field keys in display order: the ones `order` names first, in that order, then everything the
862
+ * map declares that the order left out.
863
+ * @param {Record<string, FilterPanelField>} fields Field metadata.
864
+ * @param {string[]|null|undefined} order Requested display order.
865
+ * @returns {Array<{key: string, field: FilterPanelField, spec: FilterPanelSpec|null}>}
866
+ */
867
+ function orderFields(fields, order) {
868
+ const declared = isPlainObject(fields) ? fields : {};
869
+ const keys = Object.keys(declared);
870
+ const requested = Array.isArray(order) ? order.filter((key) => keys.includes(key)) : [];
871
+ const seen = new Set(requested);
872
+ return [...requested, ...keys.filter((key) => !seen.has(key))]
873
+ .map((key) => ({
874
+ key,
875
+ field: /** @type {FilterPanelField} */ (declared[key] ?? {}),
876
+ spec: parseFieldType(declared[key]?.type)
877
+ }));
878
+ }
879
+
880
+ /**
881
+ * The option values a raw selection refers to, in option order. Matching is loose so a value that
882
+ * came back as a string from a URL still finds the numeric option it names.
883
+ * @param {unknown} raw Raw selection.
884
+ * @param {Array<{value: unknown, label: string}>} options Normalized options.
885
+ * @returns {unknown[]}
886
+ */
887
+ function selectionOf(raw, options) {
888
+ const requested = Array.isArray(raw) ? raw : (isActiveValue(raw) ? [raw] : []);
889
+ if (options.length === 0) return requested.filter((value) => isActiveValue(value));
890
+ return options
891
+ .filter((option) => requested.some((value) => sameValue(value, option.value)))
892
+ .map((option) => option.value);
893
+ }
894
+
895
+ /**
896
+ * Reads one end of a range. `{from, to}` is the declared shape; a two-element array and a bare
897
+ * scalar (read as the lower bound) are the two spellings that reach the component in practice.
898
+ * @param {unknown} raw Raw range value.
899
+ * @param {FilterPanelEdge} edge Range edge.
900
+ * @returns {unknown}
901
+ */
902
+ function readEdge(raw, edge) {
903
+ if (raw === null || raw === undefined) return null;
904
+ if (Array.isArray(raw)) return (edge === 'from' ? raw[0] : raw[1]) ?? null;
905
+ if (isPlainObject(raw)) return edge in raw ? raw[edge] : null;
906
+ return edge === 'from' ? raw : null;
907
+ }
908
+
909
+ /**
910
+ * @param {unknown} raw Candidate number.
911
+ * @param {FilterPanelSpec} spec Resolved type.
912
+ * @returns {number|null}
913
+ */
914
+ function toNumberValue(raw, spec) {
915
+ const parsed = parseNumber(raw);
916
+ if (parsed === null) return null;
917
+ return spec.integer ? Math.trunc(parsed) : parsed;
918
+ }
919
+
920
+ /** @param {Date} date Instant. @returns {string} Local `YYYY-MM-DD`. */
921
+ function localDay(date) {
922
+ const month = String(date.getMonth() + 1).padStart(2, '0');
923
+ const day = String(date.getDate()).padStart(2, '0');
924
+ return `${date.getFullYear()}-${month}-${day}`;
925
+ }
926
+
927
+ /**
928
+ * A type predicate, not a boolean: without the `value is` form TypeScript cannot narrow
929
+ * the `unknown` it is handed, and `raw[edge]` / `{...entry}` fail declaration emit.
930
+ * @param {unknown} value Candidate.
931
+ * @returns {value is Record<string, unknown>}
932
+ */
933
+ function isPlainObject(value) {
934
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
935
+ }
936
+
937
+ /** @param {unknown} left Left value. @param {unknown} right Right value. @returns {boolean} */
938
+ function sameValue(left, right) {
939
+ if (Object.is(left, right)) return true;
940
+ if (left === null || left === undefined || right === null || right === undefined) return false;
941
+ return String(left) === String(right);
942
+ }
943
+
944
+ /** @param {Record<string, unknown>} value Emitted value. @returns {Record<string, unknown>} */
945
+ function cloneValue(value) {
946
+ /** @type {Record<string, unknown>} */
947
+ const copy = {};
948
+ for (const [key, entry] of Object.entries(value)) {
949
+ if (Array.isArray(entry)) copy[key] = entry.slice();
950
+ else if (isPlainObject(entry)) copy[key] = { ...entry };
951
+ else copy[key] = entry;
952
+ }
953
+ return copy;
954
+ }
955
+
956
+ /**
957
+ * A comparable spelling of the draft. Draft values are only strings, finite numbers, null, arrays
958
+ * and `{from, to}` pairs, so JSON is a faithful identity for them.
959
+ * @param {Record<string, unknown>} draft Draft values.
960
+ * @returns {string}
961
+ */
962
+ function draftKey(draft) {
963
+ return JSON.stringify(Object.keys(draft).sort().map((key) => [key, draft[key]]));
964
+ }