@zeyos/zx 4.4.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.
Files changed (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  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 +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -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 {Array<{value:string|number|boolean,label:string,icon?:Node|string,color?:string}>} [choices] Static choices.
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
- * @property {'standard'|'compact'} [layout='standard'] Condition layout; compact rows adapt to container width.
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;
@@ -295,7 +325,7 @@ export class Filter extends Component {
295
325
  actionButton('add', this._value.root.id, 'plus', this._message('filter.addFilter', 'Add filter'), { ref: 'add', disabled: this._blocked() }),
296
326
  this.options.allowGroups ? actionButton('add-group', this._value.root.id, 'folder', this._message('filter.addGroup', 'Add group'), { disabled: this._blocked() }) : null,
297
327
  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')), { class: 'zx-button--primary', disabled: this._disabled }) : null
328
+ this.options.showApply ? actionButton('apply', '', 'check', String(this.options.applyLabel ?? this._message('filter.apply', 'Apply filters')), { kind: 'primary', disabled: this._disabled }) : null
299
329
  );
300
330
  this.el.replaceChildren(status, tree, actions);
301
331
  this.refs.status = status;
@@ -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) => node.kind === 'group' ? this._renderGroup(node, depth + 1, false) : this._renderCondition(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
- root ? null : actionButton('remove', group.id, 'trash', this._message('filter.removeGroup', 'Remove group'), { disabled: this._blocked() })
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 (target instanceof HTMLSelectElement && target.multiple) {
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
 
@@ -631,9 +789,141 @@ export class Filter extends Component {
631
789
  }
632
790
  }
633
791
 
634
- /** @param {string} action @param {string} id @param {string} iconName @param {string} label @param {Record<string,any>} [props={}] @returns {HTMLElement} */
792
+ /** @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} */
635
793
  function actionButton(action, id, iconName, label, props = {}) {
636
- 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));
794
+ const { kind, ...rest } = props;
795
+ return h('button', {
796
+ type: 'button', class: 'zx-btn', ...rest,
797
+ dataset: { filterAction: action, nodeId: id, size: 'sm', ...(kind ? { kind } : {}) }
798
+ }, icon(iconName), h('span', {}, label));
799
+ }
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;
637
927
  }
638
928
 
639
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} */
@@ -665,6 +955,12 @@ function valueInput(field, id, value, index, disabled, many = false, messages =
665
955
  input, listId ? h('datalist', { id: listId }) : null);
666
956
  }
667
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)); }
668
964
  /** @param {unknown} value @param {string} type @returns {any} */
669
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; }
670
966
  /** @param {unknown} value @returns {any} */
@@ -672,7 +968,7 @@ function parseValueJson(value) { return cloneFilterValue(value === undefined ? n
672
968
  /** @param {any} root @param {string} id @returns {any|null} */
673
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; }
674
970
  /** @param {any} root @param {string} id @returns {any|null} */
675
- 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; }
676
972
  /** @param {any} root @param {string} id @returns {any|null} */
677
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; }
678
974
  /** @param {any} root @param {string} id @returns {any|null} */
@@ -26,7 +26,7 @@
26
26
  .zx-filter-panel__label {
27
27
  color: var(--zx-color-text-muted);
28
28
  font-size: var(--zx-text-sm);
29
- font-weight: 650;
29
+ font-weight: var(--zx-weight-medium);
30
30
  }
31
31
 
32
32
  .zx-filter-panel__input {