@coreui/data-grid 0.1.0-rc.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 (101) hide show
  1. package/LICENSE +3 -0
  2. package/README.md +37 -0
  3. package/dist/css/data-grid.css +444 -0
  4. package/dist/css/data-grid.css.map +1 -0
  5. package/dist/css/data-grid.min.css +5 -0
  6. package/dist/css/data-grid.min.css.map +1 -0
  7. package/dist/js/csv.cjs +68 -0
  8. package/dist/js/csv.cjs.map +1 -0
  9. package/dist/js/csv.mjs +63 -0
  10. package/dist/js/csv.mjs.map +1 -0
  11. package/dist/js/data-grid.esm.js +9618 -0
  12. package/dist/js/data-grid.esm.js.map +1 -0
  13. package/dist/js/data-grid.esm.min.js +8 -0
  14. package/dist/js/data-grid.esm.min.js.map +1 -0
  15. package/dist/js/data-grid.js +9635 -0
  16. package/dist/js/data-grid.js.map +1 -0
  17. package/dist/js/data-grid.min.js +8 -0
  18. package/dist/js/data-grid.min.js.map +1 -0
  19. package/dist/js/data-grid.mjs +9618 -0
  20. package/dist/js/data-grid.mjs.map +1 -0
  21. package/dist/js/filters.cjs +143 -0
  22. package/dist/js/filters.cjs.map +1 -0
  23. package/dist/js/filters.mjs +137 -0
  24. package/dist/js/filters.mjs.map +1 -0
  25. package/dist/js/labels.cjs +76 -0
  26. package/dist/js/labels.cjs.map +1 -0
  27. package/dist/js/labels.mjs +71 -0
  28. package/dist/js/labels.mjs.map +1 -0
  29. package/dist/types/base-component.d.ts +19 -0
  30. package/dist/types/data-grid.d.ts +128 -0
  31. package/dist/types/dom/event-handler.d.ts +11 -0
  32. package/dist/types/dom/manipulator.d.ts +9 -0
  33. package/dist/types/engine/active-cell.d.ts +32 -0
  34. package/dist/types/engine/data-source.d.ts +19 -0
  35. package/dist/types/engine/scheduler.d.ts +11 -0
  36. package/dist/types/engine/table.d.ts +9 -0
  37. package/dist/types/engine/virtualizer.d.ts +21 -0
  38. package/dist/types/filters.d.mts +1 -0
  39. package/dist/types/filters.d.ts +13 -0
  40. package/dist/types/icons.d.ts +21 -0
  41. package/dist/types/index.d.mts +1 -0
  42. package/dist/types/index.d.ts +12 -0
  43. package/dist/types/labels.d.mts +1 -0
  44. package/dist/types/labels.d.ts +9 -0
  45. package/dist/types/types.d.ts +262 -0
  46. package/dist/types/ui/body.d.ts +34 -0
  47. package/dist/types/ui/column-chooser.d.ts +27 -0
  48. package/dist/types/ui/editor.d.ts +8 -0
  49. package/dist/types/ui/filter-menu.d.ts +21 -0
  50. package/dist/types/ui/header.d.ts +50 -0
  51. package/dist/types/ui/loading.d.ts +12 -0
  52. package/dist/types/ui/menu.d.ts +17 -0
  53. package/dist/types/ui/pagination.d.ts +17 -0
  54. package/dist/types/ui/pinning.d.ts +17 -0
  55. package/dist/types/ui/toolbar.d.ts +34 -0
  56. package/dist/types/ui/tooltip.d.ts +10 -0
  57. package/dist/types/util/config.d.ts +11 -0
  58. package/dist/types/util/csv.d.mts +1 -0
  59. package/dist/types/util/csv.d.ts +19 -0
  60. package/dist/types/util/debounce.d.ts +7 -0
  61. package/dist/types/util/index.d.ts +10 -0
  62. package/dist/types/util/sanitizer.d.ts +11 -0
  63. package/package.json +147 -0
  64. package/scss/data-grid.scss +559 -0
  65. package/src/base-component.d.ts +19 -0
  66. package/src/base-component.js +90 -0
  67. package/src/data-grid.ts +2208 -0
  68. package/src/dom/data.js +59 -0
  69. package/src/dom/event-handler.d.ts +11 -0
  70. package/src/dom/event-handler.js +331 -0
  71. package/src/dom/manipulator.d.ts +9 -0
  72. package/src/dom/manipulator.js +75 -0
  73. package/src/engine/active-cell.ts +195 -0
  74. package/src/engine/data-source.ts +63 -0
  75. package/src/engine/scheduler.ts +42 -0
  76. package/src/engine/table.ts +122 -0
  77. package/src/engine/virtualizer.ts +57 -0
  78. package/src/filters.ts +176 -0
  79. package/src/icons.ts +60 -0
  80. package/src/index.ts +56 -0
  81. package/src/labels.ts +70 -0
  82. package/src/types.ts +334 -0
  83. package/src/ui/body.ts +348 -0
  84. package/src/ui/column-chooser.ts +183 -0
  85. package/src/ui/editor.ts +98 -0
  86. package/src/ui/filter-menu.ts +398 -0
  87. package/src/ui/header.ts +441 -0
  88. package/src/ui/loading.ts +39 -0
  89. package/src/ui/menu.ts +218 -0
  90. package/src/ui/pagination.ts +150 -0
  91. package/src/ui/pinning.ts +53 -0
  92. package/src/ui/toolbar.ts +144 -0
  93. package/src/ui/tooltip.ts +113 -0
  94. package/src/util/config.d.ts +11 -0
  95. package/src/util/config.js +69 -0
  96. package/src/util/csv.ts +85 -0
  97. package/src/util/debounce.ts +18 -0
  98. package/src/util/index.d.ts +10 -0
  99. package/src/util/index.js +310 -0
  100. package/src/util/sanitizer.d.ts +11 -0
  101. package/src/util/sanitizer.js +162 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/column-chooser.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { DataGridLabels } from '../types'
