@zeyos/zx 5.0.0 → 5.2.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 +164 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- package/dist/types/components/calendar/calendar.d.ts +67 -5
- package/dist/types/components/card-view/card-view.d.ts +7 -4
- package/dist/types/components/card-view/record-card.d.ts +11 -0
- package/dist/types/components/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- package/dist/types/components/file-list/file-list.d.ts +35 -3
- package/dist/types/components/filter/filter.d.ts +127 -3
- package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
- package/dist/types/components/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- package/dist/types/components/module-bar/module-bar.d.ts +395 -0
- package/dist/types/components/pagination/pagination.d.ts +17 -3
- package/dist/types/components/select/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- package/dist/types/components/view/record-view.d.ts +59 -2
- package/dist/types/components/view-settings/view-settings.d.ts +879 -0
- package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
- package/dist/types/core/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- package/dist/types/zeyos/launcher.d.ts +9 -0
- package/dist/types/zeyos/module-bar.d.ts +176 -0
- package/dist/types/zeyos/query.d.ts +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1417 -134
- package/dist/zx.esm.js +6986 -830
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +6986 -830
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +794 -17
- package/docs/llms.md +503 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +5 -5
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- package/src/components/module-bar/module-bar.css +486 -0
- package/src/components/module-bar/module-bar.js +1074 -0
- package/src/components/multi-value-editor/multi-value-editor.css +1 -1
- package/src/components/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- package/src/components/view/record-view.css +1 -1
- package/src/components/view/record-view.js +178 -18
- package/src/components/view-settings/view-settings.css +285 -0
- package/src/components/view-settings/view-settings.js +1830 -0
- package/src/components/workspace-switcher/workspace-switcher.css +188 -0
- package/src/components/workspace-switcher/workspace-switcher.js +463 -0
- package/src/core/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- package/styles/zx.css +3 -0
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
font-size: var(--zx-text-sm);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/* The native controls only: a themed picker in a value cell (Select, TagPicker) brings its own. */
|
|
44
45
|
.zx-filter__logic,
|
|
45
46
|
.zx-filter__field,
|
|
46
47
|
.zx-filter__operator,
|
|
47
|
-
.zx-filter__value input,
|
|
48
|
-
.zx-filter__value select {
|
|
48
|
+
.zx-filter__value-control > input,
|
|
49
|
+
.zx-filter__value > select {
|
|
49
50
|
min-block-size: var(--zx-control-height);
|
|
50
51
|
border: 1px solid var(--zx-color-border-control);
|
|
51
52
|
border-radius: var(--zx-control-radius);
|
|
@@ -171,3 +172,86 @@
|
|
|
171
172
|
.zx-filter[data-layout="compact"] .zx-filter__value { grid-column: 1 / 3; grid-row: 2; }
|
|
172
173
|
.zx-filter[data-layout="compact"] .zx-filter__condition > [data-filter-action="remove"] { grid-column: 3; grid-row: 1 / 3; }
|
|
173
174
|
}
|
|
175
|
+
|
|
176
|
+
/* A choice's glyph in a themed picker: the field's type, or a value's mark, in the muted ink. */
|
|
177
|
+
.zx-filter__choice {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: var(--zx-space-2);
|
|
181
|
+
min-inline-size: 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.zx-filter__choice-icon {
|
|
185
|
+
display: inline-flex;
|
|
186
|
+
flex: none;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
min-inline-size: var(--zx-space-4);
|
|
189
|
+
color: var(--zx-color-text-muted);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.zx-filter__choice-label {
|
|
193
|
+
overflow: hidden;
|
|
194
|
+
text-overflow: ellipsis;
|
|
195
|
+
white-space: nowrap;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/*
|
|
199
|
+
* Compact rows say how they combine: the group's "and" or "or" sits in the gap above every row
|
|
200
|
+
* after the first, on the row's leading edge, so the logic reads down the list. The gap grows to
|
|
201
|
+
* hold it; the word is positioned out of the row's grid, so the row's own columns are untouched.
|
|
202
|
+
*/
|
|
203
|
+
.zx-filter[data-layout="compact"] .zx-filter__children {
|
|
204
|
+
gap: var(--zx-space-4);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.zx-filter[data-layout="compact"] .zx-filter__children > li {
|
|
208
|
+
position: relative;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.zx-filter__connector {
|
|
212
|
+
position: absolute;
|
|
213
|
+
inset-block-start: calc(var(--zx-space-4) / -2);
|
|
214
|
+
inset-inline-start: var(--zx-space-3);
|
|
215
|
+
z-index: 1;
|
|
216
|
+
display: inline-flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
/* A step taller than the gap it sits in, so it overlaps both rows' edges a little. */
|
|
219
|
+
block-size: calc(var(--zx-space-4) + var(--zx-space-1));
|
|
220
|
+
translate: 0 -50%;
|
|
221
|
+
margin: 0;
|
|
222
|
+
border: 1px solid var(--zx-color-border);
|
|
223
|
+
border-radius: var(--zx-radius-full);
|
|
224
|
+
background: var(--zx-color-bg-chrome);
|
|
225
|
+
padding: 0 var(--zx-space-2);
|
|
226
|
+
color: var(--zx-color-text-muted);
|
|
227
|
+
font: inherit;
|
|
228
|
+
font-size: var(--zx-text-xs);
|
|
229
|
+
font-weight: var(--zx-weight-medium);
|
|
230
|
+
line-height: 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
button.zx-filter__connector {
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
button.zx-filter__connector:hover {
|
|
238
|
+
border-color: var(--zx-color-border-strong);
|
|
239
|
+
color: var(--zx-color-text);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
button.zx-filter__connector:focus-visible {
|
|
243
|
+
outline-color: transparent;
|
|
244
|
+
box-shadow: var(--zx-focus-ring);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
248
|
+
button.zx-filter__connector {
|
|
249
|
+
transition: border-color var(--zx-dur-fast) var(--zx-ease), color var(--zx-dur-fast) var(--zx-ease);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@media (forced-colors: active) {
|
|
254
|
+
.zx-filter__connector {
|
|
255
|
+
border-color: CanvasText;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -3,6 +3,7 @@ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
|
3
3
|
import { icon } from '../../core/icons.js';
|
|
4
4
|
import { printf } from '../../core/i18n.js';
|
|
5
5
|
import { Select } from '../select/select.js';
|
|
6
|
+
import { TagPicker } from '../tag-picker/tag-picker.js';
|
|
6
7
|
import {
|
|
7
8
|
cloneFilterAst, cloneFilterValue, emptyFilterAst, filterCondition, filterGroup, filterOperators,
|
|
8
9
|
parseFilterAst, validateFilterAst
|
|
@@ -16,7 +17,10 @@ import {
|
|
|
16
17
|
* @property {'text'|'number'|'money'|'date'|'datetime'|'boolean'|'enum'|'status'|'priority'|'country'|'currency'|'unit'|'entity'|'tags'|'custom'} type Editor/operator type.
|
|
17
18
|
* @property {string[]} [operators] Explicit allowlisted operator IDs.
|
|
18
19
|
* @property {string} [defaultOperator] Initial operator ID.
|
|
19
|
-
* @property {
|
|
20
|
+
* @property {string|Node} [icon] Glyph beside the field's name in a `searchable` field picker, an
|
|
21
|
+
* icon name or a node; a native `<select>` cannot show one.
|
|
22
|
+
* @property {Array<{value:string|number|boolean,label:string,icon?:Node|string,color?:string}>} [choices] Static choices; a
|
|
23
|
+
* choice's `icon` shows beside it in a `searchable` value picker.
|
|
20
24
|
* @property {(context:{query:string,signal:AbortSignal,conditionId:string,field:FilterField})=>Promise<Array<{value:string|number|boolean,label:string}>>|Array<{value:string|number|boolean,label:string}>} [loadChoices] Async choices.
|
|
21
25
|
* @property {(target:Element, context:{value:unknown,setValue:(value:unknown)=>void,field:FilterField,conditionId:string})=>({destroy?:()=>void}|void)} [editor] Custom editor.
|
|
22
26
|
* @property {number} [minQuery=0] Async minimum query length.
|
|
@@ -30,8 +34,10 @@ import {
|
|
|
30
34
|
* @property {boolean} [allowGroups=true] Whether nested groups may be authored.
|
|
31
35
|
* @property {number} [maxDepth=3] Maximum nested group depth.
|
|
32
36
|
* @property {number} [maxConditions=50] Maximum condition count.
|
|
33
|
-
* @property {boolean} [searchable=false] Searchable field, operator and single-choice controls
|
|
34
|
-
*
|
|
37
|
+
* @property {boolean} [searchable=false] Searchable field, operator and single-choice controls; a many-valued
|
|
38
|
+
* operator on a field with static choices gets a `TagPicker`.
|
|
39
|
+
* @property {'standard'|'compact'} [layout='standard'] Condition layout; compact rows adapt to container width and
|
|
40
|
+
* show the group's "and"/"or" between them, a click on it switching the group's logic.
|
|
35
41
|
* @property {'and'|'or'|null} [rootLogic=null] Restrict root logic without rewriting incompatible saved expressions.
|
|
36
42
|
* @property {boolean} [showApply=true] Show the internal Apply button.
|
|
37
43
|
* @property {boolean} [showRootActions=true] Show Add controls inside the root group as well as the footer.
|
|
@@ -59,6 +65,19 @@ import {
|
|
|
59
65
|
* @property {(label: string, position: number) => string} valueAt Accessible name for one end of a pair.
|
|
60
66
|
*/
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Operators whose value is a relative span rather than a pair of dates. The model gives them
|
|
70
|
+
* `pair` arity, so their value is the pair `[amount, unit]` — a positive whole number and one of
|
|
71
|
+
* {@link RELATIVE_DATE_UNITS} — never two dates.
|
|
72
|
+
*/
|
|
73
|
+
const RELATIVE_DATE_OPERATORS = new Set(['withinLast', 'withinNext']);
|
|
74
|
+
|
|
75
|
+
/** Units a relative date span may be counted in, in the order the editor offers them. */
|
|
76
|
+
export const RELATIVE_DATE_UNITS = Object.freeze(['days', 'weeks', 'months']);
|
|
77
|
+
|
|
78
|
+
/** English unit words the relative-date editor falls back to. */
|
|
79
|
+
const RELATIVE_UNIT_WORDS = Object.freeze({ days: 'days', weeks: 'weeks', months: 'months' });
|
|
80
|
+
|
|
62
81
|
/** English text the value builders fall back to when they are called without a component. */
|
|
63
82
|
const BUILT_IN_VALUE_MESSAGES = Object.freeze({
|
|
64
83
|
choose: 'Choose…',
|
|
@@ -223,6 +242,17 @@ export class Filter extends Component {
|
|
|
223
242
|
/** Returns semantic validity and errors. @returns {{valid:boolean,errors:Array<Record<string,string>>}} */
|
|
224
243
|
validate() {
|
|
225
244
|
const result = validateFilterAst(this._value, this._fields, this._operators);
|
|
245
|
+
for (const node of conditionNodes(this._value.root)) {
|
|
246
|
+
if (!isRelativeDateOperator(this._operator(node.operator))) continue;
|
|
247
|
+
// The model only checks that both halves of the pair are filled; a span also needs a
|
|
248
|
+
// positive whole amount and a known unit, and "Enter both values" would misdescribe it.
|
|
249
|
+
if (relativeDateValueError(node.value) === null) continue;
|
|
250
|
+
const message = this._message('filter.relativeInvalid', 'Enter a whole number of days, weeks or months');
|
|
251
|
+
const existing = result.errors.find((candidate) => candidate.nodeId === node.id && candidate.code === 'value');
|
|
252
|
+
if (existing) existing.message = message;
|
|
253
|
+
else result.errors.push({ nodeId: node.id, code: 'value', message });
|
|
254
|
+
result.valid = false;
|
|
255
|
+
}
|
|
226
256
|
const error = rootLogicError(this._value, this.options.rootLogic);
|
|
227
257
|
if (error) { result.valid = false; result.errors.push(error); }
|
|
228
258
|
return result;
|
|
@@ -310,7 +340,11 @@ export class Filter extends Component {
|
|
|
310
340
|
/** @param {any} group @param {number} depth @param {boolean} root @returns {HTMLElement} */
|
|
311
341
|
_renderGroup(group, depth, root) {
|
|
312
342
|
const children = h('ol', { class: 'zx-filter__children' },
|
|
313
|
-
group.children.map((node
|
|
343
|
+
group.children.map((node, index) => {
|
|
344
|
+
const item = node.kind === 'group' ? this._renderGroup(node, depth + 1, false) : this._renderCondition(node);
|
|
345
|
+
if (index > 0 && this.options.layout === 'compact') item.prepend(this._connector(group, root));
|
|
346
|
+
return item;
|
|
347
|
+
}));
|
|
314
348
|
const logic = h('select', {
|
|
315
349
|
class: 'zx-filter__logic', dataset: { filterLogic: '', nodeId: group.id },
|
|
316
350
|
ariaLabel: root ? this._message('filter.rootLogic', 'Root filter logic')
|
|
@@ -330,19 +364,56 @@ export class Filter extends Component {
|
|
|
330
364
|
actionButton('add', group.id, 'plus', this._message('filter.addFilter', 'Add filter'), { disabled: this._blocked() }),
|
|
331
365
|
this.options.allowGroups && depth < Number(this.options.maxDepth)
|
|
332
366
|
? actionButton('add-group', group.id, 'folder', this._message('filter.addSubgroup', 'Add subgroup'), { disabled: this._blocked() }) : null,
|
|
333
|
-
|
|
367
|
+
// Named on the button too: the compact layout hides the word and leaves the glyph.
|
|
368
|
+
root ? null : actionButton('remove', group.id, 'trash', this._message('filter.removeGroup', 'Remove group'),
|
|
369
|
+
{ disabled: this._blocked(), ariaLabel: this._message('filter.removeGroup', 'Remove group') })
|
|
334
370
|
);
|
|
335
371
|
return h(root ? 'fieldset' : 'li', {
|
|
336
372
|
class: 'zx-filter__group', dataset: { nodeId: group.id, depth: String(depth) }
|
|
337
373
|
}, root ? legend : h('fieldset', {}, legend, children, controls), root ? children : null, root && this.options.showRootActions ? controls : null);
|
|
338
374
|
}
|
|
339
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The word between two rows of a compact group — "and" or "or" — so the logic reads down the
|
|
378
|
+
* list rather than only from the group's legend. A click switches the group's logic. It is a
|
|
379
|
+
* pointer shortcut kept out of the Tab order: the legend's select is the same control, reached
|
|
380
|
+
* once per group rather than once per row. A restricted root shows the word without the switch.
|
|
381
|
+
* @param {any} group Group the rows belong to.
|
|
382
|
+
* @param {boolean} root Whether it is the root group.
|
|
383
|
+
* @returns {HTMLElement}
|
|
384
|
+
*/
|
|
385
|
+
_connector(group, root) {
|
|
386
|
+
const { word, name } = filterConnectorText(group.logic, (key, fallback, ...args) => this._message(key, fallback, ...args));
|
|
387
|
+
if (this._blocked() || (root && this.options.rootLogic)) {
|
|
388
|
+
return h('span', { class: 'zx-filter__connector', ariaHidden: 'true', dataset: { connector: group.id } }, word);
|
|
389
|
+
}
|
|
390
|
+
return h('button', {
|
|
391
|
+
type: 'button', class: 'zx-filter__connector', tabindex: '-1',
|
|
392
|
+
dataset: { filterAction: 'toggle-logic', nodeId: group.id, connector: group.id },
|
|
393
|
+
ariaLabel: name
|
|
394
|
+
}, word);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Rewrites a group's connectors after its logic changed through the legend's select, which is
|
|
399
|
+
* not redrawn — the reader's focus is on it.
|
|
400
|
+
* @param {any} group
|
|
401
|
+
* @returns {void}
|
|
402
|
+
*/
|
|
403
|
+
_syncConnectors(group) {
|
|
404
|
+
const { word, name } = filterConnectorText(group.logic, (key, fallback, ...args) => this._message(key, fallback, ...args));
|
|
405
|
+
for (const connector of this.el.querySelectorAll(`[data-connector="${cssEscape(group.id)}"]`)) {
|
|
406
|
+
connector.textContent = word;
|
|
407
|
+
if (connector.tagName === 'BUTTON') connector.setAttribute('aria-label', name);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
340
411
|
/** @param {any} node @returns {HTMLElement} */
|
|
341
412
|
_renderCondition(node) {
|
|
342
413
|
const field = this._field(node.field);
|
|
343
414
|
const operators = this._operatorsFor(field);
|
|
344
415
|
const operator = this._operator(node.operator);
|
|
345
|
-
const fieldChoices = this._fields.map((candidate) => ({value: candidate.id, label: candidate.label}));
|
|
416
|
+
const fieldChoices = this._fields.map((candidate) => ({value: candidate.id, label: candidate.label, icon: candidate.icon}));
|
|
346
417
|
if (node.field && !field) fieldChoices.push({value: node.field, label: this._unavailable(node.field)});
|
|
347
418
|
const operatorChoices = operators.map((candidate) => ({value: candidate.id, label: candidate.label}));
|
|
348
419
|
if (node.operator && !operators.some((candidate) => candidate.id === node.operator)) operatorChoices.push({value: node.operator, label: this._unavailable(node.operator)});
|
|
@@ -392,8 +463,12 @@ export class Filter extends Component {
|
|
|
392
463
|
return host;
|
|
393
464
|
}
|
|
394
465
|
const messages = this._valueMessages();
|
|
466
|
+
if (isRelativeDateOperator(operator)) return this._relativeDateEditor(node, field);
|
|
395
467
|
const values = operator.arity === 'pair' ? (Array.isArray(node.value) ? node.value : [null, null]) : [node.value];
|
|
396
468
|
if (operator.arity === 'many') {
|
|
469
|
+
if (this.options.searchable && Array.isArray(field.choices) && field.choices.length) {
|
|
470
|
+
return h('div', { class: 'zx-filter__value', dataset: { arity: 'many' } }, this._tagPicker(field, node, messages));
|
|
471
|
+
}
|
|
397
472
|
const control = choiceSelect(field, node, values[0], true, this._blocked(), 0, messages);
|
|
398
473
|
if (control) return h('div', { class: 'zx-filter__value' }, control);
|
|
399
474
|
return h('div', { class: 'zx-filter__value' }, valueInput(field, node.id, Array.isArray(node.value) ? node.value.join(', ') : '', 0, this._blocked(), true, messages));
|
|
@@ -409,6 +484,34 @@ export class Filter extends Component {
|
|
|
409
484
|
return h('div', { class: 'zx-filter__value', dataset: { arity: operator.arity } }, controls);
|
|
410
485
|
}
|
|
411
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The editor for a relative date span (`withinLast`, `withinNext`): a whole-number amount and a
|
|
489
|
+
* unit select. Both controls write the pair `[amount, unit]`; a value that is not such a pair —
|
|
490
|
+
* two dates saved by an older editor, say — shows as an empty amount in days.
|
|
491
|
+
* @param {any} node Condition.
|
|
492
|
+
* @param {FilterField} field Date field.
|
|
493
|
+
* @returns {HTMLElement}
|
|
494
|
+
*/
|
|
495
|
+
_relativeDateEditor(node, field) {
|
|
496
|
+
const span = readRelativeDateValue(node.value);
|
|
497
|
+
const amount = h('input', {
|
|
498
|
+
type: 'number', min: '1', step: '1', inputMode: 'numeric',
|
|
499
|
+
value: span.amount === null ? '' : String(span.amount),
|
|
500
|
+
dataset: { filterValue: '', filterRelative: 'amount', nodeId: node.id, valueIndex: '0' },
|
|
501
|
+
ariaLabel: this._message('filter.relativeAmount', '%1: number of units', field.label),
|
|
502
|
+
disabled: this._blocked()
|
|
503
|
+
});
|
|
504
|
+
const unit = h('select', {
|
|
505
|
+
dataset: { filterValue: '', filterRelative: 'unit', nodeId: node.id, valueIndex: '1' },
|
|
506
|
+
ariaLabel: this._message('filter.relativeUnit', '%1: unit', field.label),
|
|
507
|
+
disabled: this._blocked()
|
|
508
|
+
}, RELATIVE_DATE_UNITS.map((id) => h('option', { value: id, selected: id === span.unit },
|
|
509
|
+
this._message(`filter.unit.${id}`, RELATIVE_UNIT_WORDS[id]))));
|
|
510
|
+
return h('div', { class: 'zx-filter__value', dataset: { arity: 'relative' } },
|
|
511
|
+
h('span', { class: 'zx-filter__value-control' }, amount),
|
|
512
|
+
h('span', { class: 'zx-filter__value-control' }, unit));
|
|
513
|
+
}
|
|
514
|
+
|
|
412
515
|
/**
|
|
413
516
|
* Owns a themed picker and defers mutation until Select finishes its selection event.
|
|
414
517
|
* @param {Array<{value:unknown,label:string}>} items Choices.
|
|
@@ -421,9 +524,17 @@ export class Filter extends Component {
|
|
|
421
524
|
*/
|
|
422
525
|
_searchSelect(items, value, label, nodeId, part, changed) {
|
|
423
526
|
const version = this._renderVersion;
|
|
527
|
+
// Choices with a glyph show it in the list and before the chosen value; without, plain text.
|
|
528
|
+
const glyphs = items.some((item) => choiceGlyph(item) !== null) ? {
|
|
529
|
+
renderItem: (item) => h('span', { class: 'zx-filter__choice' },
|
|
530
|
+
choiceGlyph(item) ?? h('span', { class: 'zx-filter__choice-icon' }),
|
|
531
|
+
h('span', { class: 'zx-filter__choice-label' }, String(item.label ?? ''))),
|
|
532
|
+
renderValueAdornment: (item) => choiceGlyph(item)
|
|
533
|
+
} : {};
|
|
424
534
|
const select = new Select(null, {
|
|
425
535
|
items, valueKey: 'value', labelKey: 'label', value, label, filter: 'local',
|
|
426
536
|
disabled: this._blocked(), placeholder: this._message('filter.choosePart', 'Choose %1…', part),
|
|
537
|
+
...glyphs,
|
|
427
538
|
onchange: ({detail}) => queueMicrotask(() => {
|
|
428
539
|
if (!this._disposed && version === this._renderVersion && this._canMutate()) changed(detail.value);
|
|
429
540
|
})
|
|
@@ -435,6 +546,40 @@ export class Filter extends Component {
|
|
|
435
546
|
return /** @type {HTMLElement} */ (select.el);
|
|
436
547
|
}
|
|
437
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Owns a themed multi-value picker for a many-valued operator on a field with static choices:
|
|
551
|
+
* the chosen values as removable tags, the rest in a searchable list. It writes the value without
|
|
552
|
+
* redrawing the editor, so the list stays open while the reader picks several.
|
|
553
|
+
* @param {FilterField} field Field with `choices`.
|
|
554
|
+
* @param {any} node Condition.
|
|
555
|
+
* @param {FilterValueMessages} messages Resolved value-builder text.
|
|
556
|
+
* @returns {HTMLElement} Picker root.
|
|
557
|
+
*/
|
|
558
|
+
_tagPicker(field, node, messages) {
|
|
559
|
+
// A language the host set on this editor applies to the picker too.
|
|
560
|
+
const own = /** @type {Record<string, any>} */ (this.options);
|
|
561
|
+
const lang = own.lang ?? own.language;
|
|
562
|
+
const picker = new TagPicker(null, {
|
|
563
|
+
items: filterTagItems(field.choices),
|
|
564
|
+
valueKey: 'value', labelKey: 'label',
|
|
565
|
+
values: Array.isArray(node.value) ? node.value : [],
|
|
566
|
+
disabled: this._blocked(), placeholder: messages.choose,
|
|
567
|
+
// The host's messages reach the picker's own strings; its input is named after the field.
|
|
568
|
+
msg: withFilterMessage(this.options.msg, 'tagPicker.input', messages.value(field.label)),
|
|
569
|
+
...(lang ? { lang } : {}),
|
|
570
|
+
onchange: ({ detail }) => {
|
|
571
|
+
if (!this._disposed && this._canMutate()) this._setNodeValue(node.id, filterTagValue(detail.values));
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
this._selectEditors.push(picker);
|
|
575
|
+
picker.el.classList.add('zx-filter__picker');
|
|
576
|
+
if (picker.refs.input) {
|
|
577
|
+
picker.refs.input.dataset.filterFocus = 'value';
|
|
578
|
+
picker.refs.input.dataset.nodeId = node.id;
|
|
579
|
+
}
|
|
580
|
+
return /** @type {HTMLElement} */ (picker.el);
|
|
581
|
+
}
|
|
582
|
+
|
|
438
583
|
/** @param {Event} event @returns {void} */
|
|
439
584
|
_handleChange(event) {
|
|
440
585
|
if (!this._canMutate()) return;
|
|
@@ -458,6 +603,7 @@ export class Filter extends Component {
|
|
|
458
603
|
} else if (target.dataset.filterLogic !== undefined) {
|
|
459
604
|
const node = findNode(this._value.root, id);
|
|
460
605
|
if (node?.kind === 'group' && !(node === this._value.root && this.options.rootLogic)) node.logic = target.value === 'or' ? 'or' : 'and';
|
|
606
|
+
if (node?.kind === 'group') this._syncConnectors(node);
|
|
461
607
|
this._changed('update', id);
|
|
462
608
|
} else if (target.dataset.filterValue !== undefined) this._valueInput(target);
|
|
463
609
|
}
|
|
@@ -482,7 +628,12 @@ export class Filter extends Component {
|
|
|
482
628
|
const field = this._field(node.field);
|
|
483
629
|
if (!operator || !field) return;
|
|
484
630
|
let value;
|
|
485
|
-
if (
|
|
631
|
+
if (isRelativeDateOperator(operator)) {
|
|
632
|
+
const row = target.closest('.zx-filter__value');
|
|
633
|
+
const amount = /** @type {HTMLInputElement|null} */ (row?.querySelector('[data-filter-relative="amount"]') ?? null);
|
|
634
|
+
const unit = /** @type {HTMLSelectElement|null} */ (row?.querySelector('[data-filter-relative="unit"]') ?? null);
|
|
635
|
+
value = relativeDateValue(amount?.value ?? '', unit?.value ?? RELATIVE_DATE_UNITS[0]);
|
|
636
|
+
} else if (target instanceof HTMLSelectElement && target.multiple) {
|
|
486
637
|
value = Array.from(target.selectedOptions, (option) => parseScalar(option.value, field.type));
|
|
487
638
|
} else if (operator.arity === 'many') {
|
|
488
639
|
value = target.value.split(',').map((item) => item.trim()).filter(Boolean).map((item) => parseScalar(item, field.type));
|
|
@@ -504,6 +655,13 @@ export class Filter extends Component {
|
|
|
504
655
|
else if (action === 'add-group') this.addGroup(id);
|
|
505
656
|
else if (action === 'remove' && id) this.remove(id);
|
|
506
657
|
else if (action === 'clear') this.clear();
|
|
658
|
+
else if (action === 'toggle-logic' && id) {
|
|
659
|
+
const logic = nextFilterGroupLogic(this._value, id, this.options.rootLogic);
|
|
660
|
+
if (!logic) return;
|
|
661
|
+
this.update(id, { logic });
|
|
662
|
+
// The connector was redrawn under the pointer; the group's own select keeps the focus.
|
|
663
|
+
this.focus(id);
|
|
664
|
+
}
|
|
507
665
|
else if (action === 'apply') this.apply();
|
|
508
666
|
}
|
|
509
667
|
|
|
@@ -640,6 +798,134 @@ function actionButton(action, id, iconName, label, props = {}) {
|
|
|
640
798
|
}, icon(iconName), h('span', {}, label));
|
|
641
799
|
}
|
|
642
800
|
|
|
801
|
+
/**
|
|
802
|
+
* The word a compact row's connector shows for a group's logic, and the name it is read by. The
|
|
803
|
+
* name starts with the visible word (WCAG 2.5.3, label in name) and says what a click does.
|
|
804
|
+
* @param {'and'|'or'} logic Group logic.
|
|
805
|
+
* @param {(key: string, fallback: string, ...args: unknown[]) => string} [message] Message
|
|
806
|
+
* resolver; omitted uses the built-in English text.
|
|
807
|
+
* @returns {{word: string, name: string}}
|
|
808
|
+
*/
|
|
809
|
+
export function filterConnectorText(logic, message = (_key, fallback, ...args) => printf(fallback, args)) {
|
|
810
|
+
const word = logic === 'or' ? message('filter.or', 'or') : message('filter.and', 'and');
|
|
811
|
+
const name = logic === 'or'
|
|
812
|
+
? message('filter.switchToAll', '%1 — switch to all', word)
|
|
813
|
+
: message('filter.switchToAny', '%1 — switch to any', word);
|
|
814
|
+
return { word, name };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The logic a group's connector switches it to: the other one, or null when there is no such
|
|
819
|
+
* group or it is the root and `rootLogic` restricts it. The group's select offers the same two
|
|
820
|
+
* values, so the connector and the select always name one state.
|
|
821
|
+
* @param {FilterAst} value Expression.
|
|
822
|
+
* @param {string} groupId Group to switch.
|
|
823
|
+
* @param {'and'|'or'|null} [rootLogic=null] Root restriction, as the `rootLogic` option.
|
|
824
|
+
* @returns {'and'|'or'|null}
|
|
825
|
+
*/
|
|
826
|
+
export function nextFilterGroupLogic(value, groupId, rootLogic = null) {
|
|
827
|
+
const group = findGroup(value?.root, groupId);
|
|
828
|
+
if (!group || (group === value.root && rootLogic)) return null;
|
|
829
|
+
return group.logic === 'or' ? 'and' : 'or';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Builds the value of a relative date condition from what its two controls hold: the pair
|
|
834
|
+
* `[amount, unit]`. An empty or non-numeric amount is kept as null — the condition is then
|
|
835
|
+
* incomplete rather than silently zero — and an unknown unit falls back to days.
|
|
836
|
+
* @param {unknown} amount Amount control value.
|
|
837
|
+
* @param {unknown} unit Unit control value.
|
|
838
|
+
* @returns {[number|null, 'days'|'weeks'|'months']}
|
|
839
|
+
*/
|
|
840
|
+
export function relativeDateValue(amount, unit) {
|
|
841
|
+
const text = String(amount ?? '').trim();
|
|
842
|
+
const number = text === '' ? NaN : Number(text);
|
|
843
|
+
const unitId = RELATIVE_DATE_UNITS.includes(String(unit)) ? String(unit) : RELATIVE_DATE_UNITS[0];
|
|
844
|
+
return [Number.isFinite(number) ? number : null, /** @type {'days'|'weeks'|'months'} */ (unitId)];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Reads a stored relative date value for display. Anything that is not `[amount, unit]` — null,
|
|
849
|
+
* or two dates written by the date-pair editor these operators used to get — reads as an empty
|
|
850
|
+
* amount in days.
|
|
851
|
+
* @param {unknown} value Stored condition value.
|
|
852
|
+
* @returns {{amount: number|null, unit: 'days'|'weeks'|'months'}}
|
|
853
|
+
*/
|
|
854
|
+
export function readRelativeDateValue(value) {
|
|
855
|
+
if (!Array.isArray(value) || value.length !== 2 || !RELATIVE_DATE_UNITS.includes(String(value[1]))) {
|
|
856
|
+
return { amount: null, unit: 'days' };
|
|
857
|
+
}
|
|
858
|
+
const amount = typeof value[0] === 'number' && Number.isFinite(value[0]) ? value[0] : null;
|
|
859
|
+
return { amount, unit: /** @type {'days'|'weeks'|'months'} */ (String(value[1])) };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Why a relative date value is unusable, or null when it is a positive whole number of a known
|
|
864
|
+
* unit: `[7, 'days']`, `[2, 'weeks']`, `[3, 'months']`.
|
|
865
|
+
* @param {unknown} value Condition value.
|
|
866
|
+
* @returns {'shape'|'amount'|'unit'|null}
|
|
867
|
+
*/
|
|
868
|
+
export function relativeDateValueError(value) {
|
|
869
|
+
if (!Array.isArray(value) || value.length !== 2) return 'shape';
|
|
870
|
+
if (!RELATIVE_DATE_UNITS.includes(String(value[1])) || typeof value[1] !== 'string') return 'unit';
|
|
871
|
+
if (typeof value[0] !== 'number' || !Number.isInteger(value[0]) || value[0] < 1) return 'amount';
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Static choices as `TagPicker` items: the raw value — a number stays a number — the label, and
|
|
877
|
+
* the icon as given, a name or a node (the picker copies a node).
|
|
878
|
+
* @param {FilterField['choices']} choices
|
|
879
|
+
* @returns {Array<{value: string|number|boolean, label: string, icon?: Node|string, color?: string}>}
|
|
880
|
+
*/
|
|
881
|
+
export function filterTagItems(choices) {
|
|
882
|
+
return (Array.isArray(choices) ? choices : []).map((choice) => ({
|
|
883
|
+
value: choice.value, label: choice.label,
|
|
884
|
+
...(choice.icon ? { icon: choice.icon } : {}),
|
|
885
|
+
...(choice.color ? { color: choice.color } : {})
|
|
886
|
+
}));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The value a `TagPicker` change writes into a many-valued condition: a JSON-safe copy of the
|
|
891
|
+
* chosen raw values, in the order chosen.
|
|
892
|
+
* @param {unknown} values Picker values.
|
|
893
|
+
* @returns {import('./filter-model.js').FilterJson}
|
|
894
|
+
*/
|
|
895
|
+
export function filterTagValue(values) {
|
|
896
|
+
return cloneFilterValue(Array.isArray(values) ? [...values] : []);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* A host's `msg` option with one more message: added as a plain key and to every locale table,
|
|
901
|
+
* since a locale entry is looked up first and would otherwise win.
|
|
902
|
+
* @param {Record<string, any>|null|undefined} msg Host messages, flat or by locale.
|
|
903
|
+
* @param {string} key Message key.
|
|
904
|
+
* @param {string} value Message text.
|
|
905
|
+
* @returns {Record<string, any>}
|
|
906
|
+
*/
|
|
907
|
+
export function withFilterMessage(msg, key, value) {
|
|
908
|
+
const result = {};
|
|
909
|
+
for (const [name, entry] of Object.entries(msg && typeof msg === 'object' ? msg : {})) {
|
|
910
|
+
result[name] = entry && typeof entry === 'object' ? { ...entry, [key]: value } : entry;
|
|
911
|
+
}
|
|
912
|
+
result[key] = value;
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* A choice's glyph for a themed picker: an icon name drawn through `icon()`, or a copy of a node.
|
|
918
|
+
* @param {{icon?: unknown}} item Field or value choice.
|
|
919
|
+
* @returns {HTMLElement|null}
|
|
920
|
+
*/
|
|
921
|
+
function choiceGlyph(item) {
|
|
922
|
+
const glyph = item?.icon;
|
|
923
|
+
const content = typeof glyph === 'string' && glyph ? icon(glyph, { size: 14 })
|
|
924
|
+
: glyph && typeof glyph === 'object' && typeof /** @type {Node} */ (glyph).nodeType === 'number'
|
|
925
|
+
? /** @type {Node} */ (glyph).cloneNode(true) : null;
|
|
926
|
+
return content ? h('span', { class: 'zx-filter__choice-icon', ariaHidden: 'true' }, content) : null;
|
|
927
|
+
}
|
|
928
|
+
|
|
643
929
|
/** @param {FilterField} field @param {any} node @param {any} value @param {boolean} multiple @param {boolean} disabled @param {number} [index=0] @param {FilterValueMessages} [messages=BUILT_IN_VALUE_MESSAGES] @returns {HTMLSelectElement|null} */
|
|
644
930
|
function choiceSelect(field, node, value, multiple, disabled, index = 0, messages = BUILT_IN_VALUE_MESSAGES) {
|
|
645
931
|
const choices = Array.isArray(field.choices) && field.choices.length
|
|
@@ -669,6 +955,12 @@ function valueInput(field, id, value, index, disabled, many = false, messages =
|
|
|
669
955
|
input, listId ? h('datalist', { id: listId }) : null);
|
|
670
956
|
}
|
|
671
957
|
|
|
958
|
+
/** @param {{id?: string, arity?: string}|null|undefined} operator @returns {boolean} */
|
|
959
|
+
function isRelativeDateOperator(operator) {
|
|
960
|
+
return Boolean(operator) && (RELATIVE_DATE_OPERATORS.has(String(operator.id)) || operator.arity === 'relative');
|
|
961
|
+
}
|
|
962
|
+
/** @param {any} node @returns {any[]} Every condition under a node, in document order. */
|
|
963
|
+
function conditionNodes(node) { if (!node) return []; if (node.kind === 'condition') return [node]; return node.children.flatMap((child) => conditionNodes(child)); }
|
|
672
964
|
/** @param {unknown} value @param {string} type @returns {any} */
|
|
673
965
|
function parseScalar(value, type) { if (value === '') return null; if (['number', 'money'].includes(type)) { const number = Number(value); return Number.isFinite(number) ? number : null; } if (type === 'boolean') return value === 'true'; return value; }
|
|
674
966
|
/** @param {unknown} value @returns {any} */
|
|
@@ -676,7 +968,7 @@ function parseValueJson(value) { return cloneFilterValue(value === undefined ? n
|
|
|
676
968
|
/** @param {any} root @param {string} id @returns {any|null} */
|
|
677
969
|
function findNode(root, id) { if (!root || root.id === id) return root ?? null; if (root.kind === 'group') for (const child of root.children) { const match = findNode(child, id); if (match) return match; } return null; }
|
|
678
970
|
/** @param {any} root @param {string} id @returns {any|null} */
|
|
679
|
-
function findGroup(root, id) { const node = findNode(root, id); return node?.kind === 'group' ? node : null; }
|
|
971
|
+
function findGroup(root, id) { if (!root) return null; const node = findNode(root, id); return node?.kind === 'group' ? node : null; }
|
|
680
972
|
/** @param {any} root @param {string} id @returns {any|null} */
|
|
681
973
|
function findParent(root, id) { if (root?.kind !== 'group') return null; if (root.children.some((child) => child.id === id)) return root; for (const child of root.children) { const parent = findParent(child, id); if (parent) return parent; } return null; }
|
|
682
974
|
/** @param {any} root @param {string} id @returns {any|null} */
|