@kubex/zinc 1.1.185 → 2.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 (358) hide show
  1. package/.browserslistrc +2 -0
  2. package/.github/workflows/js_build_and_deploy.yaml +54 -1
  3. package/.github/workflows/test.yaml +27 -0
  4. package/dist/chunks/zn.UWJLIXDT.js +1 -0
  5. package/dist/custom-elements.json +9998 -2345
  6. package/dist/vscode.html-custom-data.json +1022 -67
  7. package/dist/web-types.json +2129 -177
  8. package/dist/zn.d.ts +2293 -416
  9. package/dist/zn.min.css +1 -1
  10. package/dist/zn.min.js +2310 -1637
  11. package/docs/_includes/default.njk +4 -1
  12. package/docs/assets/scripts/docs.js +22 -6
  13. package/docs/assets/styles/docs.css +9 -2
  14. package/docs/data/report-table-day-compare.json +380 -0
  15. package/docs/data/report-table.json +314 -0
  16. package/docs/pages/components/avatar-group.md +57 -0
  17. package/docs/pages/components/badge.md +67 -0
  18. package/docs/pages/components/button.md +41 -1
  19. package/docs/pages/components/chat-message.md +16 -0
  20. package/docs/pages/components/checkbox.md +1 -1
  21. package/docs/pages/components/chip.md +25 -13
  22. package/docs/pages/components/color-picker.md +37 -0
  23. package/docs/pages/components/copy-button.md +2 -2
  24. package/docs/pages/components/data-table-filter.md +33 -50
  25. package/docs/pages/components/data-table-search.md +4 -4
  26. package/docs/pages/components/data-table.md +27 -3
  27. package/docs/pages/components/date-range.md +116 -0
  28. package/docs/pages/components/dialog.md +29 -37
  29. package/docs/pages/components/diff-row.md +28 -0
  30. package/docs/pages/components/diff.md +46 -0
  31. package/docs/pages/components/editor.md +55 -0
  32. package/docs/pages/components/icon.md +46 -1
  33. package/docs/pages/components/input.md +30 -11
  34. package/docs/pages/components/item.md +35 -0
  35. package/docs/pages/components/key-value.md +25 -0
  36. package/docs/pages/components/menu.md +74 -7
  37. package/docs/pages/components/nav-item.md +15 -0
  38. package/docs/pages/components/nav-list.md +132 -0
  39. package/docs/pages/components/navbar.md +100 -1
  40. package/docs/pages/components/page.md +242 -74
  41. package/docs/pages/components/priority-list.md +43 -5
  42. package/docs/pages/components/query-builder.md +23 -0
  43. package/docs/pages/components/radio-group.md +33 -1
  44. package/docs/pages/components/radio.md +1 -1
  45. package/docs/pages/components/report-table.md +202 -0
  46. package/docs/pages/components/stat.md +30 -0
  47. package/docs/pages/components/tile.md +11 -0
  48. package/docs/pages/components/tooltip.md +1 -1
  49. package/docs/pages/components/translation-grid.md +86 -0
  50. package/docs/pages/components/vertical-stepper.md +108 -64
  51. package/package.json +2 -1
  52. package/scss/_global-spacing.scss +2 -2
  53. package/scss/_root.scss +11 -171
  54. package/scss/boot.scss +14 -0
  55. package/scss/mixins/_helper-mixins.scss +24 -2
  56. package/scss/mixins/_motion-mixins.scss +126 -0
  57. package/scss/mixins/_spacing-mixins.scss +4 -3
  58. package/scss/mixins/_tab-indicator-mixins.scss +12 -0
  59. package/scss/mixins/_typography-mixins.scss +1 -1
  60. package/scss/mixins/index.scss +14 -0
  61. package/scss/shared/_base.scss +32 -1
  62. package/scss/shared/_button-mixin.scss +7 -5
  63. package/scss/shared/_table.scss +35 -4
  64. package/scss/shared/nav.scss +4 -0
  65. package/scss/shared/shimmer.scss +1 -1
  66. package/scss/table-controls.scss +206 -0
  67. package/scss/themes/_dark.scss +1 -3
  68. package/scss/themes/_index.scss +1 -1
  69. package/scss/themes/_light.scss +46 -6
  70. package/scss/themes/_modes.scss +11 -11
  71. package/scss/themes/_openbyte.scss +944 -0
  72. package/scss/themes/_typography.scss +4 -2
  73. package/scss/variables/index.scss +0 -9
  74. package/src/components/alert/alert.component.ts +14 -4
  75. package/src/components/alert/alert.scss +55 -69
  76. package/src/components/alert/alert.test.ts +8 -0
  77. package/src/components/animated-button/animated-button.component.ts +3 -2
  78. package/src/components/animated-button/animated-button.scss +6 -23
  79. package/src/components/animated-button/animated-button.test.ts +4 -3
  80. package/src/components/avatar-group/avatar-group.component.ts +84 -0
  81. package/src/components/avatar-group/avatar-group.scss +54 -0
  82. package/src/components/avatar-group/avatar-group.test.ts +59 -0
  83. package/src/components/avatar-group/index.ts +12 -0
  84. package/src/components/badge/badge.component.ts +49 -0
  85. package/src/components/badge/badge.scss +80 -0
  86. package/src/components/badge/badge.test.ts +34 -0
  87. package/src/components/badge/index.ts +12 -0
  88. package/src/components/bulk-actions/bulk-actions.component.ts +14 -2
  89. package/src/components/button/button.component.ts +228 -8
  90. package/src/components/button/button.scss +115 -32
  91. package/src/components/button/button.test.ts +122 -0
  92. package/src/components/button-menu/button-menu.component.ts +32 -11
  93. package/src/components/button-menu/button-menu.scss +5 -1
  94. package/src/components/channel-tile/channel-tile.component.ts +34 -27
  95. package/src/components/channel-tile/channel-tile.scss +76 -43
  96. package/src/components/chart/builders.test.ts +46 -0
  97. package/src/components/chart/builders.ts +27 -3
  98. package/src/components/chart/chart-live.test.ts +4 -6
  99. package/src/components/chart/chart.component.ts +125 -5
  100. package/src/components/chart/chart.scss +121 -0
  101. package/src/components/chart/chart.test.ts +81 -0
  102. package/src/components/chat-message/chat-message.component.ts +21 -1
  103. package/src/components/chat-message/chat-message.scss +13 -2
  104. package/src/components/chat-message/chat-message.test.ts +9 -0
  105. package/src/components/checkbox/checkbox.component.ts +61 -27
  106. package/src/components/checkbox/checkbox.scss +58 -25
  107. package/src/components/checkbox-group/checkbox-group.component.ts +7 -1
  108. package/src/components/chip/chip.component.ts +11 -1
  109. package/src/components/chip/chip.scss +91 -26
  110. package/src/components/chip/chip.test.ts +17 -0
  111. package/src/components/collapsible/collapsible.component.ts +17 -3
  112. package/src/components/collapsible/collapsible.scss +19 -10
  113. package/src/components/color-picker/color-picker.component.ts +277 -0
  114. package/src/components/color-picker/color-picker.scss +141 -0
  115. package/src/components/color-picker/color-picker.test.ts +57 -0
  116. package/src/components/color-picker/index.ts +12 -0
  117. package/src/components/cols/cols.component.ts +10 -0
  118. package/src/components/cols/cols.test.ts +17 -0
  119. package/src/components/confirm/confirm.component.ts +20 -6
  120. package/src/components/confirm/confirm.test.ts +52 -0
  121. package/src/components/content-block/content-block.component.ts +38 -9
  122. package/src/components/content-block/content-block.scss +11 -5
  123. package/src/components/content-block/content-block.test.ts +26 -0
  124. package/src/components/copy-button/copy-button.component.ts +2 -2
  125. package/src/components/copy-button/copy-button.scss +17 -4
  126. package/src/components/data-select/data-select.component.ts +3 -1
  127. package/src/components/data-table/data-table.component.ts +362 -140
  128. package/src/components/data-table/data-table.scss +133 -60
  129. package/src/components/data-table/data-table.test.ts +356 -4
  130. package/src/components/data-table-filter/data-table-filter.component.ts +426 -406
  131. package/src/components/data-table-filter/data-table-filter.scss +176 -148
  132. package/src/components/data-table-filter/data-table-filter.test.ts +271 -273
  133. package/src/components/data-table-search/data-table-search.component.ts +4 -2
  134. package/src/components/data-table-search/data-table-search.scss +8 -1
  135. package/src/components/data-table-search/data-table-search.test.ts +29 -0
  136. package/src/components/date-range/date-range-math.test.ts +78 -0
  137. package/src/components/date-range/date-range-math.ts +105 -0
  138. package/src/components/date-range/date-range.component.ts +375 -0
  139. package/src/components/date-range/date-range.scss +272 -0
  140. package/src/components/date-range/date-range.test.ts +247 -0
  141. package/src/components/date-range/index.ts +12 -0
  142. package/src/components/datepicker/datepicker.component.ts +44 -7
  143. package/src/components/datepicker/datepicker.scss +9 -0
  144. package/src/components/datepicker/datepicker.test.ts +2 -2
  145. package/src/components/dialog/dialog.component.ts +38 -19
  146. package/src/components/dialog/dialog.scss +65 -36
  147. package/src/components/dialog/dialog.test.ts +14 -0
  148. package/src/components/diff/diff.component.ts +55 -0
  149. package/src/components/diff/diff.scss +46 -0
  150. package/src/components/diff/diff.test.ts +15 -0
  151. package/src/components/diff/index.ts +12 -0
  152. package/src/components/diff-row/diff-row.component.ts +75 -0
  153. package/src/components/diff-row/diff-row.scss +70 -0
  154. package/src/components/diff-row/diff-row.test.ts +37 -0
  155. package/src/components/diff-row/index.ts +12 -0
  156. package/src/components/dropdown/dropdown.component.ts +12 -10
  157. package/src/components/dropdown/dropdown.scss +2 -0
  158. package/src/components/editor/editor.component.ts +159 -11
  159. package/src/components/editor/editor.scss +250 -1
  160. package/src/components/editor/editor.test.ts +31 -3
  161. package/src/components/editor/modules/ai/panel/ai-panel.scss +0 -1
  162. package/src/components/editor/modules/attachment/attachment.ts +53 -31
  163. package/src/components/editor/modules/context-menu/context-menu.ts +17 -0
  164. package/src/components/editor/modules/dialog/dialog.component.ts +23 -1
  165. package/src/components/editor/modules/dialog/dialog.test.ts +28 -0
  166. package/src/components/editor/modules/emoji/emoji.ts +4 -5
  167. package/src/components/editor/modules/merge-field/merge-field.ts +83 -0
  168. package/src/components/editor/modules/toolbar/toolbar.component.ts +121 -68
  169. package/src/components/editor/modules/toolbar/toolbar.scss +38 -0
  170. package/src/components/editor/modules/toolbar/toolbar.ts +25 -1
  171. package/src/components/empty-state/empty-state.component.ts +6 -1
  172. package/src/components/empty-state/empty-state.scss +21 -4
  173. package/src/components/expanding-action/expanding-action.component.ts +22 -34
  174. package/src/components/expanding-action/expanding-action.scss +3 -45
  175. package/src/components/expanding-action/expanding-action.test.ts +2 -1
  176. package/src/components/file/file.component.ts +30 -2
  177. package/src/components/file/file.scss +6 -0
  178. package/src/components/flow-builder/flow-builder.component.ts +36 -3
  179. package/src/components/flow-builder/flow-builder.scss +0 -6
  180. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +8 -0
  181. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +10 -5
  182. package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +17 -0
  183. package/src/components/flow-builder/modules/flow-node/flow-node.scss +10 -0
  184. package/src/components/form-actions/form-actions.component.ts +48 -2
  185. package/src/components/form-actions/form-actions.scss +1 -1
  186. package/src/components/form-group/form-group.component.ts +10 -3
  187. package/src/components/form-group/form-group.scss +37 -4
  188. package/src/components/form-group/form-group.test.ts +10 -0
  189. package/src/components/header/header.component.ts +11 -4
  190. package/src/components/header/header.scss +47 -5
  191. package/src/components/hover-container/hover-container.component.ts +4 -5
  192. package/src/components/icon/icon.component.ts +95 -9
  193. package/src/components/icon/icon.scss +70 -6
  194. package/src/components/icon/icon.test.ts +55 -0
  195. package/src/components/icon-picker/icon-picker.component.ts +35 -1
  196. package/src/components/inline-edit/inline-edit.component.ts +38 -8
  197. package/src/components/inline-edit/inline-edit.scss +128 -19
  198. package/src/components/input/input.component.ts +173 -297
  199. package/src/components/input/input.scss +65 -43
  200. package/src/components/input/input.test.ts +86 -4
  201. package/src/components/item/item.component.ts +12 -0
  202. package/src/components/item/item.scss +102 -3
  203. package/src/components/item/item.test.ts +23 -0
  204. package/src/components/key/key.component.ts +2 -2
  205. package/src/components/key/key.scss +11 -0
  206. package/src/components/key-value/index.ts +12 -0
  207. package/src/components/key-value/key-value.component.ts +160 -0
  208. package/src/components/key-value/key-value.scss +20 -0
  209. package/src/components/key-value/key-value.test.ts +22 -0
  210. package/src/components/markdown-editor/markdown-editor.component.ts +8 -1
  211. package/src/components/menu/menu.component.ts +21 -2
  212. package/src/components/menu/menu.scss +43 -23
  213. package/src/components/menu/menu.test.ts +33 -0
  214. package/src/components/menu-item/menu-item.component.ts +13 -2
  215. package/src/components/menu-item/menu-item.scss +56 -23
  216. package/src/components/menu-item/menu-item.test.ts +31 -0
  217. package/src/components/menu-item/submenu-controller.ts +25 -15
  218. package/src/components/menu-label/index.ts +12 -0
  219. package/src/components/menu-label/menu-label.component.ts +30 -0
  220. package/src/components/menu-label/menu-label.scss +18 -0
  221. package/src/components/nav-item/index.ts +12 -0
  222. package/src/components/nav-item/nav-item.component.ts +83 -0
  223. package/src/components/nav-item/nav-item.scss +128 -0
  224. package/src/components/nav-list/index.ts +12 -0
  225. package/src/components/nav-list/nav-list.component.ts +313 -0
  226. package/src/components/nav-list/nav-list.scss +87 -0
  227. package/src/components/nav-list/nav-list.test.ts +250 -0
  228. package/src/components/navbar/navbar.component.ts +235 -171
  229. package/src/components/navbar/navbar.scss +333 -169
  230. package/src/components/navbar/navbar.test.ts +63 -143
  231. package/src/components/page/page.component.ts +114 -29
  232. package/src/components/page/page.scss +223 -95
  233. package/src/components/page/page.test.ts +214 -3
  234. package/src/components/page-builder/page-builder.component.ts +51 -5
  235. package/src/components/page-builder/page-builder.scss +6 -1
  236. package/src/components/page-nav/page-nav.component.ts +25 -3
  237. package/src/components/page-nav/page-nav.scss +10 -2
  238. package/src/components/pagination/pagination.scss +1 -1
  239. package/src/components/panel/panel.component.ts +14 -13
  240. package/src/components/panel/panel.scss +14 -10
  241. package/src/components/panel/panel.test.ts +42 -0
  242. package/src/components/popup/popup.component.ts +20 -13
  243. package/src/components/popup/popup.test.ts +21 -1
  244. package/src/components/priority-list/priority-list.component.ts +157 -172
  245. package/src/components/priority-list/priority-list.scss +113 -17
  246. package/src/components/priority-list/priority-list.test.ts +301 -3
  247. package/src/components/progress-bar/progress-bar.component.ts +1 -1
  248. package/src/components/progress-bar/progress-bar.scss +16 -4
  249. package/src/components/progress-tile/progress-tile.scss +1 -1
  250. package/src/components/query-builder/query-builder.component.ts +85 -10
  251. package/src/components/query-builder/query-builder.scss +7 -1
  252. package/src/components/query-builder/query-builder.test.ts +40 -0
  253. package/src/components/radio/radio.component.ts +57 -18
  254. package/src/components/radio/radio.scss +101 -26
  255. package/src/components/radio-group/radio-group.component.ts +45 -7
  256. package/src/components/radio-group/radio-group.scss +17 -0
  257. package/src/components/radio-group/radio-group.test.ts +63 -2
  258. package/src/components/rating/rating.component.ts +23 -0
  259. package/src/components/rating/rating.scss +1 -1
  260. package/src/components/remarkd-editor/remarkd-editor.component.ts +60 -5
  261. package/src/components/remarkd-editor/remarkd-editor.scss +12 -4
  262. package/src/components/report-table/index.ts +12 -0
  263. package/src/components/report-table/report-format.test.ts +86 -0
  264. package/src/components/report-table/report-format.ts +72 -0
  265. package/src/components/report-table/report-math.test.ts +74 -0
  266. package/src/components/report-table/report-math.ts +252 -0
  267. package/src/components/report-table/report-table.component.ts +681 -0
  268. package/src/components/report-table/report-table.scss +328 -0
  269. package/src/components/report-table/report-table.test.ts +702 -0
  270. package/src/components/reveal/reveal.component.ts +15 -1
  271. package/src/components/reveal/reveal.scss +5 -0
  272. package/src/components/schedule-builder/schedule-builder.component.ts +23 -8
  273. package/src/components/scroll-container/scroll-container.component.ts +19 -1
  274. package/src/components/scroll-container/scroll-container.scss +6 -1
  275. package/src/components/select/select.component.ts +52 -36
  276. package/src/components/select/select.scss +42 -25
  277. package/src/components/select/select.test.ts +14 -2
  278. package/src/components/settings-container/settings-container.scss +18 -5
  279. package/src/components/sidebar/sidebar.component.ts +8 -1
  280. package/src/components/sidebar/sidebar.scss +6 -1
  281. package/src/components/skeleton/skeleton.component.ts +3 -3
  282. package/src/components/skeleton/skeleton.scss +6 -6
  283. package/src/components/skeleton/skeleton.test.ts +9 -0
  284. package/src/components/slash-menu/slash-menu.scss +1 -1
  285. package/src/components/slideout/slideout.component.ts +20 -3
  286. package/src/components/slideout/slideout.scss +13 -6
  287. package/src/components/slideout/slideout.test.ts +19 -0
  288. package/src/components/split-button/split-button.component.ts +1 -1
  289. package/src/components/split-pane/split-pane.component.ts +71 -8
  290. package/src/components/split-pane/split-pane.scss +145 -41
  291. package/src/components/split-pane/split-pane.test.ts +23 -3
  292. package/src/components/stat/stat.component.ts +209 -14
  293. package/src/components/stat/stat.scss +305 -5
  294. package/src/components/stat/stat.test.ts +143 -2
  295. package/src/components/status-indicator/status-indicator.scss +22 -21
  296. package/src/components/stepper/stepper.scss +3 -3
  297. package/src/components/tab/tab.component.ts +1 -0
  298. package/src/components/table/table.component.ts +12 -1
  299. package/src/components/table/table.scss +21 -13
  300. package/src/components/table/table.test.ts +17 -0
  301. package/src/components/tabs/tabs.component.ts +150 -37
  302. package/src/components/tabs/tabs.scss +48 -7
  303. package/src/components/tabs/tabs.test.ts +169 -0
  304. package/src/components/textarea/textarea.component.ts +25 -3
  305. package/src/components/textarea/textarea.scss +10 -0
  306. package/src/components/textarea/textarea.test.ts +8 -4
  307. package/src/components/theme-editor/theme-editor.scss +0 -6
  308. package/src/components/tile/tile.component.ts +32 -2
  309. package/src/components/tile/tile.scss +40 -7
  310. package/src/components/tile/tile.test.ts +10 -0
  311. package/src/components/toggle/toggle.component.ts +23 -7
  312. package/src/components/toggle/toggle.scss +36 -29
  313. package/src/components/toggle/toggle.test.ts +17 -0
  314. package/src/components/tooltip/tooltip.component.ts +3 -0
  315. package/src/components/tooltip/tooltip.scss +11 -6
  316. package/src/components/tooltip/tooltip.test.ts +12 -0
  317. package/src/components/translation-field/index.ts +12 -0
  318. package/src/components/translation-field/translation-field.component.ts +55 -0
  319. package/src/components/translation-field/translation-field.scss +101 -0
  320. package/src/components/translation-fields/index.ts +12 -0
  321. package/src/components/translation-fields/translation-fields.component.ts +80 -0
  322. package/src/components/translation-fields/translation-fields.scss +60 -0
  323. package/src/components/translation-grid/index.ts +12 -0
  324. package/src/components/translation-grid/translation-grid.component.ts +157 -0
  325. package/src/components/translation-grid/translation-grid.scss +138 -0
  326. package/src/components/translation-grid/translation-grid.test.ts +28 -0
  327. package/src/components/vertical-stepper/vertical-stepper.component.ts +35 -9
  328. package/src/components/vertical-stepper/vertical-stepper.scss +110 -39
  329. package/src/components/vertical-stepper/vertical-stepper.test.ts +35 -0
  330. package/src/components/well/well.scss +2 -2
  331. package/src/events/events.ts +2 -0
  332. package/src/events/zn-chart-click.ts +13 -0
  333. package/src/events/zn-report-change.ts +7 -0
  334. package/src/form-control.scss +36 -3
  335. package/src/internal/anchored-popover.ts +175 -0
  336. package/src/internal/animate.ts +19 -5
  337. package/src/internal/cosmic-border.ts +15 -0
  338. package/src/internal/form.ts +1 -1
  339. package/src/internal/hover-intent.ts +15 -0
  340. package/src/internal/input-modality.ts +15 -0
  341. package/src/internal/pointer-intent.ts +37 -0
  342. package/src/internal/reorder.ts +625 -0
  343. package/src/internal/scroll.ts +51 -3
  344. package/src/internal/selection-card.ts +1 -1
  345. package/src/internal/tab-indicator.ts +92 -0
  346. package/src/internal/table-sort.ts +25 -0
  347. package/src/internal/table-tabs.ts +108 -0
  348. package/src/internal/test-drag.ts +35 -0
  349. package/src/internal/theme-openbyte.test.ts +126 -0
  350. package/src/internal/theme.ts +4 -4
  351. package/src/internal/zinc-element.ts +2 -2
  352. package/src/selection-card.scss +25 -0
  353. package/src/types/web-test-runner-commands.d.ts +1 -0
  354. package/src/utilities/color.test.ts +43 -0
  355. package/src/utilities/color.ts +189 -0
  356. package/src/zinc.ts +14 -0
  357. package/.github/workflows/eleventy_build.yml +0 -42
  358. package/scss/themes/_aura.scss +0 -114
