@zeyos/zx 4.3.4 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -1,6 +1,8 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
+ import { printf } from '../../core/i18n.js';
5
+ import { Select } from '../select/select.js';
4
6
  import {
5
7
  cloneFilterAst, cloneFilterValue, emptyFilterAst, filterCondition, filterGroup, filterOperators,
6
8
  parseFilterAst, validateFilterAst
@@ -28,11 +30,16 @@ import {
28
30
  * @property {boolean} [allowGroups=true] Whether nested groups may be authored.
29
31
  * @property {number} [maxDepth=3] Maximum nested group depth.
30
32
  * @property {number} [maxConditions=50] Maximum condition count.
33
+ * @property {boolean} [searchable=false] Searchable field, operator and single-choice controls.
34
+ * @property {'standard'|'compact'} [layout='standard'] Condition layout; compact rows adapt to container width.
35
+ * @property {'and'|'or'|null} [rootLogic=null] Restrict root logic without rewriting incompatible saved expressions.
36
+ * @property {boolean} [showApply=true] Show the internal Apply button.
37
+ * @property {boolean} [showRootActions=true] Show Add controls inside the root group as well as the footer.
31
38
  * @property {boolean} [autoApply=false] Emit apply after every valid mutation.
32
39
  * @property {boolean} [readonly=false] Prevent mutations while keeping values readable.
33
40
  * @property {boolean} [disabled=false] Disable all controls.
34
- * @property {string} [applyLabel='Apply filters'] Apply button label.
35
- * @property {string} [clearLabel='Clear'] Clear button label.
41
+ * @property {string} [applyLabel] Apply button label. Omitted resolves `filter.apply`, English `Apply filters`.
42
+ * @property {string} [clearLabel] Clear button label. Omitted resolves `filter.clear`, English `Clear`.
36
43
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onchange] Draft-change listener.
37
44
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onapply] Valid-apply listener.
38
45
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [oninvalid] Invalid-apply listener.
@@ -41,6 +48,29 @@ import {
41
48
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onerror] Async failure listener.
42
49
  */
43
50
 
51
+ /**
52
+ * Words the value builders render, resolved by the component that owns them.
53
+ * @typedef {Object} FilterValueMessages
54
+ * @property {string} choose Empty-choice option label.
55
+ * @property {string} yes Boolean true label.
56
+ * @property {string} no Boolean false label.
57
+ * @property {string} commaSeparated Placeholder for a many-valued text input.
58
+ * @property {(label: string) => string} value Accessible name for a single value control.
59
+ * @property {(label: string, position: number) => string} valueAt Accessible name for one end of a pair.
60
+ */
61
+
62
+ /** English text the value builders fall back to when they are called without a component. */
63
+ const BUILT_IN_VALUE_MESSAGES = Object.freeze({
64
+ choose: 'Choose…',
65
+ yes: 'True',
66
+ no: 'False',
67
+ commaSeparated: 'Comma-separated values',
68
+ /** @param {string} label @returns {string} */
69
+ value: (label) => `${label} value`,
70
+ /** @param {string} label @param {number} position @returns {string} */
71
+ valueAt: (label, position) => `${label} value ${position}`
72
+ });
73
+
44
74
  /**
45
75
  * Backend-neutral dynamic filter expression editor.
46
76
  * @fires Filter#change
@@ -58,7 +88,8 @@ export class Filter extends Component {
58
88
  static defaults = {
59
89
  fields: [], value: null, operators: [], allowGroups: true, maxDepth: 3, maxConditions: 50,
60
90
  autoApply: false, readonly: false, disabled: false,
61
- applyLabel: 'Apply filters', clearLabel: 'Clear'
91
+ searchable: false, layout: 'standard', rootLogic: null, showApply: true, showRootActions: true,
92
+ applyLabel: null, clearLabel: null
62
93
  };
63
94
 
64
95
  /** @returns {HTMLElement} */
