@furystack/shades-common-components 12.4.0 → 12.5.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 (263) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/esm/components/app-bar-link.spec.js +16 -19
  3. package/esm/components/app-bar-link.spec.js.map +1 -1
  4. package/esm/components/app-bar.spec.js +6 -4
  5. package/esm/components/app-bar.spec.js.map +1 -1
  6. package/esm/components/avatar.spec.js +9 -9
  7. package/esm/components/avatar.spec.js.map +1 -1
  8. package/esm/components/breadcrumb.spec.js +2 -2
  9. package/esm/components/breadcrumb.spec.js.map +1 -1
  10. package/esm/components/button-group.d.ts +32 -0
  11. package/esm/components/button-group.d.ts.map +1 -1
  12. package/esm/components/button-group.js +26 -2
  13. package/esm/components/button-group.js.map +1 -1
  14. package/esm/components/button-group.spec.js +127 -11
  15. package/esm/components/button-group.spec.js.map +1 -1
  16. package/esm/components/button.spec.js +4 -4
  17. package/esm/components/button.spec.js.map +1 -1
  18. package/esm/components/cache-view.spec.js +2 -3
  19. package/esm/components/cache-view.spec.js.map +1 -1
  20. package/esm/components/carousel.spec.js +47 -47
  21. package/esm/components/carousel.spec.js.map +1 -1
  22. package/esm/components/circular-progress.spec.js +2 -2
  23. package/esm/components/command-palette/command-palette-input.spec.js +23 -19
  24. package/esm/components/command-palette/command-palette-input.spec.js.map +1 -1
  25. package/esm/components/command-palette/command-palette-suggestion-list.spec.js +27 -27
  26. package/esm/components/command-palette/command-palette-suggestion-list.spec.js.map +1 -1
  27. package/esm/components/command-palette/index.spec.js +64 -51
  28. package/esm/components/command-palette/index.spec.js.map +1 -1
  29. package/esm/components/context-menu/context-menu.spec.js +33 -33
  30. package/esm/components/context-menu/context-menu.spec.js.map +1 -1
  31. package/esm/components/data-grid/body.spec.js +13 -13
  32. package/esm/components/data-grid/body.spec.js.map +1 -1
  33. package/esm/components/data-grid/data-grid-row.spec.js +8 -8
  34. package/esm/components/data-grid/data-grid-row.spec.js.map +1 -1
  35. package/esm/components/data-grid/data-grid.d.ts +40 -2
  36. package/esm/components/data-grid/data-grid.d.ts.map +1 -1
  37. package/esm/components/data-grid/data-grid.js +7 -10
  38. package/esm/components/data-grid/data-grid.js.map +1 -1
  39. package/esm/components/data-grid/data-grid.spec.js +71 -28
  40. package/esm/components/data-grid/data-grid.spec.js.map +1 -1
  41. package/esm/components/data-grid/filters/boolean-filter.d.ts +12 -0
  42. package/esm/components/data-grid/filters/boolean-filter.d.ts.map +1 -0
  43. package/esm/components/data-grid/filters/boolean-filter.js +27 -0
  44. package/esm/components/data-grid/filters/boolean-filter.js.map +1 -0
  45. package/esm/components/data-grid/filters/boolean-filter.spec.d.ts +2 -0
  46. package/esm/components/data-grid/filters/boolean-filter.spec.d.ts.map +1 -0
  47. package/esm/components/data-grid/filters/boolean-filter.spec.js +114 -0
  48. package/esm/components/data-grid/filters/boolean-filter.spec.js.map +1 -0
  49. package/esm/components/data-grid/filters/date-filter.d.ts +12 -0
  50. package/esm/components/data-grid/filters/date-filter.d.ts.map +1 -0
  51. package/esm/components/data-grid/filters/date-filter.js +109 -0
  52. package/esm/components/data-grid/filters/date-filter.js.map +1 -0
  53. package/esm/components/data-grid/filters/date-filter.spec.d.ts +2 -0
  54. package/esm/components/data-grid/filters/date-filter.spec.d.ts.map +1 -0
  55. package/esm/components/data-grid/filters/date-filter.spec.js +145 -0
  56. package/esm/components/data-grid/filters/date-filter.spec.js.map +1 -0
  57. package/esm/components/data-grid/filters/enum-filter.d.ts +16 -0
  58. package/esm/components/data-grid/filters/enum-filter.d.ts.map +1 -0
  59. package/esm/components/data-grid/filters/enum-filter.js +72 -0
  60. package/esm/components/data-grid/filters/enum-filter.js.map +1 -0
  61. package/esm/components/data-grid/filters/enum-filter.spec.d.ts +2 -0
  62. package/esm/components/data-grid/filters/enum-filter.spec.d.ts.map +1 -0
  63. package/esm/components/data-grid/filters/enum-filter.spec.js +136 -0
  64. package/esm/components/data-grid/filters/enum-filter.spec.js.map +1 -0
  65. package/esm/components/data-grid/filters/filter-dropdown.d.ts +6 -0
  66. package/esm/components/data-grid/filters/filter-dropdown.d.ts.map +1 -0
  67. package/esm/components/data-grid/filters/filter-dropdown.js +41 -0
  68. package/esm/components/data-grid/filters/filter-dropdown.js.map +1 -0
  69. package/esm/components/data-grid/filters/filter-dropdown.spec.d.ts +2 -0
  70. package/esm/components/data-grid/filters/filter-dropdown.spec.d.ts.map +1 -0
  71. package/esm/components/data-grid/filters/filter-dropdown.spec.js +69 -0
  72. package/esm/components/data-grid/filters/filter-dropdown.spec.js.map +1 -0
  73. package/esm/components/data-grid/filters/filter-styles.d.ts +24 -0
  74. package/esm/components/data-grid/filters/filter-styles.d.ts.map +1 -0
  75. package/esm/components/data-grid/filters/filter-styles.js +25 -0
  76. package/esm/components/data-grid/filters/filter-styles.js.map +1 -0
  77. package/esm/components/data-grid/filters/index.d.ts +7 -0
  78. package/esm/components/data-grid/filters/index.d.ts.map +1 -0
  79. package/esm/components/data-grid/filters/index.js +7 -0
  80. package/esm/components/data-grid/filters/index.js.map +1 -0
  81. package/esm/components/data-grid/filters/number-filter.d.ts +12 -0
  82. package/esm/components/data-grid/filters/number-filter.d.ts.map +1 -0
  83. package/esm/components/data-grid/filters/number-filter.js +65 -0
  84. package/esm/components/data-grid/filters/number-filter.js.map +1 -0
  85. package/esm/components/data-grid/filters/number-filter.spec.d.ts +2 -0
  86. package/esm/components/data-grid/filters/number-filter.spec.d.ts.map +1 -0
  87. package/esm/components/data-grid/filters/number-filter.spec.js +142 -0
  88. package/esm/components/data-grid/filters/number-filter.spec.js.map +1 -0
  89. package/esm/components/data-grid/filters/string-filter.d.ts +12 -0
  90. package/esm/components/data-grid/filters/string-filter.d.ts.map +1 -0
  91. package/esm/components/data-grid/filters/string-filter.js +63 -0
  92. package/esm/components/data-grid/filters/string-filter.js.map +1 -0
  93. package/esm/components/data-grid/filters/string-filter.spec.d.ts +2 -0
  94. package/esm/components/data-grid/filters/string-filter.spec.d.ts.map +1 -0
  95. package/esm/components/data-grid/filters/string-filter.spec.js +128 -0
  96. package/esm/components/data-grid/filters/string-filter.spec.js.map +1 -0
  97. package/esm/components/data-grid/footer.d.ts.map +1 -1
  98. package/esm/components/data-grid/footer.js +24 -9
  99. package/esm/components/data-grid/footer.js.map +1 -1
  100. package/esm/components/data-grid/footer.spec.js +38 -36
  101. package/esm/components/data-grid/footer.spec.js.map +1 -1
  102. package/esm/components/data-grid/header.d.ts +6 -9
  103. package/esm/components/data-grid/header.d.ts.map +1 -1
  104. package/esm/components/data-grid/header.js +51 -117
  105. package/esm/components/data-grid/header.js.map +1 -1
  106. package/esm/components/data-grid/header.spec.js +116 -187
  107. package/esm/components/data-grid/header.spec.js.map +1 -1
  108. package/esm/components/data-grid/index.d.ts +1 -0
  109. package/esm/components/data-grid/index.d.ts.map +1 -1
  110. package/esm/components/data-grid/index.js +1 -0
  111. package/esm/components/data-grid/index.js.map +1 -1
  112. package/esm/components/data-grid/selection-cell.spec.js +8 -8
  113. package/esm/components/data-grid/selection-cell.spec.js.map +1 -1
  114. package/esm/components/drawer/drawer-toggle-button.spec.js +22 -22
  115. package/esm/components/drawer/drawer-toggle-button.spec.js.map +1 -1
  116. package/esm/components/drawer/index.spec.js +36 -36
  117. package/esm/components/drawer/index.spec.js.map +1 -1
  118. package/esm/components/dropdown.spec.js +38 -30
  119. package/esm/components/dropdown.spec.js.map +1 -1
  120. package/esm/components/fab.spec.js +4 -4
  121. package/esm/components/fab.spec.js.map +1 -1
  122. package/esm/components/form.spec.js +37 -37
  123. package/esm/components/form.spec.js.map +1 -1
  124. package/esm/components/grid.spec.js +3 -3
  125. package/esm/components/grid.spec.js.map +1 -1
  126. package/esm/components/image.spec.js +55 -52
  127. package/esm/components/image.spec.js.map +1 -1
  128. package/esm/components/inputs/autocomplete.spec.js +7 -14
  129. package/esm/components/inputs/autocomplete.spec.js.map +1 -1
  130. package/esm/components/inputs/checkbox.spec.js +22 -22
  131. package/esm/components/inputs/checkbox.spec.js.map +1 -1
  132. package/esm/components/inputs/input-number.spec.js +47 -47
  133. package/esm/components/inputs/input-number.spec.js.map +1 -1
  134. package/esm/components/inputs/input.spec.js +53 -53
  135. package/esm/components/inputs/input.spec.js.map +1 -1
  136. package/esm/components/inputs/radio-group.spec.js +14 -14
  137. package/esm/components/inputs/radio-group.spec.js.map +1 -1
  138. package/esm/components/inputs/radio.spec.js +16 -16
  139. package/esm/components/inputs/radio.spec.js.map +1 -1
  140. package/esm/components/inputs/select.spec.js +74 -74
  141. package/esm/components/inputs/select.spec.js.map +1 -1
  142. package/esm/components/inputs/slider.spec.js +16 -16
  143. package/esm/components/inputs/slider.spec.js.map +1 -1
  144. package/esm/components/inputs/switch.spec.js +24 -24
  145. package/esm/components/inputs/switch.spec.js.map +1 -1
  146. package/esm/components/inputs/text-area.spec.js +17 -17
  147. package/esm/components/inputs/text-area.spec.js.map +1 -1
  148. package/esm/components/linear-progress.spec.js +2 -2
  149. package/esm/components/list/list.spec.js +36 -36
  150. package/esm/components/list/list.spec.js.map +1 -1
  151. package/esm/components/markdown/markdown-display.spec.js +15 -15
  152. package/esm/components/markdown/markdown-display.spec.js.map +1 -1
  153. package/esm/components/markdown/markdown-editor.spec.js +8 -8
  154. package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
  155. package/esm/components/markdown/markdown-input.spec.js +17 -17
  156. package/esm/components/markdown/markdown-input.spec.js.map +1 -1
  157. package/esm/components/menu/menu.spec.js +28 -28
  158. package/esm/components/menu/menu.spec.js.map +1 -1
  159. package/esm/components/modal.spec.js +15 -18
  160. package/esm/components/modal.spec.js.map +1 -1
  161. package/esm/components/noty-list.spec.js +25 -23
  162. package/esm/components/noty-list.spec.js.map +1 -1
  163. package/esm/components/page-container/index.spec.js +16 -16
  164. package/esm/components/page-container/index.spec.js.map +1 -1
  165. package/esm/components/page-container/page-header.spec.js +16 -16
  166. package/esm/components/page-container/page-header.spec.js.map +1 -1
  167. package/esm/components/page-layout/index.spec.js +29 -29
  168. package/esm/components/page-layout/index.spec.js.map +1 -1
  169. package/esm/components/paper.spec.js +3 -3
  170. package/esm/components/paper.spec.js.map +1 -1
  171. package/esm/components/rating.spec.js +61 -61
  172. package/esm/components/rating.spec.js.map +1 -1
  173. package/esm/components/skeleton.spec.js +10 -6
  174. package/esm/components/skeleton.spec.js.map +1 -1
  175. package/esm/components/suggest/suggest-input.spec.js +4 -10
  176. package/esm/components/suggest/suggest-input.spec.js.map +1 -1
  177. package/esm/components/tabs.spec.js +30 -30
  178. package/esm/components/tabs.spec.js.map +1 -1
  179. package/esm/components/tree/tree.spec.js +27 -27
  180. package/esm/components/tree/tree.spec.js.map +1 -1
  181. package/esm/components/typography.spec.js +3 -3
  182. package/esm/components/typography.spec.js.map +1 -1
  183. package/esm/components/wizard/index.spec.js +5 -5
  184. package/esm/components/wizard/index.spec.js.map +1 -1
  185. package/esm/utils/promisify-animation.d.ts.map +1 -1
  186. package/esm/utils/promisify-animation.js +3 -0
  187. package/esm/utils/promisify-animation.js.map +1 -1
  188. package/package.json +2 -2
  189. package/src/components/app-bar-link.spec.tsx +16 -19
  190. package/src/components/app-bar.spec.tsx +6 -4
  191. package/src/components/avatar.spec.tsx +9 -9
  192. package/src/components/breadcrumb.spec.tsx +2 -2
  193. package/src/components/button-group.spec.tsx +155 -11
  194. package/src/components/button-group.tsx +49 -2
  195. package/src/components/button.spec.tsx +4 -4
  196. package/src/components/cache-view.spec.tsx +3 -3
  197. package/src/components/carousel.spec.tsx +47 -47
  198. package/src/components/circular-progress.spec.tsx +2 -2
  199. package/src/components/command-palette/command-palette-input.spec.tsx +23 -19
  200. package/src/components/command-palette/command-palette-suggestion-list.spec.tsx +27 -27
  201. package/src/components/command-palette/index.spec.tsx +64 -51
  202. package/src/components/context-menu/context-menu.spec.tsx +33 -33
  203. package/src/components/data-grid/body.spec.tsx +13 -13
  204. package/src/components/data-grid/data-grid-row.spec.tsx +8 -8
  205. package/src/components/data-grid/data-grid.spec.tsx +106 -28
  206. package/src/components/data-grid/data-grid.tsx +44 -11
  207. package/src/components/data-grid/filters/boolean-filter.spec.tsx +142 -0
  208. package/src/components/data-grid/filters/boolean-filter.tsx +45 -0
  209. package/src/components/data-grid/filters/date-filter.spec.tsx +181 -0
  210. package/src/components/data-grid/filters/date-filter.tsx +162 -0
  211. package/src/components/data-grid/filters/enum-filter.spec.tsx +168 -0
  212. package/src/components/data-grid/filters/enum-filter.tsx +119 -0
  213. package/src/components/data-grid/filters/filter-dropdown.spec.tsx +89 -0
  214. package/src/components/data-grid/filters/filter-dropdown.tsx +60 -0
  215. package/src/components/data-grid/filters/filter-styles.ts +26 -0
  216. package/src/components/data-grid/filters/index.ts +6 -0
  217. package/src/components/data-grid/filters/number-filter.spec.tsx +174 -0
  218. package/src/components/data-grid/filters/number-filter.tsx +115 -0
  219. package/src/components/data-grid/filters/string-filter.spec.tsx +157 -0
  220. package/src/components/data-grid/filters/string-filter.tsx +112 -0
  221. package/src/components/data-grid/footer.spec.tsx +38 -36
  222. package/src/components/data-grid/footer.tsx +21 -8
  223. package/src/components/data-grid/header.spec.tsx +128 -212
  224. package/src/components/data-grid/header.tsx +95 -183
  225. package/src/components/data-grid/index.tsx +1 -0
  226. package/src/components/data-grid/selection-cell.spec.tsx +8 -8
  227. package/src/components/drawer/drawer-toggle-button.spec.tsx +22 -22
  228. package/src/components/drawer/index.spec.tsx +36 -36
  229. package/src/components/dropdown.spec.tsx +38 -30
  230. package/src/components/fab.spec.tsx +4 -4
  231. package/src/components/form.spec.tsx +37 -37
  232. package/src/components/grid.spec.tsx +3 -3
  233. package/src/components/image.spec.tsx +55 -52
  234. package/src/components/inputs/autocomplete.spec.tsx +7 -14
  235. package/src/components/inputs/checkbox.spec.tsx +22 -22
  236. package/src/components/inputs/input-number.spec.tsx +47 -47
  237. package/src/components/inputs/input.spec.tsx +53 -53
  238. package/src/components/inputs/radio-group.spec.tsx +14 -14
  239. package/src/components/inputs/radio.spec.tsx +16 -16
  240. package/src/components/inputs/select.spec.tsx +74 -74
  241. package/src/components/inputs/slider.spec.tsx +16 -16
  242. package/src/components/inputs/switch.spec.tsx +24 -24
  243. package/src/components/inputs/text-area.spec.tsx +17 -17
  244. package/src/components/linear-progress.spec.tsx +2 -2
  245. package/src/components/list/list.spec.tsx +36 -36
  246. package/src/components/markdown/markdown-display.spec.tsx +15 -15
  247. package/src/components/markdown/markdown-editor.spec.tsx +8 -8
  248. package/src/components/markdown/markdown-input.spec.tsx +17 -17
  249. package/src/components/menu/menu.spec.tsx +28 -28
  250. package/src/components/modal.spec.tsx +15 -18
  251. package/src/components/noty-list.spec.tsx +25 -23
  252. package/src/components/page-container/index.spec.tsx +16 -16
  253. package/src/components/page-container/page-header.spec.tsx +16 -16
  254. package/src/components/page-layout/index.spec.tsx +29 -29
  255. package/src/components/paper.spec.tsx +3 -3
  256. package/src/components/rating.spec.tsx +61 -61
  257. package/src/components/skeleton.spec.tsx +10 -6
  258. package/src/components/suggest/suggest-input.spec.tsx +4 -10
  259. package/src/components/tabs.spec.tsx +30 -30
  260. package/src/components/tree/tree.spec.tsx +27 -27
  261. package/src/components/typography.spec.tsx +3 -3
  262. package/src/components/wizard/index.spec.tsx +5 -5
  263. package/src/utils/promisify-animation.ts +3 -0