9
+
10
+ const CLASS_NAME_COLUMN_CHOOSER = 'data-grid-column-chooser'
11
+ const CLASS_NAME_OPTION = 'data-grid-column-chooser-option'
12
+ const CLASS_NAME_FOOTER = 'data-grid-column-chooser-footer'
13
+
14
+ export interface ColumnChooserColumn {
15
+ id: string
16
+ label: string
17
+ canHide: boolean
18
+ visible: boolean
19
+ }
20
+
21
+ export interface GridColumnChooserContext {
22
+ root: HTMLElement
23
+ labels: DataGridLabels
24
+ getColumns: () => ColumnChooserColumn[]
25
+ toggleVisibility: (columnId: string, visible: boolean) => void
26
+ showAll: () => void
27
+ reset: () => void
28
+ }
29
+
30
+ export interface GridColumnChooser {
31
+ toggle: (button: HTMLElement) => void
32
+ close: () => void
33
+ dispose: () => void
34
+ }
35
+
36
+ let columnChooserCount = 0
37
+
38
+ export const createColumnChooser = (context: GridColumnChooserContext): GridColumnChooser => {
39
+ const element = document.createElement('div')
40
+ element.classList.add(CLASS_NAME_COLUMN_CHOOSER)
41
+ element.setAttribute('role', 'dialog')
42
+ element.setAttribute('aria-label', context.labels.toolbarColumns)
43
+ element.id = `data-grid-column-chooser-${++columnChooserCount}`
44
+
45
+ let trigger: HTMLElement | null = null
46
+
47
+ const close = (refocus = false) => {
48
+ if (!trigger) {
49
+ return
50
+ }
51
+
52
+ trigger.setAttribute('aria-expanded', 'false')
53
+ trigger.removeAttribute('aria-controls')
54
+ if (refocus) {
55
+ trigger.focus()
56
+ }
57
+
58
+ trigger = null
59
+ element.remove()
60
+ document.removeEventListener('pointerdown', onOutsidePointerDown, true)
61
+ window.removeEventListener('scroll', onWindowChange, true)
62
+ window.removeEventListener('resize', onWindowChange)
63
+ }
64
+
65
+ const onOutsidePointerDown = (event: Event) => {
66
+ const target = event.target as Node
67
+ if (!element.contains(target) && !trigger?.contains(target)) {
68
+ close()
69
+ }
70
+ }
71
+
72
+ const onWindowChange = (event?: Event) => {
73
+ if (event?.target instanceof Node && element.contains(event.target)) {
74
+ return
75
+ }
76
+
77
+ close()
78
+ }
79
+
80
+ const onKeydown = (event: KeyboardEvent) => {
81
+ if (event.key === 'Escape') {
82
+ event.stopPropagation()
83
+ close(true)
84
+ }
85
+ }
86
+
87
+ element.addEventListener('keydown', onKeydown)
88
+
89
+ const buildContent = () => {
90
+ const list = document.createElement('div')
91
+
92
+ for (const column of context.getColumns()) {
93
+ const label = document.createElement('label')
94
+ label.classList.add('form-check', CLASS_NAME_OPTION)
95
+ const input = document.createElement('input')
96
+ input.type = 'checkbox'
97
+ input.classList.add('form-check-input')
98
+ input.checked = column.visible
99
+ input.dataset.coreuiColumnChooserOption = column.id
100
+ if (!column.canHide) {
101
+ input.disabled = true
102
+ input.setAttribute('aria-disabled', 'true')
103
+ }
104
+
105
+ input.addEventListener('change', () => {
106
+ context.toggleVisibility(column.id, input.checked)
107
+ })
108
+ const text = document.createElement('span')
109
+ text.classList.add('form-check-label')
110
+ text.textContent = column.label
111
+ label.append(input, text)
112
+ list.append(label)
113
+ }
114
+
115
+ const footer = document.createElement('div')
116
+ footer.classList.add(CLASS_NAME_FOOTER)
117
+
118
+ const showAll = document.createElement('button')
119
+ showAll.type = 'button'
120
+ showAll.classList.add('btn', 'btn-link', 'btn-sm')
121
+ showAll.textContent = context.labels.showAllColumns
122
+ showAll.dataset.coreuiColumnChooserShowAll = ''
123
+ showAll.addEventListener('click', () => {
124
+ context.showAll()
125
+ element.replaceChildren(buildContent())
126
+ })
127
+
128
+ const reset = document.createElement('button')
129
+ reset.type = 'button'
130
+ reset.classList.add('btn', 'btn-link', 'btn-sm')
131
+ reset.textContent = context.labels.resetColumns
132
+ reset.dataset.coreuiColumnChooserReset = ''
133
+ reset.addEventListener('click', () => {
134
+ context.reset()
135
+ element.replaceChildren(buildContent())
136
+ })
137
+
138
+ footer.append(showAll, reset)
139
+
140
+ const content = document.createDocumentFragment()
141
+ content.append(list, footer)
142
+ return content
143
+ }
144
+
145
+ const open = (button: HTMLElement) => {
146
+ close()
147
+ element.replaceChildren(buildContent())
148
+
149
+ trigger = button
150
+ button.setAttribute('aria-expanded', 'true')
151
+ button.setAttribute('aria-controls', element.id)
152
+ context.root.append(element)
153
+
154
+ const rootRect = context.root.getBoundingClientRect()
155
+ const buttonRect = button.getBoundingClientRect()
156
+ element.style.top = `${buttonRect.bottom - rootRect.top}px`
157
+ element.style.left = `${Math.max(buttonRect.left - rootRect.left, 0)}px`
158
+
159
+ document.addEventListener('pointerdown', onOutsidePointerDown, true)
160
+ window.addEventListener('scroll', onWindowChange, true)
161
+ window.addEventListener('resize', onWindowChange)
162
+ element.querySelector<HTMLElement>('input:not(:disabled)')?.focus()
163
+ }
164
+
165
+ return {
166
+ toggle(button) {
167
+ if (trigger === button) {
168
+ close(true)
169
+ return
170
+ }
171
+
172
+ close()
173
+ open(button)
174
+ },
175
+ close() {
176
+ close()
177
+ },
178
+ dispose() {
179
+ close()
180
+ element.removeEventListener('keydown', onKeydown)
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/editor.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type {
9
+ DataGridEditableOptions, DataGridEditor, DataGridEditorContext
10
+ } from '../types'
11
+
12
+ const CLASS_NAME_EDITOR = 'data-grid-cell-editor'
13
+
14
+ const createTextEditor = (context: DataGridEditorContext): DataGridEditor => {
15
+ const input = document.createElement('input')
16
+ input.type = 'text'
17
+ input.classList.add('form-control', 'form-control-sm', CLASS_NAME_EDITOR)
18
+ input.value = String(context.value ?? '')
19
+
20
+ return {
21
+ element: input,
22
+ focus() {
23
+ input.focus()
24
+ input.select()
25
+ },
26
+ getValue: () => input.value
27
+ }
28
+ }
29
+
30
+ const createNumberEditor = (context: DataGridEditorContext, options: DataGridEditableOptions): DataGridEditor => {
31
+ const input = document.createElement('input')
32
+ input.type = 'number'
33
+ input.classList.add('form-control', 'form-control-sm', CLASS_NAME_EDITOR)
34
+ if (options.min !== undefined) {
35
+ input.min = String(options.min)
36
+ }
37
+
38
+ if (options.max !== undefined) {
39
+ input.max = String(options.max)
40
+ }
41
+
42
+ if (options.step !== undefined) {
43
+ input.step = String(options.step)
44
+ }
45
+
46
+ input.value = context.value === undefined || context.value === null ? '' : String(context.value)
47
+
48
+ return {
49
+ element: input,
50
+ focus() {
51
+ input.focus()
52
+ input.select()
53
+ },
54
+ getValue: () => (input.value === '' ? undefined : Number(input.value))
55
+ }
56
+ }
57
+
58
+ const createSelectEditor = (context: DataGridEditorContext, options: DataGridEditableOptions): DataGridEditor => {
59
+ const select = document.createElement('select')
60
+ select.classList.add('form-select', 'form-select-sm', CLASS_NAME_EDITOR)
61
+
62
+ // Option values can be arbitrary (not just strings) - keep the originals
63
+ // and resolve the committed value by index.
64
+ const values: unknown[] = []
65
+ for (const option of options.options ?? []) {
66
+ const optionElement = document.createElement('option')
67
+ const value = typeof option === 'object' && option !== null ? option.value : option
68
+ optionElement.textContent = typeof option === 'object' && option !== null ? option.label : String(option)
69
+ optionElement.value = String(values.length)
70
+ if (value === context.value) {
71
+ optionElement.selected = true
72
+ }
73
+
74
+ values.push(value)
75
+ select.append(optionElement)
76
+ }
77
+
78
+ return {
79
+ element: select,
80
+ focus: () => select.focus(),
81
+ getValue: () => values[Number(select.value)]
82
+ }
83
+ }
84
+
85
+ export const createBuiltInEditor = (
86
+ options: DataGridEditableOptions,
87
+ context: DataGridEditorContext
88
+ ): DataGridEditor => {
89
+ if (options.type === 'number') {
90
+ return createNumberEditor(context, options)
91
+ }
92
+
93
+ if (options.type === 'select') {
94
+ return createSelectEditor(context, options)
95
+ }
96
+
97
+ return createTextEditor(context)
98
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/filter-menu.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import { FILTER_OPERATORS, VALUELESS_OPERATORS } from '../filters'
9
+ import { interpolate } from '../labels'
10
+ import type {
11
+ DataGridColumn,
12
+ DataGridColumnFilterValue,
13
+ DataGridFilterCondition,
14
+ DataGridFilterOperator,
15
+ DataGridFilterType,
16
+ DataGridLabels
17
+ } from '../types'
18
+
19
+ const CLASS_NAME_FILTER_MENU = 'data-grid-filter-menu'
20
+ const CLASS_NAME_CONDITION = 'data-grid-filter-menu-condition'
21
+ const CLASS_NAME_JOIN = 'data-grid-filter-menu-join'
22
+ const CLASS_NAME_FOOTER = 'data-grid-filter-menu-footer'
23
+ const CLASS_NAME_VALUES = 'data-grid-filter-menu-values'
24
+
25
+ const SEARCH_THRESHOLD = 10
26
+ const MAX_CONDITIONS = 2
27
+
28
+ const OPERATOR_LABEL_KEYS: Record<DataGridFilterOperator, keyof DataGridLabels> = {
29
+ contains: 'operatorContains',
30
+ notContains: 'operatorNotContains',
31
+ equals: 'operatorEquals',
32
+ notEquals: 'operatorNotEquals',
33
+ startsWith: 'operatorStartsWith',
34
+ endsWith: 'operatorEndsWith',
35
+ blank: 'operatorBlank',
36
+ notBlank: 'operatorNotBlank',
37
+ greaterThan: 'operatorGreaterThan',
38
+ greaterThanOrEqual: 'operatorGreaterThanOrEqual',
39
+ lessThan: 'operatorLessThan',
40
+ lessThanOrEqual: 'operatorLessThanOrEqual',
41
+ between: 'operatorBetween'
42
+ }
43
+
44
+ export interface GridFilterMenuContext {
45
+ root: HTMLElement
46
+ labels: DataGridLabels
47
+ getColumn: (columnKey: string) => DataGridColumn | null
48
+ getFacetedValues: (columnKey: string) => unknown[]
49
+ getFilterValue: (columnKey: string) => DataGridColumnFilterValue | undefined
50
+ applyFilter: (columnKey: string, value?: DataGridColumnFilterValue) => void
51
+ }
52
+
53
+ export interface GridFilterMenu {
54
+ toggle: (button: HTMLElement, columnKey: string) => void
55
+ close: () => void
56
+ dispose: () => void
57
+ }
58
+
59
+ let filterMenuCount = 0
60
+
61
+ const inputTypeFor = (filterType: DataGridFilterType): string =>
62
+ (filterType === 'number' ? 'number' : (filterType === 'date' ? 'date' : 'text'))
63
+
64
+ export const createFilterMenu = (context: GridFilterMenuContext): GridFilterMenu => {
65
+ const element = document.createElement('div')
66
+ element.classList.add(CLASS_NAME_FILTER_MENU)
67
+ element.setAttribute('role', 'dialog')
68
+ element.id = `data-grid-filter-menu-${++filterMenuCount}`
69
+
70
+ let trigger: HTMLElement | null = null
71
+
72
+ const close = (refocus = false) => {
73
+ if (!trigger) {
74
+ return
75
+ }
76
+
77
+ trigger.setAttribute('aria-expanded', 'false')
78
+ trigger.removeAttribute('aria-controls')
79
+ if (refocus) {
80
+ trigger.focus()
81
+ }
82
+
83
+ trigger = null
84
+ element.remove()
85
+ document.removeEventListener('pointerdown', onOutsidePointerDown, true)
86
+ window.removeEventListener('scroll', onWindowChange, true)
87
+ window.removeEventListener('resize', onWindowChange)
88
+ }
89
+
90
+ const onOutsidePointerDown = (event: Event) => {
91
+ const target = event.target as Node
92
+ if (!element.contains(target) && !trigger?.contains(target)) {
93
+ close()
94
+ }
95
+ }
96
+
97
+ const onWindowChange = (event?: Event) => {
98
+ if (event?.target instanceof Node && element.contains(event.target)) {
99
+ return
100
+ }
101
+
102
+ close()
103
+ }
104
+
105
+ const onKeydown = (event: KeyboardEvent) => {
106
+ if (event.key === 'Escape') {
107
+ event.stopPropagation()
108
+ close(true)
109
+ }
110
+ }
111
+
112
+ element.addEventListener('keydown', onKeydown)
113
+
114
+ const buildConditionRow = (
115
+ filterType: Exclude<DataGridFilterType, 'select'>,
116
+ condition: DataGridFilterCondition | null
117
+ ): HTMLElement => {
118
+ const row = document.createElement('div')
119
+ row.classList.add(CLASS_NAME_CONDITION)
120
+
121
+ const select = document.createElement('select')
122
+ select.classList.add('form-select', 'form-select-sm')
123
+ select.setAttribute('aria-label', context.labels.filterAction)
124
+ for (const operator of FILTER_OPERATORS[filterType]) {
125
+ const option = document.createElement('option')
126
+ option.value = operator
127
+ option.textContent = context.labels[OPERATOR_LABEL_KEYS[operator]]
128
+ select.append(option)
129
+ }
130
+
131
+ select.value = condition?.operator ?? FILTER_OPERATORS[filterType][0]
132
+
133
+ const value = document.createElement('input')
134
+ value.type = inputTypeFor(filterType)
135
+ value.classList.add('form-control', 'form-control-sm')
136
+ value.dataset.coreuiFilterValue = ''
137
+ value.value = condition?.value === undefined ? '' : String(condition.value)
138
+
139
+ const value2 = document.createElement('input')
140
+ value2.type = inputTypeFor(filterType)
141
+ value2.classList.add('form-control', 'form-control-sm')
142
+ value2.dataset.coreuiFilterValue2 = ''
143
+ value2.value = condition?.value2 === undefined ? '' : String(condition.value2)
144
+
145
+ const syncVisibility = () => {
146
+ const operator = select.value as DataGridFilterOperator
147
+ value.hidden = VALUELESS_OPERATORS.has(operator)
148
+ value2.hidden = operator !== 'between'
149
+ }
150
+
151
+ select.addEventListener('change', syncVisibility)
152
+ row.append(select, value, value2)
153
+ syncVisibility()
154
+ return row
155
+ }
156
+
157
+ const readCondition = (row: HTMLElement): DataGridFilterCondition | null => {
158
+ const operator = row.querySelector('select')!.value as DataGridFilterOperator
159
+ const { value } = (row.querySelector<HTMLInputElement>('[data-coreui-filter-value]')!)
160
+ const value2 = row.querySelector<HTMLInputElement>('[data-coreui-filter-value2]')!.value
161
+
162
+ if (VALUELESS_OPERATORS.has(operator)) {
163
+ return { operator }
164
+ }
165
+
166
+ if (value.trim() === '') {
167
+ return null
168
+ }
169
+
170
+ return {
171
+ operator,
172
+ value,
173
+ ...(operator === 'between' && value2.trim() !== '' && { value2 })
174
+ }
175
+ }
176
+
177
+ const buildFooter = (onApply: () => void, columnKey: string): HTMLElement => {
178
+ const footer = document.createElement('div')
179
+ footer.classList.add(CLASS_NAME_FOOTER)
180
+
181
+ const apply = document.createElement('button')
182
+ apply.type = 'button'
183
+ apply.classList.add('btn', 'btn-primary', 'btn-sm')
184
+ apply.textContent = context.labels.applyFilter
185
+ apply.addEventListener('click', onApply)
186
+
187
+ const clear = document.createElement('button')
188
+ clear.type = 'button'
189
+ clear.classList.add('btn', 'btn-outline-secondary', 'btn-sm')
190
+ clear.textContent = context.labels.clearFilter
191
+ clear.addEventListener('click', () => {
192
+ context.applyFilter(columnKey)
193
+ close(true)
194
+ })
195
+
196
+ footer.append(apply, clear)
197
+ return footer
198
+ }
199
+
200
+ const buildConditionsContent = (columnKey: string, filterType: Exclude<DataGridFilterType, 'select'>) => {
201
+ const current = context.getFilterValue(columnKey)
202
+ const conditions =
203
+ current && typeof current !== 'string' && 'conditions' in current ? current.conditions : []
204
+ const join = current && typeof current !== 'string' && 'conditions' in current ? current.join : 'and'
205
+
206
+ const rows: HTMLElement[] = [buildConditionRow(filterType, conditions[0] ?? null)]
207
+ const body = document.createElement('div')
208
+ body.append(rows[0])
209
+
210
+ const joinRow = document.createElement('div')
211
+ joinRow.classList.add(CLASS_NAME_JOIN)
212
+ const joinName = `${element.id}-join`
213
+ for (const option of ['and', 'or'] as const) {
214
+ const label = document.createElement('label')
215
+ const radio = document.createElement('input')
216
+ radio.type = 'radio'
217
+ radio.classList.add('form-check-input')
218
+ radio.name = joinName
219
+ radio.value = option
220
+ radio.checked = join === option
221
+ label.append(radio, ` ${option === 'and' ? context.labels.joinAnd : context.labels.joinOr}`)
222
+ joinRow.append(label)
223
+ }
224
+
225
+ const addButton = document.createElement('button')
226
+ addButton.type = 'button'
227
+ addButton.classList.add('btn', 'btn-link', 'btn-sm')
228
+ addButton.textContent = context.labels.addCondition
229
+
230
+ const showSecond = () => {
231
+ if (rows.length >= MAX_CONDITIONS) {
232
+ return
233
+ }
234
+
235
+ rows.push(buildConditionRow(filterType, conditions[1] ?? null))
236
+ addButton.before(joinRow, rows[1])
237
+ addButton.hidden = true
238
+ }
239
+
240
+ addButton.addEventListener('click', showSecond)
241
+ body.append(addButton)
242
+ if (conditions.length > 1) {
243
+ showSecond()
244
+ }
245
+
246
+ const onApply = () => {
247
+ const collected = rows
248
+ .map(row => readCondition(row))
249
+ .filter((condition): condition is DataGridFilterCondition => condition !== null)
250
+ const joinValue =
251
+ (joinRow.querySelector<HTMLInputElement>('input:checked')?.value as 'and' | 'or') ?? 'and'
252
+ context.applyFilter(
253
+ columnKey,
254
+ collected.length === 0 ? undefined : { conditions: collected, join: joinValue }
255
+ )
256
+ close(true)
257
+ }
258
+
259
+ body.append(buildFooter(onApply, columnKey))
260
+ return body
261
+ }
262
+
263
+ const buildSelectContent = (columnKey: string) => {
264
+ const values = context.getFacetedValues(columnKey)
265
+ const current = context.getFilterValue(columnKey)
266
+ const selected =
267
+ current && typeof current !== 'string' && 'operator' in current ?
268
+ new Set(current.value.map(String)) :
269
+ null
270
+
271
+ const body = document.createElement('div')
272
+ const list = document.createElement('div')
273
+ list.classList.add(CLASS_NAME_VALUES)
274
+
275
+ const checkboxes: HTMLInputElement[] = []
276
+ const rowFor = (labelText: string, checked: boolean): HTMLInputElement => {
277
+ const label = document.createElement('label')
278
+ label.classList.add('form-check')
279
+ const input = document.createElement('input')
280
+ input.type = 'checkbox'
281
+ input.classList.add('form-check-input')
282
+ input.checked = checked
283
+ const text = document.createElement('span')
284
+ text.classList.add('form-check-label')
285
+ text.textContent = labelText
286
+ label.append(input, text)
287
+ list.append(label)
288
+ return input
289
+ }
290
+
291
+ const selectAll = rowFor(context.labels.selectAllValues, selected === null)
292
+ selectAll.dataset.coreuiSelectAllValues = ''
293
+
294
+ for (const value of values) {
295
+ const input = rowFor(String(value), selected === null || selected.has(String(value)))
296
+ input.dataset.coreuiFilterOption = String(value)
297
+ checkboxes.push(input)
298
+ }
299
+
300
+ const syncSelectAll = () => {
301
+ selectAll.checked = checkboxes.every(input => input.checked)
302
+ }
303
+
304
+ selectAll.addEventListener('change', () => {
305
+ for (const input of checkboxes) {
306
+ input.checked = selectAll.checked
307
+ }
308
+ })
309
+ list.addEventListener('change', event => {
310
+ if (event.target !== selectAll) {
311
+ syncSelectAll()
312
+ }
313
+ })
314
+ syncSelectAll()
315
+
316
+ if (values.length > SEARCH_THRESHOLD) {
317
+ const search = document.createElement('input')
318
+ search.type = 'text'
319
+ search.classList.add('form-control', 'form-control-sm')
320
+ search.setAttribute('aria-label', context.labels.searchValues)
321
+ search.placeholder = context.labels.searchValues
322
+ search.addEventListener('input', () => {
323
+ const needle = search.value.toLowerCase()
324
+ for (const input of checkboxes) {
325
+ input.closest('label')!.hidden = !input.dataset.coreuiFilterOption!.toLowerCase().includes(needle)
326
+ }
327
+ })
328
+ body.append(search)
329
+ }
330
+
331
+ body.append(list)
332
+
333
+ const onApply = () => {
334
+ const checked = checkboxes.filter(input => input.checked)
335
+ context.applyFilter(
336
+ columnKey,
337
+ checked.length === checkboxes.length ?
338
+ undefined :
339
+ { operator: 'in', value: checked.map(input => input.dataset.coreuiFilterOption!) }
340
+ )
341
+ close(true)
342
+ }
343
+
344
+ body.append(buildFooter(onApply, columnKey))
345
+ return body
346
+ }
347
+
348
+ const open = (button: HTMLElement, columnKey: string) => {
349
+ close()
350
+ const column = context.getColumn(columnKey)
351
+ if (!column) {
352
+ return
353
+ }
354
+
355
+ element.setAttribute(
356
+ 'aria-label',
357
+ interpolate(context.labels.filterColumn, { column: column.label ?? column.key })
358
+ )
359
+ const filterType = column.filterType ?? 'text'
360
+ element.replaceChildren(
361
+ filterType === 'select' ? buildSelectContent(columnKey) : buildConditionsContent(columnKey, filterType)
362
+ )
363
+
364
+ trigger = button
365
+ button.setAttribute('aria-expanded', 'true')
366
+ button.setAttribute('aria-controls', element.id)
367
+ context.root.append(element)
368
+
369
+ const rootRect = context.root.getBoundingClientRect()
370
+ const buttonRect = button.getBoundingClientRect()
371
+ element.style.top = `${buttonRect.bottom - rootRect.top}px`
372
+ element.style.left = `${Math.max(buttonRect.right - rootRect.left - element.offsetWidth, 0)}px`
373
+
374
+ document.addEventListener('pointerdown', onOutsidePointerDown, true)
375
+ window.addEventListener('scroll', onWindowChange, true)
376
+ window.addEventListener('resize', onWindowChange)
377
+ element.querySelector<HTMLElement>('select, input')?.focus()
378
+ }
379
+
380
+ return {
381
+ toggle(button, columnKey) {
382
+ if (trigger === button) {
383
+ close(true)
384
+ return
385
+ }
386
+
387
+ close()
388
+ open(button, columnKey)
389
+ },
390
+ close() {
391
+ close()
392
+ },
393
+ dispose() {
394
+ close()
395
+ element.removeEventListener('keydown', onKeydown)
396
+ }
397
+ }
398
+ }