@@ -73,6 +104,9 @@ export class Filter extends Component {
73
104
  this._disabled = Boolean(this.options.disabled);
74
105
  this._async = new Map();
75
106
  this._customEditors = [];
107
+ this._selectEditors = [];
108
+ this._disposed = false;
109
+ root.dataset.layout = this.options.layout === 'compact' ? 'compact' : 'standard';
76
110
  this._value = this.options.value == null ? emptyFilterAst()
77
111
  : parseFilterAst(this.options.value, this._limits());
78
112
 
@@ -157,8 +191,10 @@ export class Filter extends Component {
157
191
  if (!removed) return this;
158
192
  this._abortTree(removed);
159
193
  this._render();
160
- const label = removed.kind === 'condition' ? this._field(removed.field)?.label ?? 'Filter' : 'Filter group';
161
- this._announce(`${label} removed`);
194
+ const label = removed.kind === 'condition'
195
+ ? this._field(removed.field)?.label ?? this._message('filter.conditionNoun', 'Filter')
196
+ : this._message('filter.groupNoun', 'Filter group');
197
+ this._announce(this._message('filter.removed', '%1 removed', label));
162
198
  this._changed('remove', id);
163
199
  this.focus(focusId);
164
200
  return this;
@@ -185,7 +221,12 @@ export class Filter extends Component {
185
221
  }
186
222
 
187
223
  /** Returns semantic validity and errors. @returns {{valid:boolean,errors:Array<Record<string,string>>}} */
188
- validate() { return validateFilterAst(this._value, this._fields, this._operators); }
224
+ validate() {
225
+ const result = validateFilterAst(this._value, this._fields, this._operators);
226
+ const error = rootLogicError(this._value, this.options.rootLogic);
227
+ if (error) { result.valid = false; result.errors.push(error); }
228
+ return result;
229
+ }
189
230
 
190
231
  /** Applies only a valid AST. @returns {FilterAst|null} */
191
232
  apply() {
@@ -215,9 +256,13 @@ export class Filter extends Component {
215
256
  /** Focuses a condition's field or the Add button. @param {string|null} [id=null] @returns {this} */
216
257
  focus(id = null) {
217
258
  const selector = id
218
- ? `[data-node-id="${cssEscape(id)}"] :is([data-filter-field], [data-filter-logic], [data-filter-action="add"])`
259
+ ? `[data-node-id="${cssEscape(id)}"] :is([data-filter-field], [data-filter-focus="field"], [data-filter-logic], [data-filter-action="add"])`
219
260
  : '[data-filter-action="add"]';
220
- queueMicrotask(() => /** @type {HTMLElement|null} */ (this.el.querySelector(selector))?.focus());
261
+ queueMicrotask(() => {
262
+ if (this._disposed) return;
263
+ const target = /** @type {HTMLElement|null} */ (this.el.querySelector(selector)) ?? this.refs.add;
264
+ target?.focus();
265
+ });
221
266
  return this;
222
267
  }
223
268
 
@@ -230,6 +275,8 @@ export class Filter extends Component {
230
275
 
231
276
  /** @returns {void} */
232
277
  destroy() {
278
+ this._disposed = true;
279
+ this._renderVersion += 1;
233
280
  this._abortAll();
234
281
  this._destroyEditors();
235
282
  const root = this.el;
@@ -245,10 +292,10 @@ export class Filter extends Component {
245
292
  const status = h('div', { ref: 'status', class: 'zx-filter__status', role: 'status', ariaLive: 'polite' });
246
293
  const tree = this._renderGroup(this._value.root, 0, true);
247
294
  const actions = h('div', { class: 'zx-filter__actions' },
248
- actionButton('add', this._value.root.id, 'plus', 'Add filter', { ref: 'add', disabled: this._blocked() }),
249
- this.options.allowGroups ? actionButton('add-group', this._value.root.id, 'folder', 'Add group', { disabled: this._blocked() }) : null,
250
- actionButton('clear', '', 'x', String(this.options.clearLabel), { disabled: this._blocked() }),
251
- actionButton('apply', '', 'check', String(this.options.applyLabel), { class: 'zx-button--primary', disabled: this._disabled })
295
+ actionButton('add', this._value.root.id, 'plus', this._message('filter.addFilter', 'Add filter'), { ref: 'add', disabled: this._blocked() }),
296
+ this.options.allowGroups ? actionButton('add-group', this._value.root.id, 'folder', this._message('filter.addGroup', 'Add group'), { disabled: this._blocked() }) : null,
297
+ actionButton('clear', '', 'x', String(this.options.clearLabel ?? this._message('filter.clear', 'Clear')), { disabled: this._blocked() }),
298
+ this.options.showApply ? actionButton('apply', '', 'check', String(this.options.applyLabel ?? this._message('filter.apply', 'Apply filters')), { kind: 'primary', disabled: this._disabled }) : null
252
299
  );
253
300
  this.el.replaceChildren(status, tree, actions);
254
301
  this.refs.status = status;
@@ -266,20 +313,28 @@ export class Filter extends Component {
266
313
  group.children.map((node) => node.kind === 'group' ? this._renderGroup(node, depth + 1, false) : this._renderCondition(node)));
267
314
  const logic = h('select', {
268
315
  class: 'zx-filter__logic', dataset: { filterLogic: '', nodeId: group.id },
269
- ariaLabel: root ? 'Root filter logic' : 'Group filter logic', disabled: this._blocked()
316
+ ariaLabel: root ? this._message('filter.rootLogic', 'Root filter logic')
317
+ : this._message('filter.groupLogic', 'Group filter logic'),
318
+ disabled: this._blocked()
270
319
  },
271
- h('option', { value: 'and', selected: group.logic === 'and' }, 'Match all'),
272
- h('option', { value: 'or', selected: group.logic === 'or' }, 'Match any'));
273
- const legend = h('legend', { class: 'zx-filter__legend' }, logic, h('span', {}, root ? 'conditions' : 'in this group'));
320
+ h('option', { value: 'and', selected: group.logic === 'and' }, this._message('filter.matchAll', 'Match all')),
321
+ h('option', { value: 'or', selected: group.logic === 'or' }, this._message('filter.matchAny', 'Match any')));
322
+ const legend = h('legend', { class: 'zx-filter__legend' },
323
+ root && this.options.rootLogic
324
+ ? (group.logic === 'or'
325
+ ? this._message('filter.matchAnyConditions', 'Match any conditions')
326
+ : this._message('filter.matchAllConditions', 'Match all conditions'))
327
+ : [logic, h('span', {}, root ? this._message('filter.conditions', 'conditions')
328
+ : this._message('filter.inThisGroup', 'in this group'))]);
274
329
  const controls = h('div', { class: 'zx-filter__group-actions' },
275
- actionButton('add', group.id, 'plus', 'Add filter', { disabled: this._blocked() }),
330
+ actionButton('add', group.id, 'plus', this._message('filter.addFilter', 'Add filter'), { disabled: this._blocked() }),
276
331
  this.options.allowGroups && depth < Number(this.options.maxDepth)
277
- ? actionButton('add-group', group.id, 'folder', 'Add subgroup', { disabled: this._blocked() }) : null,
278
- root ? null : actionButton('remove', group.id, 'trash', 'Remove group', { disabled: this._blocked() })
332
+ ? actionButton('add-group', group.id, 'folder', this._message('filter.addSubgroup', 'Add subgroup'), { disabled: this._blocked() }) : null,
333
+ root ? null : actionButton('remove', group.id, 'trash', this._message('filter.removeGroup', 'Remove group'), { disabled: this._blocked() })
279
334
  );
280
335
  return h(root ? 'fieldset' : 'li', {
281
336
  class: 'zx-filter__group', dataset: { nodeId: group.id, depth: String(depth) }
282
- }, root ? legend : h('fieldset', {}, legend, children, controls), root ? children : null, root ? controls : null);
337
+ }, root ? legend : h('fieldset', {}, legend, children, controls), root ? children : null, root && this.options.showRootActions ? controls : null);
283
338
  }
284
339
 
285
340
  /** @param {any} node @returns {HTMLElement} */
@@ -287,24 +342,35 @@ export class Filter extends Component {
287
342
  const field = this._field(node.field);
288
343
  const operators = this._operatorsFor(field);
289
344
  const operator = this._operator(node.operator);
290
- const fieldSelect = h('select', {
345
+ const fieldChoices = this._fields.map((candidate) => ({value: candidate.id, label: candidate.label}));
346
+ if (node.field && !field) fieldChoices.push({value: node.field, label: this._unavailable(node.field)});
347
+ const operatorChoices = operators.map((candidate) => ({value: candidate.id, label: candidate.label}));
348
+ if (node.operator && !operators.some((candidate) => candidate.id === node.operator)) operatorChoices.push({value: node.operator, label: this._unavailable(node.operator)});
349
+ const fieldSelect = this.options.searchable ? this._searchSelect(fieldChoices, node.field, this._message('filter.field', 'Filter field'), node.id, 'field', (value) => {
350
+ const selectedField = this._field(String(value));
351
+ this.update(node.id, {field: String(value), operator: this._defaultOperator(selectedField) ?? '', value: null});
352
+ this.focus(node.id);
353
+ }) : h('select', {
291
354
  class: 'zx-filter__field', dataset: { filterField: '', nodeId: node.id },
292
- ariaLabel: 'Filter field', disabled: this._blocked()
355
+ ariaLabel: this._message('filter.field', 'Filter field'), disabled: this._blocked()
293
356
  },
294
- h('option', { value: '', selected: !node.field }, 'Choose a field…'),
357
+ h('option', { value: '', selected: !node.field }, this._message('filter.chooseField', 'Choose a field…')),
295
358
  this._fields.map((candidate) => h('option', { value: candidate.id, selected: candidate.id === node.field }, candidate.label)),
296
- node.field && !field ? h('option', { value: node.field, selected: true }, `Unavailable: ${node.field}`) : null);
297
- const operatorSelect = h('select', {
359
+ node.field && !field ? h('option', { value: node.field, selected: true }, this._unavailable(node.field)) : null);
360
+ const operatorSelect = this.options.searchable ? this._searchSelect(operatorChoices, node.operator, this._message('filter.operator', 'Filter operator'), node.id, 'operator', (value) => {
361
+ this.update(node.id, {operator: String(value), value: null});
362
+ queueMicrotask(() => this.el.querySelector(`[data-node-id="${cssEscape(node.id)}"] :is([data-filter-value], [data-filter-focus="value"])`)?.focus());
363
+ }) : h('select', {
298
364
  class: 'zx-filter__operator', dataset: { filterOperator: '', nodeId: node.id },
299
- ariaLabel: 'Filter operator', disabled: this._blocked()
365
+ ariaLabel: this._message('filter.operator', 'Filter operator'), disabled: this._blocked()
300
366
  },
301
- h('option', { value: '', selected: !node.operator }, 'Choose an operator…'),
367
+ h('option', { value: '', selected: !node.operator }, this._message('filter.chooseOperator', 'Choose an operator…')),
302
368
  operators.map((candidate) => h('option', { value: candidate.id, selected: candidate.id === node.operator }, candidate.label)),
303
369
  node.operator && !operators.some((candidate) => candidate.id === node.operator)
304
- ? h('option', { value: node.operator, selected: true }, `Unavailable: ${node.operator}`) : null);
370
+ ? h('option', { value: node.operator, selected: true }, this._unavailable(node.operator)) : null);
305
371
  return h('li', { class: 'zx-filter__condition', dataset: { nodeId: node.id } },
306
372
  fieldSelect, operatorSelect, this._renderValue(node, field, operator),
307
- actionButton('remove', node.id, 'trash', `Remove ${field?.label ?? 'filter'}`, { disabled: this._blocked() })
373
+ this._removeConditionButton(node, field)
308
374
  );
309
375
  }
310
376
 
@@ -312,7 +378,7 @@ export class Filter extends Component {
312
378
  _renderValue(node, field, operator) {
313
379
  const host = h('div', { class: 'zx-filter__value' });
314
380
  if (!field || !operator) return host;
315
- if (operator.arity === 'none') return h('div', { class: 'zx-filter__value zx-filter__value--empty' }, 'No value');
381
+ if (operator.arity === 'none') return h('div', { class: 'zx-filter__value zx-filter__value--empty' }, this._message('filter.noValue', 'No value'));
316
382
  if (field.type === 'custom' && typeof field.editor === 'function') {
317
383
  const renderVersion = this._renderVersion;
318
384
  queueMicrotask(() => {
@@ -325,17 +391,50 @@ export class Filter extends Component {
325
391
  });
326
392
  return host;
327
393
  }
394
+ const messages = this._valueMessages();
328
395
  const values = operator.arity === 'pair' ? (Array.isArray(node.value) ? node.value : [null, null]) : [node.value];
329
396
  if (operator.arity === 'many') {
330
- const control = choiceSelect(field, node, values[0], true, this._blocked());
397
+ const control = choiceSelect(field, node, values[0], true, this._blocked(), 0, messages);
331
398
  if (control) return h('div', { class: 'zx-filter__value' }, control);
332
- return h('div', { class: 'zx-filter__value' }, valueInput(field, node.id, Array.isArray(node.value) ? node.value.join(', ') : '', 0, this._blocked(), true));
399
+ return h('div', { class: 'zx-filter__value' }, valueInput(field, node.id, Array.isArray(node.value) ? node.value.join(', ') : '', 0, this._blocked(), true, messages));
333
400
  }
334
- const controls = values.map((value, index) => choiceSelect(field, node, value, false, this._blocked(), index)
335
- ?? valueInput(field, node.id, value, index, this._blocked()));
401
+ const choices = field.choices?.length ? field.choices : field.type === 'boolean' ? [{value: true, label: messages.yes}, {value: false, label: messages.no}] : null;
402
+ const controls = values.map((value, index) => this.options.searchable && choices
403
+ ? this._searchSelect(choices, value, operator.arity === 'pair' ? messages.valueAt(field.label, index + 1) : messages.value(field.label), node.id, 'value', (selected) => {
404
+ const next = operator.arity === 'pair' ? (Array.isArray(node.value) ? [...node.value] : [null, null]) : null;
405
+ if (next) next[index] = selected;
406
+ this._setNodeValue(node.id, next ?? selected);
407
+ }) : choiceSelect(field, node, value, false, this._blocked(), index, messages)
408
+ ?? valueInput(field, node.id, value, index, this._blocked(), false, messages));
336
409
  return h('div', { class: 'zx-filter__value', dataset: { arity: operator.arity } }, controls);
337
410
  }
338
411
 
412
+ /**
413
+ * Owns a themed picker and defers mutation until Select finishes its selection event.
414
+ * @param {Array<{value:unknown,label:string}>} items Choices.
415
+ * @param {unknown} value Selected value.
416
+ * @param {string} label Accessible name.
417
+ * @param {string} nodeId Condition ID.
418
+ * @param {string} part Focus target name.
419
+ * @param {(value:unknown)=>void} changed Selection handler.
420
+ * @returns {HTMLElement} Picker root.
421
+ */
422
+ _searchSelect(items, value, label, nodeId, part, changed) {
423
+ const version = this._renderVersion;
424
+ const select = new Select(null, {
425
+ items, valueKey: 'value', labelKey: 'label', value, label, filter: 'local',
426
+ disabled: this._blocked(), placeholder: this._message('filter.choosePart', 'Choose %1…', part),
427
+ onchange: ({detail}) => queueMicrotask(() => {
428
+ if (!this._disposed && version === this._renderVersion && this._canMutate()) changed(detail.value);
429
+ })
430
+ });
431
+ this._selectEditors.push(select);
432
+ select.el.classList.add('zx-filter__picker');
433
+ select.refs.input.dataset.filterFocus = part;
434
+ select.refs.input.dataset.nodeId = nodeId;
435
+ return /** @type {HTMLElement} */ (select.el);
436
+ }
437
+
339
438
  /** @param {Event} event @returns {void} */
340
439
  _handleChange(event) {
341
440
  if (!this._canMutate()) return;
@@ -358,7 +457,7 @@ export class Filter extends Component {
358
457
  queueMicrotask(() => /** @type {HTMLElement|null} */ (this.el.querySelector(`[data-node-id="${cssEscape(id)}"] [data-filter-value]`))?.focus());
359
458
  } else if (target.dataset.filterLogic !== undefined) {
360
459
  const node = findNode(this._value.root, id);
361
- if (node?.kind === 'group') node.logic = target.value === 'or' ? 'or' : 'and';
460
+ if (node?.kind === 'group' && !(node === this._value.root && this.options.rootLogic)) node.logic = target.value === 'or' ? 'or' : 'and';
362
461
  this._changed('update', id);
363
462
  } else if (target.dataset.filterValue !== undefined) this._valueInput(target);
364
463
  }
@@ -469,7 +568,10 @@ export class Filter extends Component {
469
568
  /** @returns {void} */
470
569
  _abortAll() { for (const id of [...this._async.keys()]) this._abort(id); }
471
570
  /** @returns {void} */
472
- _destroyEditors() { for (const editor of this._customEditors ?? []) editor.destroy?.(); this._customEditors = []; }
571
+ _destroyEditors() {
572
+ for (const editor of [...(this._customEditors ?? []), ...(this._selectEditors ?? [])]) editor.destroy?.();
573
+ this._customEditors = []; this._selectEditors = [];
574
+ }
473
575
  /** @returns {boolean} */
474
576
  _blocked() { return this._readonly || this._disabled; }
475
577
  /** @returns {boolean} */
@@ -486,37 +588,82 @@ export class Filter extends Component {
486
588
  _defaultOperator(field) { return field?.defaultOperator && this._operatorsFor(field).some((operator) => operator.id === field.defaultOperator) ? field.defaultOperator : this._operatorsFor(field)[0]?.id ?? null; }
487
589
  /** @param {string} text @returns {void} */
488
590
  _announce(text) { if (this.refs.status) this.refs.status.textContent = text; }
591
+
592
+ /** Names a field or operator a saved expression refers to that this instance does not offer.
593
+ * @param {string} id Unknown field or operator ID. @returns {string} */
594
+ _unavailable(id) { return this._message('filter.unavailable', 'Unavailable: %1', id); }
595
+
596
+ /** @param {any} node Condition. @param {FilterField|null} field Resolved field. @returns {HTMLElement} */
597
+ _removeConditionButton(node, field) {
598
+ const label = this._message('filter.remove', 'Remove %1',
599
+ field?.label ?? this._message('filter.filterNoun', 'filter'));
600
+ return actionButton('remove', node.id, 'trash', label,
601
+ { disabled: this._blocked(), ariaLabel: label });
602
+ }
603
+
604
+ /**
605
+ * Resolves the strings the two value builders need. They are module functions so that the
606
+ * markup they produce stays testable without a component, which means the words have to be
607
+ * handed to them rather than looked up inside.
608
+ * @returns {FilterValueMessages}
609
+ */
610
+ _valueMessages() {
611
+ return {
612
+ choose: this._message('filter.choose', 'Choose…'),
613
+ yes: this._message('filter.booleanTrue', 'True'),
614
+ no: this._message('filter.booleanFalse', 'False'),
615
+ commaSeparated: this._message('filter.commaSeparated', 'Comma-separated values'),
616
+ value: (label) => this._message('filter.fieldValue', '%1 value', label),
617
+ valueAt: (label, position) => this._message('filter.fieldValueAt', '%1 value %2', label, position)
618
+ };
619
+ }
620
+
621
+ /**
622
+ * Resolves a message through the host translator, falling back to the built-in English text.
623
+ * @param {string} key Message key.
624
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
625
+ * @param {...unknown} args Interpolation values.
626
+ * @returns {string}
627
+ */
628
+ _message(key, fallback, ...args) {
629
+ const message = this.msg(key, ...args);
630
+ return message === key ? printf(fallback, args) : message;
631
+ }
489
632
  }
490
633
 
491
- /** @param {string} action @param {string} id @param {string} iconName @param {string} label @param {Record<string,any>} [props={}] @returns {HTMLElement} */
634
+ /** @param {string} action @param {string} id @param {string} iconName @param {string} label @param {Record<string,any>} [props={}] `kind` picks the Button kind; the rest are element properties. @returns {HTMLElement} */
492
635
  function actionButton(action, id, iconName, label, props = {}) {
493
- return h('button', { type: 'button', class: ['zx-button', 'zx-button--sm', props.class].filter(Boolean), ...props, dataset: { filterAction: action, nodeId: id } }, icon(iconName, { size: 13 }), h('span', {}, label));
636
+ const { kind, ...rest } = props;
637
+ return h('button', {
638
+ type: 'button', class: 'zx-btn', ...rest,
639
+ dataset: { filterAction: action, nodeId: id, size: 'sm', ...(kind ? { kind } : {}) }
640
+ }, icon(iconName), h('span', {}, label));
494
641
  }
495
642
 
496
- /** @param {FilterField} field @param {any} node @param {any} value @param {boolean} multiple @param {boolean} disabled @param {number} [index=0] @returns {HTMLSelectElement|null} */
497
- function choiceSelect(field, node, value, multiple, disabled, index = 0) {
643
+ /** @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
+ function choiceSelect(field, node, value, multiple, disabled, index = 0, messages = BUILT_IN_VALUE_MESSAGES) {
498
645
  const choices = Array.isArray(field.choices) && field.choices.length
499
646
  ? field.choices
500
647
  : field.type === 'boolean'
501
- ? [{ value: true, label: 'True' }, { value: false, label: 'False' }]
648
+ ? [{ value: true, label: messages.yes }, { value: false, label: messages.no }]
502
649
  : null;
503
650
  if (!choices) return null;
504
651
  const values = new Set(Array.isArray(value) ? value.map(String) : [String(value ?? '')]);
505
652
  return /** @type {HTMLSelectElement} */ (h('select', {
506
653
  dataset: { filterValue: '', nodeId: node.id, valueIndex: String(index) },
507
- ariaLabel: `${field.label} value`, multiple, disabled
508
- }, !multiple ? h('option', { value: '', selected: !values.has(String(value)) || value == null }, 'Choose…') : null,
654
+ ariaLabel: messages.value(field.label), multiple, disabled
655
+ }, !multiple ? h('option', { value: '', selected: !values.has(String(value)) || value == null }, messages.choose) : null,
509
656
  choices.map((choice) => h('option', { value: choice.value, selected: values.has(String(choice.value)) }, choice.label))));
510
657
  }
511
658
 
512
- /** @param {FilterField} field @param {string} id @param {any} value @param {number} index @param {boolean} disabled @param {boolean} [many=false] @returns {HTMLElement} */
513
- function valueInput(field, id, value, index, disabled, many = false) {
659
+ /** @param {FilterField} field @param {string} id @param {any} value @param {number} index @param {boolean} disabled @param {boolean} [many=false] @param {FilterValueMessages} [messages=BUILT_IN_VALUE_MESSAGES] @returns {HTMLElement} */
660
+ function valueInput(field, id, value, index, disabled, many = false, messages = BUILT_IN_VALUE_MESSAGES) {
514
661
  const type = many ? 'text' : ['number', 'money'].includes(field.type) ? 'number' : field.type === 'date' ? 'date' : field.type === 'datetime' ? 'datetime-local' : 'text';
515
662
  const listId = field.loadChoices ? `zx-filter-choices-${id}-${index}` : null;
516
663
  const input = /** @type {HTMLInputElement} */ (h('input', {
517
664
  type, value: value ?? '', dataset: { filterValue: '', nodeId: id, valueIndex: String(index) },
518
- ariaLabel: `${field.label} value${index ? ` ${index + 1}` : ''}`, disabled,
519
- placeholder: many ? 'Comma-separated values' : '', list: listId
665
+ ariaLabel: index ? messages.valueAt(field.label, index + 1) : messages.value(field.label), disabled,
666
+ placeholder: many ? messages.commaSeparated : '', list: listId
520
667
  }));
521
668
  return h('span', { class: 'zx-filter__value-control' },
522
669
  input, listId ? h('datalist', { id: listId }) : null);
@@ -552,3 +699,14 @@ function cssEscape(value) { return globalThis.CSS?.escape ? CSS.escape(value) :
552
699
  /** @event Filter#query @type {CustomEvent<Record<string,unknown>>} */
553
700
  /** @event Filter#loaded @type {CustomEvent<Record<string,unknown>>} */
554
701
  /** @event Filter#error @type {CustomEvent<Record<string,unknown>>} */
702
+
703
+ /**
704
+ * Reports a restricted root mismatch without mutating the supplied expression.
705
+ * @param {FilterAst} value Expression to validate.
706
+ * @param {'and'|'or'|null} logic Required root logic.
707
+ * @returns {Record<string,string>|null} Actionable validation error, or null.
708
+ */
709
+ export function rootLogicError(value, logic) {
710
+ if (!logic || value.root.logic === logic) return null;
711
+ return {nodeId: value.root.id, code: 'root-logic', message: `This filter requires match ${logic === 'or' ? 'any' : 'all'} conditions.`};
712
+ }
@@ -0,0 +1,104 @@
1
+ .zx-filter-panel {
2
+ display: grid;
3
+ gap: var(--zx-space-4);
4
+ align-content: start;
5
+ min-inline-size: 0;
6
+ color: var(--zx-color-text);
7
+ font-size: var(--zx-control-font-size);
8
+ }
9
+
10
+ .zx-filter-panel__fields {
11
+ display: grid;
12
+ /* One column in a sheet or a sidebar, more when the panel sits above a list and has the room. */
13
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
14
+ align-items: start;
15
+ gap: var(--zx-space-4);
16
+ min-inline-size: 0;
17
+ }
18
+
19
+ .zx-filter-panel__field {
20
+ display: grid;
21
+ gap: var(--zx-space-2);
22
+ align-content: start;
23
+ min-inline-size: 0;
24
+ }
25
+
26
+ .zx-filter-panel__label {
27
+ color: var(--zx-color-text-muted);
28
+ font-size: var(--zx-text-sm);
29
+ font-weight: var(--zx-weight-medium);
30
+ }
31
+
32
+ .zx-filter-panel__input {
33
+ inline-size: 100%;
34
+ min-inline-size: 0;
35
+ }
36
+
37
+ .zx-filter-panel__range {
38
+ display: grid;
39
+ grid-template-columns: repeat(2, minmax(0, 1fr));
40
+ gap: var(--zx-space-2);
41
+ min-inline-size: 0;
42
+ }
43
+
44
+ .zx-filter-panel__edge {
45
+ display: grid;
46
+ gap: var(--zx-space-1);
47
+ min-inline-size: 0;
48
+ }
49
+
50
+ .zx-filter-panel__edge-label {
51
+ color: var(--zx-color-text-muted);
52
+ font-size: var(--zx-text-xs);
53
+ }
54
+
55
+ .zx-filter-panel__unsupported {
56
+ margin: 0;
57
+ border: 1px dashed var(--zx-color-border-strong);
58
+ border-radius: var(--zx-radius-md);
59
+ background: var(--zx-color-bg-muted);
60
+ color: var(--zx-color-text-muted);
61
+ padding: var(--zx-space-2) var(--zx-space-3);
62
+ font-size: var(--zx-text-sm);
63
+ }
64
+
65
+ .zx-filter-panel__empty {
66
+ margin: 0;
67
+ border: 1px dashed var(--zx-color-border);
68
+ border-radius: var(--zx-radius-md);
69
+ color: var(--zx-color-text-muted);
70
+ padding: var(--zx-space-5) var(--zx-space-4);
71
+ text-align: center;
72
+ }
73
+
74
+ .zx-filter-panel__actions {
75
+ display: flex;
76
+ flex-wrap: wrap;
77
+ justify-content: end;
78
+ align-items: center;
79
+ gap: var(--zx-space-2);
80
+ border-block-start: 1px solid var(--zx-color-border);
81
+ padding-block-start: var(--zx-space-3);
82
+ }
83
+
84
+ /* Draft mode with unapplied edits: the Apply button is the one thing still to do. */
85
+ .zx-filter-panel[data-state="dirty"] .zx-filter-panel__apply {
86
+ box-shadow: var(--zx-focus-ring);
87
+ }
88
+
89
+ .zx-filter-panel .zx-checklist {
90
+ min-inline-size: 0;
91
+ }
92
+
93
+ /* The composed controls size themselves for a form row. A filter panel is often half that wide —
94
+ a sheet, a sidebar, one of two range edges — so inside it they stretch and are allowed to
95
+ shrink instead of overflowing the column they were given. */
96
+ .zx-filter-panel .zx-number-field {
97
+ display: flex;
98
+ inline-size: 100%;
99
+ min-inline-size: 0;
100
+ }
101
+
102
+ .zx-filter-panel .zx-number-field__input {
103
+ min-inline-size: 0;
104
+ }