@@ -1,9 +1,9 @@
1
+ import {AnchoredPopoverController} from '../../internal/anchored-popover';
1
2
  import {classMap} from 'lit/directives/class-map.js';
2
3
  import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit';
3
4
  import {FormControlController, validValidityState} from "../../internal/form";
4
5
  import {
5
6
  operatorText,
6
- type QueryBuilderData,
7
7
  type QueryBuilderDateSubmitFormat,
8
8
  type QueryBuilderItem,
9
9
  QueryBuilderOperators,
@@ -12,92 +12,114 @@ import {
12
12
  import {property, state} from 'lit/decorators.js';
13
13
  import ZincElement, {type ZincFormControl} from '../../internal/zinc-element';
14
14
  import ZnButton from "../button";
15
- import ZnDatepicker from "../datepicker";
16
- import ZnDropdown from "../dropdown";
15
+ import ZnIcon from "../icon";
17
16
  import ZnInput from "../input";
18
- import ZnMenu from "../menu";
19
- import ZnMenuItem from "../menu-item";
17
+ import ZnOptGroup from "../opt-group";
18
+ import ZnOption from "../option";
19
+ import ZnSelect from "../select";
20
20
  import type {ZnInputEvent} from "../../events/zn-input";
21
21
 
22
22
  import styles from './data-table-filter.scss';
23
23
 
24
- interface ActiveFilter {
24
+ /** A field offered by the filter: a zn-query-builder filter whose operators may be left out. */
25
+ export type FilterField = Omit<QueryBuilderItem, 'operators'> & {operators?: QueryBuilderItem['operators'] | string[]};
26
+
27
+ interface FilterRule {
25
28
  key: string;
26
- comparator: QueryBuilderOperators;
29
+ comparator: string;
27
30
  value: string;
28
- /** Display text for values the wire format renders unreadable, such as dates. */
29
- label?: string;
30
- /** Kept so a date can be re-encoded when the comparator changes. */
31
+ /** Kept so a date can be re-encoded when the operator changes. */
31
32
  timestamp?: number;
32
33
  }
33
34
 
35
+ export const FILTER_OPERATOR_LABELS: Record<string, string> = {
36
+ eq: 'is',
37
+ neq: 'is not',
38
+ in: 'is any of',
39
+ nin: 'is none of',
40
+ contains: 'contains',
41
+ like: 'is like',
42
+ starts: 'starts with',
43
+ gt: 'is more than',
44
+ gte: 'is at least',
45
+ lt: 'is less than',
46
+ lte: 'is at most',
47
+ before: 'is before',
48
+ after: 'is after',
49
+ };
50
+
34
51
  const BOOLEAN_OPTIONS: QueryBuilderOptions = {1: 'True', 0: 'False'};
35
- const TEXT_DEBOUNCE = 350;
36
52
  const SUGGESTION_LIMIT = 8;
37
- const OPTION_SEARCH_THRESHOLD = 8;
38
-
39
- // Pills have to stay short, so the wordier operators get a compact label
40
- const OPERATOR_LABELS: Partial<Record<QueryBuilderOperators, string>> = {
41
- [QueryBuilderOperators.Eq]: 'is',
42
- [QueryBuilderOperators.Neq]: 'is not',
43
- [QueryBuilderOperators.Gt]: '>',
44
- [QueryBuilderOperators.Gte]: '\u2265',
45
- [QueryBuilderOperators.Lt]: '<',
46
- [QueryBuilderOperators.Lte]: '\u2264',
47
- [QueryBuilderOperators.In]: 'is any of',
48
- [QueryBuilderOperators.Nin]: 'is none of',
49
- [QueryBuilderOperators.Before]: 'before',
50
- [QueryBuilderOperators.After]: 'after',
51
- };
53
+ const FIELD_SEARCH_THRESHOLD = 8;
52
54
 
53
55
  /**
54
- * @summary An inline filter bar for data tables. Each active filter is a pill whose dropdown sets its value.
56
+ * @summary The filter for data tables: a funnel that opens a field / operator / value builder, and chips for the
57
+ * rules that apply.
55
58
  * @documentation https://zinc.style/components/data-table-filter
56
59
  * @status experimental
57
60
  * @since 1.0
58
61
  *
59
62
  * @dependency zn-button
60
- * @dependency zn-datepicker
61
- * @dependency zn-dropdown
63
+ * @dependency zn-icon
62
64
  * @dependency zn-input
63
- * @dependency zn-menu
64
- * @dependency zn-menu-item
65
+ * @dependency zn-opt-group
66
+ * @dependency zn-option
67
+ * @dependency zn-select
65
68
  *
66
- * @event zn-filter-change - Emitted when the active filters change. Read the encoded query off `value`.
69
+ * @event zn-filter-change - Emitted when the applied rules change. Read the encoded query off `value`.
70
+ * @event zn-clear - Emitted when `clear()` removes every rule.
67
71
  */
68
72
  export default class ZnDataTableFilter extends ZincElement implements ZincFormControl {
69
73
  static styles: CSSResultGroup = unsafeCSS(styles);
70
74
  static dependencies = {
71
75
  'zn-button': ZnButton,
72
- 'zn-datepicker': ZnDatepicker,
73
- 'zn-dropdown': ZnDropdown,
76
+ 'zn-icon': ZnIcon,
74
77
  'zn-input': ZnInput,
75
- 'zn-menu': ZnMenu,
76
- 'zn-menu-item': ZnMenuItem,
78
+ 'zn-opt-group': ZnOptGroup,
79
+ 'zn-option': ZnOption,
80
+ 'zn-select': ZnSelect,
77
81
  };
78
82
 
79
- private _formController: FormControlController = new FormControlController(this, {});
80
- private _textTimeout?: number;
81
- private _pendingOpen?: string;
83
+ // A chips-only filter mirrors another, so it must not submit a second copy of its value
84
+ private _formController: FormControlController = new FormControlController(this, {
85
+ disabled: () => !!this.source,
86
+ });
82
87
 
83
- @property({type: Array}) filters: QueryBuilderData = [];
88
+ private readonly _popover = new AnchoredPopoverController(this, {
89
+ trigger: '.filter__toggle',
90
+ popover: '.filter__pop',
91
+ width: () => Math.min(704, window.innerWidth * 0.94),
92
+ align: 'end',
93
+ focus: ['.filter__rule:last-of-type zn-select[aria-label="Filter field"]'],
94
+ onHide: () => this.discard(),
95
+ });
96
+
97
+ private _mirrors = new Set<ZnDataTableFilter>();
98
+ private _emitted?: string;
99
+
100
+ @property({type: Array}) filters: FilterField[] = [];
84
101
 
85
102
  @property() name: string = "data-table-filter";
86
103
 
87
104
  @property() value: string = '';
88
105
 
89
- /** Filter keys to show a pill for before the user adds any. */
106
+ /** Fields to offer an empty rule for when the builder opens with nothing applied. */
90
107
  @property({attribute: 'default-filters'}) defaultFilters: string = '';
91
108
 
92
- /** Typeahead values per filter key. `zn-data-table` fills this from the rows it has loaded. */
109
+ /** Typeahead values per field. `zn-data-table` fills this from the rows it has loaded. */
93
110
  @property({attribute: false}) suggestions: Record<string, string[]> = {};
94
111
 
95
- @state() private _active: ActiveFilter[] = [];
112
+ /**
113
+ * Another filter to show the chips of. This element then renders only that filter's chips, and that filter stops
114
+ * rendering its own, so a table can keep the funnel in its header and the chips under it.
115
+ */
116
+ @property({attribute: false}) source: ZnDataTableFilter | null = null;
117
+
118
+ @state() private _rules: FilterRule[] = [];
96
119
 
97
- // What the user has typed so far, which the debounced value hasn't caught up with yet
98
- @state() private _typing: Record<string, string> = {};
120
+ @state() private _typing: {index: number; text: string} | null = null;
99
121
 
100
- @state() private _optionQuery: Record<string, string> = {};
122
+ @state() private _fieldSearch: number | null = null;
101
123
 
102
124
  get validationMessage(): string {
103
125
  return '';
@@ -123,235 +145,222 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
123
145
  this._formController.updateValidity();
124
146
  }
125
147
 
126
- protected firstUpdated(_changedProperties: PropertyValues) {
127
- super.firstUpdated(_changedProperties);
128
- this._formController.updateValidity();
148
+ /** Number of rules that apply. */
149
+ get activeCount(): number {
150
+ return this.committed().length;
129
151
  }
130
152
 
131
- protected updated(changed: PropertyValues) {
132
- super.updated(changed);
153
+ /** Opens the filter builder. */
154
+ show() {
155
+ if (this._rules.length === 0) {
156
+ const defaults = this.defaultFilters.split(',').map(key => key.trim()).filter(key => this.field(key));
157
+ this._rules = defaults.length > 0
158
+ ? defaults.map(key => ({key, comparator: this.operators(this.field(key)!)[0], value: ''}))
159
+ : [{key: '', comparator: '', value: ''}];
160
+ }
161
+ this._popover.show();
162
+ }
133
163
 
134
- const key = this._pendingOpen;
135
- if (!key) return;
136
- this._pendingOpen = undefined;
164
+ /** Closes the filter builder. */
165
+ hide() {
166
+ this._popover.hide();
167
+ }
137
168
 
138
- // The pill has only just rendered, so let it upgrade before anchoring a panel to it
139
- requestAnimationFrame(() => {
140
- const pill = [...this.shadowRoot!.querySelectorAll('zn-dropdown')]
141
- .find(dropdown => dropdown.dataset.key === key);
169
+ /** Removes every rule. */
170
+ clear() {
171
+ this.setRules([]);
172
+ this.emit('zn-clear');
173
+ }
142
174
 
143
- // The panel animates in, so the field can only take focus once it has settled
144
- void pill?.show().then(() => requestAnimationFrame(() => pill.querySelector('zn-input')?.focus()));
145
- });
175
+ protected firstUpdated(changed: PropertyValues) {
176
+ super.firstUpdated(changed);
177
+ this._formController.updateValidity();
146
178
  }
147
179
 
148
180
  protected willUpdate(changed: PropertyValues) {
149
181
  super.willUpdate(changed);
150
182
 
151
- // Rebuild the active filters from an externally-set value (e.g. restored from a shareable URL)
152
- // once both the value and the filters schema are available. Must run before the default-filters
153
- // seeding so a shared filter wins over defaults.
154
- if (changed.has('value') || changed.has('filters')) {
155
- this.hydrateFromValue();
183
+ if (changed.has('source')) {
184
+ this.toggleAttribute('data-chips', !!this.source);
185
+ (changed.get('source') as ZnDataTableFilter | null | undefined)?.unmirror(this);
186
+ this.source?.mirror(this);
156
187
  }
157
188
 
158
- if (changed.has('filters') && this._active.length === 0) {
159
- this.defaultFilters
160
- .split(',')
161
- .map(key => key.trim())
162
- .filter(key => key.length > 0)
163
- .forEach(key => this.addFilter(key, false));
189
+ if (changed.has('value') && this.value !== this._emitted) {
190
+ this.readValue();
191
+ } else if (changed.has('filters')) {
192
+ this._rules = this._rules.map(rule => rule.timestamp ? rule : this.ruleFrom(rule));
164
193
  }
165
194
  }
166
195
 
196
+ protected updated(changed: PropertyValues) {
197
+ super.updated(changed);
198
+ this._mirrors.forEach(mirror => mirror.requestUpdate());
199
+ }
200
+
201
+ connectedCallback() {
202
+ super.connectedCallback();
203
+ this.source?.mirror(this);
204
+ }
205
+
167
206
  disconnectedCallback() {
168
207
  super.disconnectedCallback();
169
- if (this._textTimeout) {
170
- window.clearTimeout(this._textTimeout);
171
- }
208
+ this.source?.unmirror(this);
172
209
  }
173
210
 
174
- /** Number of filters with a value set. */
175
- get activeCount(): number {
176
- return this._active.filter(item => item.value.length > 0).length;
211
+ private mirror(chips: ZnDataTableFilter) {
212
+ this._mirrors.add(chips);
213
+ this.requestUpdate();
177
214
  }
178
215
 
179
- /** Removes every active filter. */
180
- clear() {
181
- this._active = [];
182
- this.emitChange();
183
- this.emit('zn-clear');
216
+ private unmirror(chips: ZnDataTableFilter) {
217
+ if (this._mirrors.delete(chips)) this.requestUpdate();
184
218
  }
185
219
 
186
- private definition(key: string): QueryBuilderItem | undefined {
187
- return this.filters.find((item: QueryBuilderItem) => item.id === key);
220
+ private readValue() {
221
+ const pending = this._popover.open ? this._rules.filter(rule => !this.isComplete(rule)) : [];
222
+ this._rules = [...this.decoded(), ...pending];
223
+ this._emitted = this.value;
188
224
  }
189
225
 
190
- private operatorsFor(filter: QueryBuilderItem): QueryBuilderOperators[] {
191
- return filter.operators?.length > 0 ? filter.operators : [QueryBuilderOperators.Eq];
226
+ /** The complete rules in `value`, which the builder's edits only reach once they are applied. */
227
+ private committed(): FilterRule[] {
228
+ return this.decoded().filter(rule => this.isComplete(rule));
192
229
  }
193
230
 
194
- private comparatorFor(filter: QueryBuilderItem): QueryBuilderOperators {
195
- return this.operatorsFor(filter)[0];
196
- }
231
+ private decoded(): FilterRule[] {
232
+ let decoded: unknown = [];
233
+ try {
234
+ decoded = this.value ? JSON.parse(atob(this.value)) : [];
235
+ } catch {
236
+ decoded = [];
237
+ }
197
238
 
198
- private operatorLabel(comparator: QueryBuilderOperators): string {
199
- return OPERATOR_LABELS[comparator] ?? operatorText[comparator].toLowerCase();
239
+ return Array.isArray(decoded)
240
+ ? decoded
241
+ .filter((entry: Partial<FilterRule> | null) => typeof entry?.key === 'string' && entry.key)
242
+ .map((entry: {key: string; comparator?: string; value?: unknown}) => this.ruleFrom(entry))
243
+ : [];
200
244
  }
201
245
 
202
- // `in`/`nin` take a set of values; every other comparator holds one
203
- private isMultiple(comparator: QueryBuilderOperators): boolean {
204
- return comparator === QueryBuilderOperators.In || comparator === QueryBuilderOperators.Nin;
246
+ private ruleFrom(entry: {key: string; comparator?: string; value?: unknown}): FilterRule {
247
+ const value = Array.isArray(entry.value) ? entry.value.join(' ') : String(entry.value ?? '');
248
+ const rule: FilterRule = {key: entry.key, comparator: entry.comparator ?? '', value};
249
+ const field = this.field(entry.key);
250
+ if (field && this.isDate(field) && value) {
251
+ rule.timestamp = this.parseDate(value, rule.comparator, field.dateSubmitFormat);
252
+ }
253
+ return rule;
205
254
  }
206
255
 
207
- private optionsFor(filter: QueryBuilderItem): QueryBuilderOptions | undefined {
208
- if (filter.options) return filter.options;
209
- if (filter.type === 'bool' || filter.type === 'boolean') return BOOLEAN_OPTIONS;
210
- return undefined;
256
+ private field(key: string): FilterField | undefined {
257
+ return this.filters.find(field => field.id === key);
211
258
  }
212
259
 
213
- private addFilter(key: string, emit = true) {
214
- if (this._active.some(item => item.key === key)) return;
260
+ private operators(field: FilterField): string[] {
261
+ return field.operators?.length ? field.operators : [QueryBuilderOperators.Eq];
262
+ }
215
263
 
216
- const filter = this.definition(key);
217
- if (!filter) return;
264
+ private operatorLabel(op: string): string {
265
+ return FILTER_OPERATOR_LABELS[op] ?? operatorText[op as QueryBuilderOperators]?.toLowerCase() ?? op;
266
+ }
218
267
 
219
- this._active = [...this._active, {key, comparator: this.comparatorFor(filter), value: ''}];
268
+ private optionsFor(field: FilterField): QueryBuilderOptions | undefined {
269
+ if (field.options) return field.options;
270
+ if (field.type === 'bool' || field.type === 'boolean') return BOOLEAN_OPTIONS;
271
+ return undefined;
272
+ }
220
273
 
221
- if (emit) {
222
- // Picking a filter and setting its value is one action, so carry straight on to the value
223
- this._pendingOpen = key;
224
- this.emitChange();
225
- }
274
+ private isMultiple(comparator: string): boolean {
275
+ return comparator === 'in' || comparator === 'nin';
226
276
  }
227
277
 
228
- private removeFilter(key: string) {
229
- this._active = this._active.filter(item => item.key !== key);
230
- this.emitChange();
278
+ private isDate(field: FilterField): boolean {
279
+ return field.type === 'date' || field.type === 'dateTime';
231
280
  }
232
281
 
233
- private setValue(key: string, value: string, label?: string, timestamp?: number) {
234
- this._active = this._active.map(item => item.key === key ? {...item, value, label, timestamp} : item);
235
- this.emitChange();
282
+ private humanize(value: string) {
283
+ return value.charAt(0).toUpperCase() + value.slice(1);
236
284
  }
237
285
 
238
- private setComparator(filter: QueryBuilderItem, active: ActiveFilter, comparator: QueryBuilderOperators) {
239
- // A set of values can't survive a move to a comparator that holds one
240
- const value = this.isMultiple(active.comparator) && !this.isMultiple(comparator)
241
- ? this.selectedValues(filter, active)[0] ?? ''
242
- : active.value;
286
+ private isComplete(rule: FilterRule): boolean {
287
+ return !!this.field(rule.key) && !!rule.comparator && rule.value !== '';
288
+ }
243
289
 
244
- this._active = this._active.map(item => item.key === active.key
245
- ? {
246
- ...item,
247
- comparator,
248
- value: item.timestamp
249
- ? this.serializeDate(item.timestamp, comparator, filter.dateSubmitFormat)
250
- : value,
251
- }
252
- : item);
253
- this.emitChange();
290
+ // Option keys keep the space-separated wire format the query builder uses; typed values can contain spaces of
291
+ // their own, so they only split on commas
292
+ private values(field: FilterField | undefined, value: string): string[] {
293
+ return value.split(field && this.optionsFor(field) ? ' ' : ',').map(item => item.trim()).filter(Boolean);
254
294
  }
255
295
 
256
- private toggleOption(filter: QueryBuilderItem, active: ActiveFilter, option: string) {
257
- if (!this.isMultiple(active.comparator)) {
258
- this.setValue(active.key, active.value === option ? '' : option);
259
- return;
296
+ private ruleText(rule: FilterRule): string {
297
+ const field = this.field(rule.key);
298
+ const name = field ? this.humanize(field.name) : rule.key;
299
+ const options = field && this.optionsFor(field);
300
+ let value = rule.value;
301
+ if (field && this.isDate(field) && rule.timestamp) {
302
+ value = this.dateInputValue(field, rule.timestamp);
303
+ } else if (options) {
304
+ const items = this.isMultiple(rule.comparator) ? this.values(field, rule.value) : [rule.value];
305
+ value = items.map(item => options[item] ?? item).join(', ');
260
306
  }
261
-
262
- const selected = this.selectedValues(filter, active);
263
- const next = selected.includes(option)
264
- ? selected.filter(item => item !== option)
265
- : [...selected, option];
266
-
267
- this.setValue(active.key, next.join(' '));
307
+ return `${name} ${this.operatorLabel(rule.comparator)} ${value}`;
268
308
  }
269
309
 
270
- // Option keys keep the space-separated wire format the query builder uses; typed
271
- // values can contain spaces of their own, so they only split on commas
272
- private selectedValues(filter: QueryBuilderItem, active: ActiveFilter): string[] {
273
- return active.value
274
- .split(this.optionsFor(filter) ? ' ' : ',')
275
- .map(item => item.trim())
276
- .filter(item => item.length > 0);
310
+ private setLocal(rules: FilterRule[]) {
311
+ this._rules = rules;
277
312
  }
278
313
 
279
- private emitChange() {
280
- const data = this._active
281
- .filter(item => item.value.trim().length > 0)
282
- .map(item => ({key: item.key, comparator: item.comparator, value: item.value.trim()}));
314
+ private setRules(rules: FilterRule[]) {
315
+ this.setLocal(rules);
316
+ const complete = rules
317
+ .filter(rule => this.isComplete(rule))
318
+ .map(rule => ({key: rule.key, comparator: rule.comparator, value: rule.value}));
319
+ const encoded = complete.length > 0 ? btoa(JSON.stringify(complete)) : '';
320
+ if (encoded === this.value) return;
283
321
 
284
- this.value = data.length > 0 ? btoa(JSON.stringify(data)) : '';
322
+ this._emitted = encoded;
323
+ this.value = encoded;
285
324
  this._formController.updateValidity();
286
325
  this.emit('zn-filter-change');
287
326
  }
288
327
 
289
- /**
290
- * Rebuild the active filters from an encoded `value` (the inverse of emitChange), so a filter
291
- * restored from a shareable URL shows its pills. Runs only while there are no active filters and
292
- * needs the filters schema present to resolve each key. Sets `_active` directly so it neither
293
- * re-emits nor overwrites the value it just read.
294
- */
295
- private hydrateFromValue() {
296
- // Guard on valued filters (not just any pill) so default-filters' empty pills don't block a
297
- // shared filter, and so a user's in-progress filter is never clobbered by a later value change.
298
- if (!this.value || this.filters.length === 0 || this.activeCount > 0) return;
299
-
300
- let decoded: unknown;
301
- try {
302
- decoded = JSON.parse(atob(this.value));
303
- } catch {
304
- return;
305
- }
306
- if (!Array.isArray(decoded)) return;
307
-
308
- const active: ActiveFilter[] = [];
309
- decoded.forEach((entry) => {
310
- if (typeof entry !== 'object' || entry === null) return;
311
- const cond = entry as {key?: string; comparator?: string; value?: unknown};
312
- if (typeof cond.key !== 'string') return;
313
-
314
- const filter = this.definition(cond.key);
315
- if (!filter) return;
316
-
317
- const comparator = (cond.comparator as QueryBuilderOperators) || this.comparatorFor(filter);
318
- // Multi-value filters serialize as a joined string; tolerate an array form too.
319
- const value = Array.isArray(cond.value) ? cond.value.join(' ') : String(cond.value ?? '');
320
-
321
- active.push({key: cond.key, comparator, value});
328
+ private updateRule(index: number, change: Partial<FilterRule>) {
329
+ const rules = this._rules.map((rule, i) => {
330
+ if (i !== index) return rule;
331
+ const next = {...rule, ...change};
332
+ const field = this.field(next.key);
333
+
334
+ // A new field resets the operator and value, which may not apply to it
335
+ if (change.key !== undefined && change.key !== rule.key) {
336
+ next.comparator = field ? this.operators(field)[0] : 'eq';
337
+ next.value = '';
338
+ next.timestamp = undefined;
339
+ this._typing = null;
340
+ } else if (change.comparator !== undefined && field) {
341
+ if (next.timestamp) {
342
+ next.value = this.serializeDate(next.timestamp, next.comparator, field.dateSubmitFormat);
343
+ } else if (this.isMultiple(rule.comparator) && !this.isMultiple(next.comparator)) {
344
+ next.value = this.values(field, rule.value)[0] ?? '';
345
+ }
346
+ }
347
+ return next;
322
348
  });
323
-
324
- if (active.length > 0) this._active = active;
349
+ this.setLocal(rules);
325
350
  }
326
351
 
327
- private handleDateInput(filter: QueryBuilderItem, active: ActiveFilter, event: Event) {
328
- const picker = event.target as ZnDatepicker;
329
- const timestamp = picker.timestamp;
330
-
331
- if (!timestamp) {
332
- this.setValue(active.key, '');
352
+ private dateChange(index: number, field: FilterField, input: string) {
353
+ if (!input) {
354
+ this.updateRule(index, {value: '', timestamp: undefined});
333
355
  return;
334
356
  }
335
-
336
- this.setValue(
337
- active.key,
338
- this.serializeDate(timestamp, active.comparator, filter.dateSubmitFormat),
339
- picker.value as string,
340
- timestamp
341
- );
342
-
343
- // A time picker needs the panel to stay put while the time is set
344
- if (filter.type !== 'dateTime') {
345
- void picker.closest('zn-dropdown')?.hide();
346
- }
357
+ const timestamp = new Date(field.type === 'dateTime' ? input : `${input}T00:00`).getTime();
358
+ const comparator = this._rules[index].comparator;
359
+ this.updateRule(index, {timestamp, value: this.serializeDate(timestamp, comparator, field.dateSubmitFormat)});
347
360
  }
348
361
 
349
362
  // Mirrors zn-query-builder's date serialization so backends built against it keep working
350
- private serializeDate(
351
- timestamp: number,
352
- comparator: QueryBuilderOperators,
353
- format: QueryBuilderDateSubmitFormat = 'legacy'
354
- ): string {
363
+ private serializeDate(timestamp: number, comparator: string, format: QueryBuilderDateSubmitFormat = 'legacy'): string {
355
364
  if (format === 'timestamp') return timestamp.toString();
356
365
 
357
366
  if (format === 'iso') {
@@ -359,251 +368,262 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
359
368
  return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString();
360
369
  }
361
370
 
362
- if (comparator === QueryBuilderOperators.Eq || comparator === QueryBuilderOperators.Neq) {
371
+ if (comparator === 'eq' || comparator === 'neq') {
363
372
  return (timestamp / 1000).toString();
364
373
  }
365
374
 
366
375
  // Everything else goes as minutes from now, negated for `before`
367
- const multiplier = comparator === QueryBuilderOperators.Before ? -1 : 1;
376
+ const multiplier = comparator === 'before' ? -1 : 1;
368
377
  return (Math.floor((Date.now() - timestamp) / 1000 / 60) * multiplier).toString();
369
378
  }
370
379
 
371
- private handleTextInput(key: string, event: ZnInputEvent) {
372
- const input = event.target as ZnInput;
373
- const value = input.value as string;
374
-
375
- this._typing = {...this._typing, [key]: value};
376
-
377
- if (this._textTimeout) window.clearTimeout(this._textTimeout);
378
- this._textTimeout = window.setTimeout(() => this.setValue(key, value), TEXT_DEBOUNCE);
380
+ private parseDate(value: string, comparator: string, format: QueryBuilderDateSubmitFormat = 'legacy'): number | undefined {
381
+ let timestamp: number;
382
+ if (format === 'timestamp') {
383
+ timestamp = Number(value);
384
+ } else if (format === 'iso') {
385
+ const date = new Date(value);
386
+ timestamp = date.getTime() + date.getTimezoneOffset() * 60_000;
387
+ } else if (comparator === 'eq' || comparator === 'neq') {
388
+ timestamp = Number(value) * 1000;
389
+ } else {
390
+ const multiplier = comparator === 'before' ? -1 : 1;
391
+ timestamp = Date.now() - Number(value) * multiplier * 60_000;
392
+ }
393
+ return Number.isFinite(timestamp) ? timestamp : undefined;
379
394
  }
380
395
 
381
- private suggestionsFor(filter: QueryBuilderItem, active: ActiveFilter): string[] {
382
- const available = this.suggestions[active.key] ?? [];
383
- if (available.length === 0) return [];
396
+ private dateInputValue(field: FilterField, timestamp: number): string {
397
+ const date = new Date(timestamp);
398
+ const pad = (n: number) => String(n).padStart(2, '0');
399
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
400
+ return field.type === 'dateTime' ? `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
401
+ }
384
402
 
385
- // Match the term being typed, which for a multi-value filter is the one after the last comma
386
- const typed = (this._typing[active.key] ?? active.value).split(',').pop()!.trim().toLowerCase();
387
- const selected = this.selectedValues(filter, active).map(item => item.toLowerCase());
403
+ private suggestionsFor(field: FilterField, rule: FilterRule, index: number): string[] {
404
+ if (this._typing?.index !== index) return [];
405
+ const available = this.suggestions[rule.key] ?? [];
406
+ const multiple = this.isMultiple(rule.comparator);
407
+ const typed = (multiple ? this._typing.text.split(',').pop()! : this._typing.text).trim().toLowerCase();
408
+ const chosen = multiple ? this.values(field, this._typing.text).map(item => item.toLowerCase()) : [];
388
409
 
389
410
  return available
390
- .filter(value => value.toLowerCase().includes(typed) && !selected.includes(value.toLowerCase()))
411
+ .filter(value => value.toLowerCase().includes(typed) && !chosen.includes(value.toLowerCase())
412
+ && value.toLowerCase() !== typed)
391
413
  .slice(0, SUGGESTION_LIMIT);
392
414
  }
393
415
 
394
- private applySuggestion(filter: QueryBuilderItem, active: ActiveFilter, value: string, event: Event) {
395
- if (this._textTimeout) window.clearTimeout(this._textTimeout);
396
-
397
- const next = this.isMultiple(active.comparator)
398
- ? [...this.selectedValues(filter, active), value].join(',')
416
+ private applySuggestion(index: number, field: FilterField, rule: FilterRule, value: string) {
417
+ const typed = this._typing?.text ?? rule.value;
418
+ const next = this.isMultiple(rule.comparator)
419
+ ? [...this.values(field, typed).slice(0, typed.trimEnd().endsWith(',') ? undefined : -1), value].join(',')
399
420
  : value;
400
-
401
- this._typing = {...this._typing, [active.key]: next};
402
- this.setValue(active.key, next);
403
-
404
- if (!this.isMultiple(active.comparator)) {
405
- void (event.currentTarget as HTMLElement).closest('zn-dropdown')?.hide();
406
- }
407
- }
408
-
409
- private humanize(value: string) {
410
- return value.charAt(0).toUpperCase() + value.slice(1);
421
+ this._typing = null;
422
+ this.updateRule(index, {value: next});
411
423
  }
412
424
 
413
- private pillLabel(filter: QueryBuilderItem, active: ActiveFilter) {
414
- const name = this.humanize(filter.name);
415
- const selected = this.selectedValues(filter, active);
416
-
417
- if (selected.length === 0) return name;
425
+ // A removed control takes focus with it, so hand focus to the first selector that matches after the re-render
426
+ private async keepFocus(owner: ZnDataTableFilter, ...selectors: string[]) {
427
+ const focused = this.shadowRoot?.activeElement;
428
+ if (!focused) return;
429
+ await owner.updateComplete;
430
+ await this.updateComplete;
431
+ if (focused.isConnected) return;
418
432
 
419
- // With a choice of operator the pill has to say which one is applied
420
- const operator = this.operatorsFor(filter).length > 1
421
- ? ` ${this.operatorLabel(active.comparator)}`
422
- : '';
423
-
424
- if (selected.length > 1) return `${name}${operator} (${selected.length})`;
433
+ const target = selectors
434
+ .map(selector => this.shadowRoot?.querySelector(selector) ?? owner.shadowRoot?.querySelector(selector))
435
+ .find(Boolean);
436
+ (target instanceof ZnButton ? target.button : target as HTMLElement | undefined)?.focus();
437
+ }
425
438
 
426
- const options = this.optionsFor(filter);
427
- const value = active.label ?? (options ? options[selected[0]] ?? selected[0] : selected[0]);
439
+ private toggle = () => {
440
+ if (this._popover.open) {
441
+ this.hide();
442
+ } else {
443
+ this.show();
444
+ }
445
+ };
428
446
 
429
- return operator ? `${name}${operator} ${value}` : `${name}: ${value}`;
447
+ private apply() {
448
+ this.setRules(this._rules);
449
+ this.hide();
430
450
  }
431
451
 
432
- private renderOperators(filter: QueryBuilderItem, active: ActiveFilter) {
433
- const operators = this.operatorsFor(filter);
434
- if (operators.length < 2) return nothing;
435
-
436
- return html`
437
- <div class="filter-bar__operators">
438
- ${operators.map(operator => html`
439
- <zn-button class="${classMap({
440
- 'filter-bar__operator': true,
441
- 'filter-bar__operator--selected': operator === active.comparator,
442
- })}"
443
- outline
444
- @click="${() => this.setComparator(filter, active, operator)}">
445
- ${this.operatorLabel(operator)}
446
- </zn-button>`)}
447
- </div>`;
452
+ // Closing without applying abandons the builder's edits
453
+ private discard() {
454
+ this._rules = this.committed();
455
+ this._typing = null;
448
456
  }
449
457
 
450
- private renderPill(active: ActiveFilter) {
451
- const filter = this.definition(active.key);
452
- if (!filter) return nothing;
458
+ private renderToggle() {
459
+ const applied = this.activeCount;
453
460
 
454
- const operators = this.operatorsFor(filter);
461
+ return html`
462
+ <zn-button icon-button="small"
463
+ icon="funnel@lu"
464
+ icon-size="18"
465
+ tooltip="Filter"
466
+ aria-label="Filter"
467
+ muted-notifications
468
+ notification="${applied || nothing}"
469
+ aria-expanded="${this._popover.open}"
470
+ aria-haspopup="dialog"
471
+ class="${classMap({
472
+ 'filter__toggle': true,
473
+ 'filter__toggle--active': applied > 0,
474
+ 'filter__toggle--open': this._popover.open,
475
+ })}"
476
+ @click="${this.toggle}">
477
+ </zn-button>`;
478
+ }
479
+
480
+ private renderPopover() {
481
+ if (!this._popover.open) return nothing;
482
+
483
+ const incomplete = this._rules.filter(rule => !this.isComplete(rule)).length;
484
+ const hint = incomplete === 0
485
+ ? 'Show records that match all of these'
486
+ : incomplete === 1 ? 'Choose a value for the empty filter to apply it' : 'Choose a value for each empty filter to apply it';
487
+ const used = new Set(this._rules.map(rule => rule.key));
488
+ const canAdd = this.filters.some(field => !used.has(field.id));
455
489
 
456
490
  return html`
457
- <div class="filter-bar__chip">
458
- <zn-dropdown placement="bottom-start"
459
- data-key="${active.key}"
460
- ?stay-open-on-select="${this.isMultiple(active.comparator)}">
461
- <zn-button slot="trigger"
462
- class="filter-bar__pill"
463
- pill
464
- panel-bg
465
- icon="chevron-down@lu"
466
- icon-size="16"
467
- icon-position="right">${this.pillLabel(filter, active)}
491
+ <div class="filter__pop" role="dialog" aria-label="Filters" style="${this._popover.style}">
492
+ <div class="filter__pop__head">
493
+ <b>Filters</b>
494
+ <span class="filter__pop__meta">${hint}</span>
495
+ </div>
496
+ <div class="filter__pop__body">
497
+ ${this._rules.map((rule, index) => this.renderRule(rule, index, used))}
498
+ </div>
499
+ <div class="filter__pop__foot">
500
+ <zn-button class="filter__add" size="small" color="secondary" icon="plus@lu" icon-size="14" ?disabled="${!canAdd}"
501
+ @click="${() => this.setLocal([...this._rules, {key: '', comparator: '', value: ''}])}">
502
+ Add filter
468
503
  </zn-button>
469
- ${this.optionsFor(filter)
470
- ? html`
471
- <zn-menu variant="shell">
472
- ${operators.length > 1 ? html`
473
- ${operators.map(operator => html`
474
- <zn-menu-item type="checkbox"
475
- keep-open
476
- ?checked="${operator === active.comparator}"
477
- @zn-menu-select="${() => this.setComparator(filter, active, operator)}">
478
- ${this.operatorLabel(operator)}
479
- </zn-menu-item>`)}
480
- <div class="filter-bar__separator"></div>` : nothing}
481
- ${this.renderOptionSearch(filter, active)}
482
- ${this.renderOptionItems(filter, active)}
483
- </zn-menu>`
484
- : html`
485
- <div class="filter-bar__editor">
486
- ${this.renderOperators(filter, active)}
487
- ${this.renderValueInput(filter, active)}
488
- </div>`}
489
- </zn-dropdown>
490
- <zn-button class="filter-bar__remove"
491
- icon-button="small"
492
- plain
493
- icon="x@lu"
494
- icon-size="16"
495
- label="Remove ${this.humanize(filter.name)} filter"
496
- @click="${() => this.removeFilter(active.key)}">
497
- </zn-button>
504
+ <span class="filter__pop__spacer"></span>
505
+ ${this._rules.some(rule => this.isComplete(rule)) ? html`
506
+ <zn-button size="small" color="transparent" @click="${() => {
507
+ this.keepFocus(this, '.filter__add');
508
+ this.setLocal([]);
509
+ }}">Clear all</zn-button>` : nothing}
510
+ <zn-button size="small" color="primary" @click="${() => this.apply()}">Apply</zn-button>
511
+ </div>
498
512
  </div>`;
499
513
  }
500
514
 
501
- private isDateFilter(filter: QueryBuilderItem): boolean {
502
- return filter.type === 'date' || filter.type === 'dateTime';
515
+ private renderRule(rule: FilterRule, index: number, used: Set<string>) {
516
+ const field = this.field(rule.key);
517
+ const fields = this.filters.filter(f => f.id === rule.key || !used.has(f.id));
518
+ const searchable = this.filters.length > FIELD_SEARCH_THRESHOLD;
519
+ const placeholder = searchable && this._fieldSearch === index
520
+ ? `Search ${this.filters.length} fields...`
521
+ : 'Please select...';
522
+
523
+ return html`
524
+ <div class="filter__rule">
525
+ <zn-select placeholder="${placeholder}" value="${rule.key}" aria-label="Filter field" ?search="${searchable}"
526
+ @zn-show="${() => { this._fieldSearch = index; }}"
527
+ @zn-hide="${() => { this._fieldSearch = null; }}"
528
+ @zn-change="${(e: Event) => this.updateRule(index, {key: (e.target as ZnSelect).value as string})}">
529
+ <zn-opt-group label="Filter by">
530
+ ${fields.map(f => html`<zn-option value="${f.id}">${this.humanize(f.name)}</zn-option>`)}
531
+ </zn-opt-group>
532
+ </zn-select>
533
+ <zn-select value="${rule.comparator}" ?disabled="${!field}" aria-label="Filter operator"
534
+ @zn-change="${(e: Event) => this.updateRule(index, {comparator: (e.target as ZnSelect).value as string})}">
535
+ ${field ? this.operators(field).map(op => html`<zn-option value="${op}">${this.operatorLabel(op)}</zn-option>`) : nothing}
536
+ </zn-select>
537
+ ${this.renderValue(rule, index, field)}
538
+ <zn-button class="filter__remove" icon="trash-2@lu" icon-size="16" icon-color="grey" icon-button="small" plain
539
+ aria-label="Remove filter"
540
+ @click="${() => {
541
+ this.keepFocus(this, '.filter__rule:last-of-type .filter__remove', '.filter__add');
542
+ this.setLocal(this._rules.filter((_, i) => i !== index));
543
+ }}"></zn-button>
544
+ ${field ? this.renderSuggestions(field, rule, index) : nothing}
545
+ </div>`;
503
546
  }
504
547
 
505
- private renderValueInput(filter: QueryBuilderItem, active: ActiveFilter) {
506
- if (this.isDateFilter(filter)) {
548
+ private renderValue(rule: FilterRule, index: number, field?: FilterField) {
549
+ const options = field && this.optionsFor(field);
550
+ if (field && options) {
507
551
  return html`
508
- <zn-datepicker inline
509
- ?time-picker="${filter.type === 'dateTime'}"
510
- format-time="HH:mm"
511
- @zn-change="${(e: Event) => this.handleDateInput(filter, active, e)}">
512
- </zn-datepicker>`;
552
+ <zn-select placeholder="Value" value="${rule.value}" aria-label="Filter value"
553
+ ?multiple="${this.isMultiple(rule.comparator)}"
554
+ @zn-change="${(e: Event) => {
555
+ const value = (e.target as ZnSelect).value;
556
+ this.updateRule(index, {value: Array.isArray(value) ? value.join(' ') : value});
557
+ }}">
558
+ ${Object.entries(options).map(([value, label]) => html`<zn-option value="${value}">${label}</zn-option>`)}
559
+ </zn-select>`;
513
560
  }
514
561
 
515
- const suggestions = this.suggestionsFor(filter, active);
516
-
517
- return html`
518
- <zn-input type="${filter.type === 'number' ? 'number' : 'text'}"
519
- no-spin-buttons
520
- placeholder="${this.humanize(filter.name)}"
521
- value="${active.value}"
522
- @zn-input="${(e: ZnInputEvent) => this.handleTextInput(active.key, e)}">
523
- </zn-input>
524
- ${suggestions.length > 0 ? html`
525
- <div class="filter-bar__suggestions">
526
- ${suggestions.map(value => html`
527
- <zn-button class="filter-bar__suggestion"
528
- text
529
- @click="${(e: Event) => this.applySuggestion(filter, active, value, e)}">${value}
530
- </zn-button>`)}
531
- </div>` : nothing}`;
532
- }
533
-
534
- private handleOptionSearch(key: string, event: ZnInputEvent) {
535
- const input = event.target as ZnInput;
536
- this._optionQuery = {...this._optionQuery, [key]: input.value as string};
537
- }
538
-
539
- private renderOptionSearch(filter: QueryBuilderItem, active: ActiveFilter) {
540
- if (Object.keys(this.optionsFor(filter)!).length <= OPTION_SEARCH_THRESHOLD) return nothing;
541
-
542
- return html`
543
- <zn-input class="filter-bar__option-search"
544
- size="small"
545
- placeholder="Search ${this.humanize(filter.name)}"
546
- value="${this._optionQuery[active.key] ?? ''}"
547
- @zn-input="${(e: ZnInputEvent) => this.handleOptionSearch(active.key, e)}"
548
- @keydown="${(e: KeyboardEvent) => e.stopPropagation()}">
549
- </zn-input>`;
550
- }
551
-
552
- private renderOptionItems(filter: QueryBuilderItem, active: ActiveFilter) {
553
- const options = this.optionsFor(filter)!;
554
- const selected = this.selectedValues(filter, active);
555
- const query = (this._optionQuery[active.key] ?? '').trim().toLowerCase();
556
- const matches = Object.keys(options).filter(key => String(options[key]).toLowerCase().includes(query));
557
-
558
- if (matches.length === 0) {
562
+ if (field && this.isDate(field)) {
559
563
  return html`
560
- <zn-menu-item class="filter-bar__no-matches" disabled>No matches</zn-menu-item>`;
564
+ <zn-input type="${field.type === 'dateTime' ? 'datetime-local' : 'date'}" aria-label="Filter value"
565
+ value="${rule.timestamp ? this.dateInputValue(field, rule.timestamp) : ''}"
566
+ @zn-change="${(e: Event) => this.dateChange(index, field, (e.target as ZnInput).value as string)}"></zn-input>`;
561
567
  }
562
568
 
563
- return matches.map(key => html`
564
- <zn-menu-item type="checkbox"
565
- value="${key}"
566
- ?checked="${selected.includes(key)}"
567
- @zn-menu-select="${() => this.toggleOption(filter, active, key)}">${options[key]}
568
- </zn-menu-item>`);
569
+ return html`
570
+ <zn-input type="${field?.type === 'number' ? 'number' : 'text'}" no-spin-buttons placeholder="Value"
571
+ value="${rule.value}" ?disabled="${!field}" aria-label="Filter value"
572
+ @zn-input="${(e: ZnInputEvent) => { this._typing = {index, text: (e.target as ZnInput).value as string}; }}"
573
+ @zn-change="${(e: Event) => {
574
+ this._typing = null;
575
+ this.updateRule(index, {value: (e.target as ZnInput).value as string});
576
+ }}"></zn-input>`;
569
577
  }
570
578
 
571
- private renderAddFilter() {
572
- const available = this.filters.filter(
573
- (filter: QueryBuilderItem) => !this._active.some(item => item.key === filter.id)
574
- );
579
+ private renderSuggestions(field: FilterField, rule: FilterRule, index: number) {
580
+ const suggestions = this.suggestionsFor(field, rule, index);
581
+ if (suggestions.length === 0) return nothing;
582
+
583
+ return html`
584
+ <div class="filter__suggestions" role="group" aria-label="Suggestions">
585
+ ${suggestions.map(value => html`
586
+ <button class="filter__suggestion"
587
+ @mousedown="${(e: Event) => e.preventDefault()}"
588
+ @click="${() => this.applySuggestion(index, field, rule, value)}">${value}</button>`)}
589
+ </div>`;
590
+ }
575
591
 
576
- if (available.length === 0) return nothing;
592
+ private renderChips(owner: ZnDataTableFilter) {
593
+ const applied = owner.committed();
594
+ if (applied.length === 0) return nothing;
577
595
 
578
596
  return html`
579
- <zn-dropdown placement="bottom-start">
580
- <zn-button slot="trigger"
581
- class="filter-bar__add"
582
- icon-button="small"
583
- icon="plus@lu"
584
- icon-size="18"
585
- tooltip="Add filter"
586
- aria-label="Add filter">
587
- </zn-button>
588
- <zn-menu variant="shell">
589
- ${available.map((filter: QueryBuilderItem) => html`
590
- <zn-menu-item value="${filter.id}"
591
- @zn-menu-select="${() => this.addFilter(filter.id)}">${this.humanize(filter.name)}
592
- </zn-menu-item>`)}
593
- </zn-menu>
594
- </zn-dropdown>`;
597
+ <div class="filter__chips">
598
+ <span class="filter__chips__label">Filters:</span>
599
+ ${applied.map(rule => html`
600
+ <span class="filter__chip">
601
+ <button class="filter__chip__text" @click="${(e: Event) => {
602
+ e.stopPropagation();
603
+ owner.show();
604
+ }}">${owner.ruleText(rule)}</button>
605
+ <button class="filter__chip__remove" aria-label="Remove filter: ${owner.ruleText(rule)}"
606
+ @click="${() => {
607
+ this.keepFocus(owner, '.filter__chip:last-of-type .filter__chip__remove', '.filter__toggle');
608
+ owner.setRules(applied.filter(r => r !== rule));
609
+ }}">
610
+ <zn-icon src="x@lu" size="14"></zn-icon>
611
+ </button>
612
+ </span>`)}
613
+ <button class="filter__chips__clear" @click="${() => {
614
+ this.keepFocus(owner, '.filter__toggle');
615
+ owner.setRules([]);
616
+ }}">Clear all</button>
617
+ </div>`;
595
618
  }
596
619
 
597
620
  render() {
621
+ if (this.source) return this.renderChips(this.source);
598
622
  if (this.filters.length === 0) return nothing;
599
623
 
600
624
  return html`
601
- <div class="filter-bar">
602
- ${this._active.map((active: ActiveFilter) => this.renderPill(active))}
603
- ${this.renderAddFilter()}
604
- ${this._active.length > 0 ? html`
605
- <zn-button class="filter-bar__clear" text @click="${this.clear}">Clear</zn-button>` : nothing}
606
- </div>
607
- `;
625
+ ${this.renderToggle()}
626
+ ${this.renderPopover()}
627
+ ${this._mirrors.size === 0 ? this.renderChips(this) : nothing}`;
608
628
  }
609
629
  }