@@ -0,0 +1,181 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'
3
+ import { ObservableValue, usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import type { FilterableFindOptions } from '../data-grid.js'
6
+ import { DateFilter } from './date-filter.js'
7
+
8
+ describe('DateFilter', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = ''
15
+ })
16
+
17
+ const createFindOptions = (options: Partial<FilterableFindOptions> = {}): ObservableValue<FilterableFindOptions> => {
18
+ return new ObservableValue<FilterableFindOptions>(options)
19
+ }
20
+
21
+ const renderDateFilter = async (
22
+ findOptions: ObservableValue<FilterableFindOptions>,
23
+ field = 'createdAt',
24
+ onClose = vi.fn(),
25
+ ) => {
26
+ const injector = new Injector()
27
+ const rootElement = document.getElementById('root')!
28
+ initializeShadeRoot({
29
+ injector,
30
+ rootElement,
31
+ jsxElement: <DateFilter field={field} findOptions={findOptions} onClose={onClose} />,
32
+ })
33
+ await flushUpdates()
34
+ return { injector, onClose }
35
+ }
36
+
37
+ it('should render mode segmented control and date input', async () => {
38
+ const findOptions = createFindOptions()
39
+ await usingAsync((await renderDateFilter(findOptions)).injector, async () => {
40
+ const control = document.querySelector('shade-segmented-control')
41
+ expect(control).not.toBeNull()
42
+
43
+ const input = document.querySelector('[data-testid="date-filter-value"]')
44
+ expect(input).not.toBeNull()
45
+ })
46
+ })
47
+
48
+ it('should apply "before" filter on submit', async () => {
49
+ const findOptions = createFindOptions()
50
+ const { injector, onClose } = await renderDateFilter(findOptions)
51
+ await usingAsync(injector, async () => {
52
+ const input = document.querySelector('[data-testid="date-filter-value"]') as HTMLInputElement
53
+ input.value = '2025-06-15T10:30'
54
+ input.dispatchEvent(new Event('input', { bubbles: true }))
55
+
56
+ const form = document.querySelector('form') as HTMLFormElement
57
+ form.dispatchEvent(new Event('submit', { bubbles: true }))
58
+ await flushUpdates()
59
+
60
+ const filter = findOptions.getValue().filter?.createdAt as Record<string, Date>
61
+ expect(filter.$lt).toBeInstanceOf(Date)
62
+ expect(filter.$lt.toISOString()).toBe(new Date('2025-06-15T10:30').toISOString())
63
+ expect(onClose).toHaveBeenCalled()
64
+ })
65
+ })
66
+
67
+ it('should apply "after" filter when mode is changed', async () => {
68
+ const findOptions = createFindOptions()
69
+ const { injector, onClose } = await renderDateFilter(findOptions)
70
+ await usingAsync(injector, async () => {
71
+ const afterButton = document.querySelector(
72
+ 'shade-segmented-control button[data-value="after"]',
73
+ ) as HTMLButtonElement
74
+ afterButton?.click()
75
+ await flushUpdates()
76
+
77
+ const input = document.querySelector('[data-testid="date-filter-value"]') as HTMLInputElement
78
+ input.value = '2025-01-01T00:00'
79
+ input.dispatchEvent(new Event('input', { bubbles: true }))
80
+
81
+ const form = document.querySelector('form') as HTMLFormElement
82
+ form.dispatchEvent(new Event('submit', { bubbles: true }))
83
+ await flushUpdates()
84
+
85
+ const filter = findOptions.getValue().filter?.createdAt as Record<string, Date>
86
+ expect(filter.$gt).toBeInstanceOf(Date)
87
+ expect(onClose).toHaveBeenCalled()
88
+ })
89
+ })
90
+
91
+ it('should apply "between" filter with both dates', async () => {
92
+ const findOptions = createFindOptions()
93
+ const { injector, onClose } = await renderDateFilter(findOptions)
94
+ await usingAsync(injector, async () => {
95
+ const betweenButton = document.querySelector(
96
+ 'shade-segmented-control button[data-value="between"]',
97
+ ) as HTMLButtonElement
98
+ betweenButton?.click()
99
+ await flushUpdates()
100
+
101
+ const startInput = document.querySelector('[data-testid="date-filter-value"]') as HTMLInputElement
102
+ startInput.value = '2025-01-01T00:00'
103
+ startInput.dispatchEvent(new Event('input', { bubbles: true }))
104
+
105
+ const endInput = document.querySelector('[data-testid="date-filter-value-end"]') as HTMLInputElement
106
+ endInput.value = '2025-12-31T23:59'
107
+ endInput.dispatchEvent(new Event('input', { bubbles: true }))
108
+
109
+ const form = document.querySelector('form') as HTMLFormElement
110
+ form.dispatchEvent(new Event('submit', { bubbles: true }))
111
+ await flushUpdates()
112
+
113
+ const filter = findOptions.getValue().filter?.createdAt as Record<string, Date>
114
+ expect(filter.$gte).toBeInstanceOf(Date)
115
+ expect(filter.$lte).toBeInstanceOf(Date)
116
+ expect(onClose).toHaveBeenCalled()
117
+ })
118
+ })
119
+
120
+ it('should clear filter when Clear button is clicked', async () => {
121
+ const findOptions = createFindOptions({ filter: { createdAt: { $lt: new Date() } } })
122
+ const { injector, onClose } = await renderDateFilter(findOptions)
123
+ await usingAsync(injector, async () => {
124
+ const clearButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Clear')
125
+ clearButton?.click()
126
+ await flushUpdates()
127
+
128
+ expect(findOptions.getValue().filter?.createdAt).toBeUndefined()
129
+ expect(onClose).toHaveBeenCalled()
130
+ })
131
+ })
132
+
133
+ it('should remove filter when submitting empty date', async () => {
134
+ const findOptions = createFindOptions({ filter: { createdAt: { $lt: new Date() } } })
135
+ const { injector, onClose } = await renderDateFilter(findOptions)
136
+ await usingAsync(injector, async () => {
137
+ const input = document.querySelector('[data-testid="date-filter-value"]') as HTMLInputElement
138
+ input.value = ''
139
+ input.dispatchEvent(new Event('input', { bubbles: true }))
140
+
141
+ const form = document.querySelector('form') as HTMLFormElement
142
+ form.dispatchEvent(new Event('submit', { bubbles: true }))
143
+ await flushUpdates()
144
+
145
+ expect(findOptions.getValue().filter?.createdAt).toBeUndefined()
146
+ expect(onClose).toHaveBeenCalled()
147
+ })
148
+ })
149
+
150
+ it('should preserve filters on other fields', async () => {
151
+ const findOptions = createFindOptions({
152
+ filter: { createdAt: { $lt: new Date() }, name: { $regex: 'keep' } },
153
+ })
154
+ const { injector } = await renderDateFilter(findOptions)
155
+ await usingAsync(injector, async () => {
156
+ const clearButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Clear')
157
+ clearButton?.click()
158
+ await flushUpdates()
159
+
160
+ const updatedFilter = findOptions.getValue().filter
161
+ expect(updatedFilter?.createdAt).toBeUndefined()
162
+ expect(updatedFilter?.name).toEqual({ $regex: 'keep' })
163
+ })
164
+ })
165
+
166
+ it('should reset skip to 0 when applying filter', async () => {
167
+ const findOptions = createFindOptions({ skip: 20 })
168
+ const { injector } = await renderDateFilter(findOptions)
169
+ await usingAsync(injector, async () => {
170
+ const input = document.querySelector('[data-testid="date-filter-value"]') as HTMLInputElement
171
+ input.value = '2025-06-15T10:30'
172
+ input.dispatchEvent(new Event('input', { bubbles: true }))
173
+
174
+ const form = document.querySelector('form') as HTMLFormElement
175
+ form.dispatchEvent(new Event('submit', { bubbles: true }))
176
+ await flushUpdates()
177
+
178
+ expect(findOptions.getValue().skip).toBe(0)
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,162 @@
1
+ import { createComponent, Shade } from '@furystack/shades'
2
+ import type { ObservableValue } from '@furystack/utils'
3
+ import { SegmentedControl } from '../../button-group.js'
4
+ import { Button } from '../../button.js'
5
+ import { close as closeIcon, search as searchIcon } from '../../icons/icon-definitions.js'
6
+ import { Icon } from '../../icons/icon.js'
7
+ import type { FilterableFindOptions } from '../data-grid.js'
8
+ import { filterBaseCss, filterInputCss } from './filter-styles.js'
9
+
10
+ type DateMode = 'before' | 'after' | 'between'
11
+
12
+ export const DateFilter = Shade<{
13
+ field: string
14
+ findOptions: ObservableValue<FilterableFindOptions>
15
+ onClose: () => void
16
+ }>({
17
+ shadowDomName: 'data-grid-date-filter',
18
+ css: {
19
+ ...filterBaseCss,
20
+ '& input[type="datetime-local"]': filterInputCss,
21
+ },
22
+ render: ({ props, useObservable, useState }) => {
23
+ const [findOptions, setFindOptions] = useObservable('findOptions', props.findOptions)
24
+
25
+ const currentFilter = findOptions.filter?.[props.field] as
26
+ | { $lt?: Date; $gt?: Date; $gte?: Date; $lte?: Date }
27
+ | undefined
28
+
29
+ const detectMode = (): DateMode => {
30
+ if (currentFilter?.$gte && currentFilter?.$lte) return 'between'
31
+ if (currentFilter?.$lt) return 'before'
32
+ if (currentFilter?.$gt) return 'after'
33
+ return 'before'
34
+ }
35
+
36
+ const toLocalDateTimeString = (date?: Date) => {
37
+ if (!date) return ''
38
+ const d = date instanceof Date ? date : new Date(date)
39
+ if (isNaN(d.getTime())) return ''
40
+ const pad = (n: number) => n.toString().padStart(2, '0')
41
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
42
+ }
43
+
44
+ const getInitialValue = (): string => {
45
+ if (currentFilter?.$gte) return toLocalDateTimeString(currentFilter.$gte)
46
+ if (currentFilter?.$lt) return toLocalDateTimeString(currentFilter.$lt)
47
+ if (currentFilter?.$gt) return toLocalDateTimeString(currentFilter.$gt)
48
+ return ''
49
+ }
50
+
51
+ const getInitialSecondValue = (): string => {
52
+ if (currentFilter?.$lte) return toLocalDateTimeString(currentFilter.$lte)
53
+ return ''
54
+ }
55
+
56
+ const [selectedMode, setSelectedMode] = useState<DateMode>('selectedMode', detectMode())
57
+ let dateValue = getInitialValue()
58
+ let secondDateValue = getInitialSecondValue()
59
+
60
+ const applyFilter = () => {
61
+ const filter = { ...findOptions.filter }
62
+ if (!dateValue) {
63
+ delete filter[props.field]
64
+ setFindOptions({ ...findOptions, filter, skip: 0 })
65
+ props.onClose()
66
+ return
67
+ }
68
+
69
+ let filterValue: Record<string, Date>
70
+ switch (selectedMode) {
71
+ case 'before':
72
+ filterValue = { $lt: new Date(dateValue) }
73
+ break
74
+ case 'after':
75
+ filterValue = { $gt: new Date(dateValue) }
76
+ break
77
+ case 'between':
78
+ filterValue = {
79
+ $gte: new Date(dateValue),
80
+ ...(secondDateValue ? { $lte: new Date(secondDateValue) } : {}),
81
+ }
82
+ break
83
+ default:
84
+ throw new Error(`Invalid date mode: ${selectedMode as unknown as string}`)
85
+ }
86
+
87
+ filter[props.field] = filterValue
88
+ setFindOptions({ ...findOptions, filter, skip: 0 })
89
+ props.onClose()
90
+ }
91
+
92
+ const clearFilter = () => {
93
+ const filter = { ...findOptions.filter }
94
+ delete filter[props.field]
95
+ setFindOptions({ ...findOptions, filter, skip: 0 })
96
+ props.onClose()
97
+ }
98
+
99
+ return (
100
+ <form
101
+ onsubmit={(ev: Event) => {
102
+ ev.preventDefault()
103
+ applyFilter()
104
+ }}
105
+ >
106
+ <div className="filter-row">
107
+ <SegmentedControl
108
+ size="small"
109
+ value={selectedMode}
110
+ onValueChange={(v) => setSelectedMode(v as DateMode)}
111
+ options={[
112
+ { value: 'before', label: 'Before' },
113
+ { value: 'after', label: 'After' },
114
+ { value: 'between', label: 'Between' },
115
+ ]}
116
+ />
117
+ </div>
118
+ <div className="filter-row">
119
+ <input
120
+ data-testid="date-filter-value"
121
+ type="datetime-local"
122
+ value={dateValue}
123
+ autofocus
124
+ oninput={(ev: Event) => {
125
+ dateValue = (ev.target as HTMLInputElement).value
126
+ }}
127
+ />
128
+ </div>
129
+ <div className="filter-row" style={{ display: selectedMode === 'between' ? 'flex' : 'none' }}>
130
+ <input
131
+ data-testid="date-filter-value-end"
132
+ type="datetime-local"
133
+ value={secondDateValue}
134
+ oninput={(ev: Event) => {
135
+ secondDateValue = (ev.target as HTMLInputElement).value
136
+ }}
137
+ />
138
+ </div>
139
+ <div className="filter-actions">
140
+ <Button
141
+ type="button"
142
+ variant="outlined"
143
+ size="small"
144
+ onclick={clearFilter}
145
+ startIcon={<Icon icon={closeIcon} size={14} />}
146
+ >
147
+ Clear
148
+ </Button>
149
+ <Button
150
+ type="submit"
151
+ variant="outlined"
152
+ size="small"
153
+ color="primary"
154
+ startIcon={<Icon icon={searchIcon} size={14} />}
155
+ >
156
+ Apply
157
+ </Button>
158
+ </div>
159
+ </form>
160
+ )
161
+ },
162
+ })
@@ -0,0 +1,168 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'
3
+ import { ObservableValue, usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import type { FilterableFindOptions } from '../data-grid.js'
6
+ import { EnumFilter } from './enum-filter.js'
7
+
8
+ const enumValues = [
9
+ { label: 'Admin', value: 'admin' },
10
+ { label: 'User', value: 'user' },
11
+ { label: 'Guest', value: 'guest' },
12
+ ]
13
+
14
+ describe('EnumFilter', () => {
15
+ beforeEach(() => {
16
+ document.body.innerHTML = '<div id="root"></div>'
17
+ })
18
+
19
+ afterEach(() => {
20
+ document.body.innerHTML = ''
21
+ })
22
+
23
+ const createFindOptions = (options: Partial<FilterableFindOptions> = {}): ObservableValue<FilterableFindOptions> => {
24
+ return new ObservableValue<FilterableFindOptions>(options)
25
+ }
26
+
27
+ const renderEnumFilter = async (
28
+ findOptions: ObservableValue<FilterableFindOptions>,
29
+ field = 'role',
30
+ values = enumValues,
31
+ onClose = vi.fn(),
32
+ ) => {
33
+ const injector = new Injector()
34
+ const rootElement = document.getElementById('root')!
35
+ initializeShadeRoot({
36
+ injector,
37
+ rootElement,
38
+ jsxElement: <EnumFilter field={field} values={values} findOptions={findOptions} onClose={onClose} />,
39
+ })
40
+ await flushUpdates()
41
+ return { injector, onClose }
42
+ }
43
+
44
+ it('should render mode control and checkboxes for each value', async () => {
45
+ const findOptions = createFindOptions()
46
+ await usingAsync((await renderEnumFilter(findOptions)).injector, async () => {
47
+ const control = document.querySelector('shade-segmented-control')
48
+ expect(control).not.toBeNull()
49
+
50
+ const checkboxes = document.querySelectorAll('shade-checkbox')
51
+ expect(checkboxes.length).toBe(3)
52
+ })
53
+ })
54
+
55
+ it('should apply $in filter when values are selected and Apply is clicked', async () => {
56
+ const findOptions = createFindOptions()
57
+ const { injector, onClose } = await renderEnumFilter(findOptions)
58
+ await usingAsync(injector, async () => {
59
+ const checkboxes = document.querySelectorAll('shade-checkbox input[type="checkbox"]')
60
+ const adminCheckbox = checkboxes[0] as HTMLInputElement
61
+ adminCheckbox.checked = true
62
+ adminCheckbox.dispatchEvent(new Event('change', { bubbles: true }))
63
+
64
+ const applyButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Apply')
65
+ applyButton?.click()
66
+ await flushUpdates()
67
+
68
+ expect(findOptions.getValue().filter).toEqual({ role: { $in: ['admin'] } })
69
+ expect(onClose).toHaveBeenCalled()
70
+ })
71
+ })
72
+
73
+ it('should apply $nin filter when exclude mode is selected', async () => {
74
+ const findOptions = createFindOptions()
75
+ const { injector, onClose } = await renderEnumFilter(findOptions)
76
+ await usingAsync(injector, async () => {
77
+ const excludeButton = document.querySelector(
78
+ 'shade-segmented-control button[data-value="exclude"]',
79
+ ) as HTMLButtonElement
80
+ excludeButton?.click()
81
+ await flushUpdates()
82
+
83
+ const checkboxes = document.querySelectorAll('shade-checkbox input[type="checkbox"]')
84
+ const guestCheckbox = checkboxes[2] as HTMLInputElement
85
+ guestCheckbox.checked = true
86
+ guestCheckbox.dispatchEvent(new Event('change', { bubbles: true }))
87
+
88
+ const applyButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Apply')
89
+ applyButton?.click()
90
+ await flushUpdates()
91
+
92
+ expect(findOptions.getValue().filter).toEqual({ role: { $nin: ['guest'] } })
93
+ expect(onClose).toHaveBeenCalled()
94
+ })
95
+ })
96
+
97
+ it('should remove filter when no values are selected', async () => {
98
+ const findOptions = createFindOptions({ filter: { role: { $in: ['admin'] } } })
99
+ const { injector, onClose } = await renderEnumFilter(findOptions)
100
+ await usingAsync(injector, async () => {
101
+ const checkboxes = document.querySelectorAll('shade-checkbox input[type="checkbox"]')
102
+ const adminCheckbox = checkboxes[0] as HTMLInputElement
103
+ adminCheckbox.checked = false
104
+ adminCheckbox.dispatchEvent(new Event('change', { bubbles: true }))
105
+
106
+ const applyButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Apply')
107
+ applyButton?.click()
108
+ await flushUpdates()
109
+
110
+ expect(findOptions.getValue().filter?.role).toBeUndefined()
111
+ expect(onClose).toHaveBeenCalled()
112
+ })
113
+ })
114
+
115
+ it('should clear filter when Clear button is clicked', async () => {
116
+ const findOptions = createFindOptions({ filter: { role: { $in: ['admin', 'user'] } } })
117
+ const { injector, onClose } = await renderEnumFilter(findOptions)
118
+ await usingAsync(injector, async () => {
119
+ const clearButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Clear')
120
+ clearButton?.click()
121
+ await flushUpdates()
122
+
123
+ expect(findOptions.getValue().filter?.role).toBeUndefined()
124
+ expect(onClose).toHaveBeenCalled()
125
+ })
126
+ })
127
+
128
+ it('should preserve filters on other fields', async () => {
129
+ const findOptions = createFindOptions({ filter: { role: { $in: ['admin'] }, name: { $regex: 'keep' } } })
130
+ const { injector } = await renderEnumFilter(findOptions)
131
+ await usingAsync(injector, async () => {
132
+ const clearButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Clear')
133
+ clearButton?.click()
134
+ await flushUpdates()
135
+
136
+ const updatedFilter = findOptions.getValue().filter
137
+ expect(updatedFilter?.role).toBeUndefined()
138
+ expect(updatedFilter?.name).toEqual({ $regex: 'keep' })
139
+ })
140
+ })
141
+
142
+ it('should reset skip to 0 when applying filter', async () => {
143
+ const findOptions = createFindOptions({ skip: 20 })
144
+ const { injector } = await renderEnumFilter(findOptions)
145
+ await usingAsync(injector, async () => {
146
+ const checkboxes = document.querySelectorAll('shade-checkbox input[type="checkbox"]')
147
+ const checkbox = checkboxes[0] as HTMLInputElement
148
+ checkbox.checked = true
149
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }))
150
+
151
+ const applyButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Apply')
152
+ applyButton?.click()
153
+ await flushUpdates()
154
+
155
+ expect(findOptions.getValue().skip).toBe(0)
156
+ })
157
+ })
158
+
159
+ it('should pre-check existing $in filter values', async () => {
160
+ const findOptions = createFindOptions({ filter: { role: { $in: ['admin', 'guest'] } } })
161
+ await usingAsync((await renderEnumFilter(findOptions)).injector, async () => {
162
+ const checkboxes = document.querySelectorAll('shade-checkbox input[type="checkbox"]')
163
+ expect((checkboxes[0] as HTMLInputElement).checked).toBe(true)
164
+ expect((checkboxes[1] as HTMLInputElement).checked).toBe(false)
165
+ expect((checkboxes[2] as HTMLInputElement).checked).toBe(true)
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,119 @@
1
+ import { createComponent, Shade } from '@furystack/shades'
2
+ import type { ObservableValue } from '@furystack/utils'
3
+ import { SegmentedControl } from '../../button-group.js'
4
+ import { Button } from '../../button.js'
5
+ import { close as closeIcon, search as searchIcon } from '../../icons/icon-definitions.js'
6
+ import { Icon } from '../../icons/icon.js'
7
+ import { Checkbox } from '../../inputs/checkbox.js'
8
+ import type { FilterableFindOptions } from '../data-grid.js'
9
+ import { filterBaseCss } from './filter-styles.js'
10
+
11
+ type EnumMode = 'include' | 'exclude'
12
+
13
+ export const EnumFilter = Shade<{
14
+ field: string
15
+ values: Array<{ label: string; value: string }>
16
+ findOptions: ObservableValue<FilterableFindOptions>
17
+ onClose: () => void
18
+ }>({
19
+ shadowDomName: 'data-grid-enum-filter',
20
+ css: {
21
+ ...filterBaseCss,
22
+ '& .filter-mode': {
23
+ marginBottom: '8px',
24
+ },
25
+ '& .filter-checkboxes': {
26
+ maxHeight: '200px',
27
+ overflowY: 'auto',
28
+ display: 'flex',
29
+ flexDirection: 'column',
30
+ gap: '2px',
31
+ marginBottom: '8px',
32
+ },
33
+ '& shade-checkbox': {
34
+ marginBottom: '0',
35
+ },
36
+ },
37
+ render: ({ props, useObservable, useState }) => {
38
+ const [findOptions, setFindOptions] = useObservable('findOptions', props.findOptions)
39
+
40
+ const currentFilter = findOptions.filter?.[props.field] as { $in?: string[]; $nin?: string[] } | undefined
41
+ const isExcludeMode = !!currentFilter?.$nin
42
+ const currentSelected = currentFilter?.$in ?? currentFilter?.$nin ?? []
43
+
44
+ const [mode, setMode] = useState<EnumMode>('mode', isExcludeMode ? 'exclude' : 'include')
45
+ const selected = new Set<string>(currentSelected)
46
+
47
+ const applyFilter = () => {
48
+ const filter = { ...findOptions.filter }
49
+ if (selected.size === 0) {
50
+ delete filter[props.field]
51
+ } else {
52
+ const operator = mode === 'include' ? '$in' : '$nin'
53
+ filter[props.field] = { [operator]: Array.from(selected) }
54
+ }
55
+ setFindOptions({ ...findOptions, filter, skip: 0 })
56
+ props.onClose()
57
+ }
58
+
59
+ const clearFilter = () => {
60
+ const filter = { ...findOptions.filter }
61
+ delete filter[props.field]
62
+ setFindOptions({ ...findOptions, filter, skip: 0 })
63
+ props.onClose()
64
+ }
65
+
66
+ return (
67
+ <div>
68
+ <div className="filter-mode">
69
+ <SegmentedControl
70
+ size="small"
71
+ value={mode}
72
+ onValueChange={(v) => setMode(v as EnumMode)}
73
+ options={[
74
+ { value: 'include', label: 'Include' },
75
+ { value: 'exclude', label: 'Exclude' },
76
+ ]}
77
+ />
78
+ </div>
79
+ <div className="filter-checkboxes">
80
+ {props.values.map(({ label, value }) => (
81
+ <Checkbox
82
+ checked={selected.has(value)}
83
+ labelTitle={label}
84
+ onchange={(ev: Event) => {
85
+ const isChecked = (ev.target as HTMLInputElement).checked
86
+ if (isChecked) {
87
+ selected.add(value)
88
+ } else {
89
+ selected.delete(value)
90
+ }
91
+ }}
92
+ />
93
+ ))}
94
+ </div>
95
+ <div className="filter-actions">
96
+ <Button
97
+ type="button"
98
+ variant="outlined"
99
+ size="small"
100
+ onclick={clearFilter}
101
+ startIcon={<Icon icon={closeIcon} size={14} />}
102
+ >
103
+ Clear
104
+ </Button>
105
+ <Button
106
+ type="button"
107
+ variant="outlined"
108
+ size="small"
109
+ color="primary"
110
+ onclick={applyFilter}
111
+ startIcon={<Icon icon={searchIcon} size={14} />}
112
+ >
113
+ Apply
114
+ </Button>
115
+ </div>
116
+ </div>
117
+ )
118
+ },
119
+ })