@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.
- package/CHANGELOG.md +266 -1
- package/README.md +7 -3
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +195 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
- package/dist/types/components/button/button.d.ts +8 -0
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +12 -4
- package/dist/types/components/card-view/record-card.d.ts +53 -5
- package/dist/types/components/code-editor/code-editor.d.ts +160 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/dialog/dialog.d.ts +29 -0
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/file-item/file-item.d.ts +234 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
- package/dist/types/components/launcher/launcher.d.ts +19 -12
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +4 -37
- package/dist/types/components/message/message.d.ts +7 -0
- package/dist/types/components/modal/modal.d.ts +48 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/select.d.ts +4 -0
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
- package/dist/types/components/tabbox/tabbox.d.ts +7 -0
- package/dist/types/components/table/table.d.ts +144 -0
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/core/icons.d.ts +17 -0
- package/dist/types/index.d.ts +13 -1
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/zx-zeyos.esm.js +651 -88
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +6266 -3900
- package/dist/zx.esm.js +8487 -1805
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +8487 -1805
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1811 -247
- package/docs/llms.md +345 -40
- package/docs/llms.txt +11 -7
- package/package.json +1 -2
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +599 -0
- package/src/components/activity-list/activity-list.css +64 -0
- package/src/components/activity-list/activity-list.js +375 -0
- package/src/components/app-rail/app-rail.css +227 -19
- package/src/components/app-sidebar/app-sidebar.css +414 -24
- package/src/components/app-sidebar/app-sidebar.js +65 -5
- package/src/components/button/button.css +8 -0
- package/src/components/button/button.js +6 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +41 -13
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +212 -9
- package/src/components/card-view/card-view.js +32 -16
- package/src/components/card-view/record-card.js +98 -21
- package/src/components/code-editor/code-editor.css +67 -0
- package/src/components/code-editor/code-editor.js +288 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +80 -3
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/dialog/dialog.js +88 -66
- package/src/components/dropdown/dropdown.js +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +371 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +199 -45
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/kanban-view/kanban-card.js +239 -0
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +674 -36
- package/src/components/kanban-view/kanban-view.js +1762 -240
- package/src/components/launcher/launcher.js +59 -27
- package/src/components/list-toolbar/list-toolbar.css +189 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/menu-button/menu-button.css +80 -3
- package/src/components/menu-button/menu-button.js +21 -37
- package/src/components/message/message.css +14 -12
- package/src/components/message/message.js +13 -2
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +141 -0
- package/src/components/navigation-bar/navigation-bar.css +77 -2
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/panel/panel.js +13 -2
- package/src/components/search/search.js +17 -5
- package/src/components/select/field-adapter.js +4 -1
- package/src/components/select/select.js +3 -0
- package/src/components/sort-control/sort-control.css +17 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +213 -0
- package/src/components/stat-tile/stat-tile.js +590 -0
- package/src/components/tabbox/tabbox.js +12 -1
- package/src/components/table/table.css +51 -2
- package/src/components/table/table.js +298 -19
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +3 -1
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/component.js +8 -3
- package/src/core/icons.js +74 -5
- package/src/index.js +33 -3
- package/src/internal/app-rail.js +19 -1
- package/src/internal/menu-items.js +142 -0
- package/styles/print.css +192 -0
- package/styles/zx.css +11 -0
- 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
|
+
}
|