@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,441 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/header.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import { interpolate } from '../labels'
9
+ import type {
10
+ DataGridColumn, DataGridItem, DataGridLabels, DataGridSlot, SortingState, Table
11
+ } from '../types'
12
+ import { applyPinnedCell, type GridPinning } from './pinning'
13
+
14
+ const CLASS_NAME_HEADER = 'data-grid-header'
15
+ const CLASS_NAME_HEADER_CELL = 'data-grid-header-cell'
16
+ const CLASS_NAME_HEADER_LABEL = 'data-grid-header-label'
17
+ const CLASS_NAME_FILTER_ROW = 'data-grid-filter-row'
18
+ const CLASS_NAME_RESIZER = 'data-grid-resizer'
19
+ const CLASS_NAME_RESIZER_ACTIVE = 'data-grid-resizer-active'
20
+ const CLASS_NAME_SELECT_CELL = 'data-grid-select-cell'
21
+ const CLASS_NAME_SORTABLE = 'data-grid-sortable'
22
+ const CLASS_NAME_SORT_BUTTON = 'data-grid-sort-button'
23
+ const CLASS_NAME_SORT_ICON = 'data-grid-sort-icon'
24
+
25
+ export interface GridHeaderContext {
26
+ // With cellNavigation on, the label row joins the roving-tabindex grid: its
27
+ // cells carry grid roles and neither they nor their buttons are tabbable.
28
+ // The filter row keeps native tab semantics (an input row, not grid cells).
29
+ cellNavigation: boolean
30
+ columns: DataGridColumn[]
31
+ draggable: boolean
32
+ filterRow: boolean
33
+ hasMenu: (column: DataGridColumn) => boolean
34
+ icons: {
35
+ columnMenu: string
36
+ filter: string
37
+ sortAscending: string
38
+ sortDescending: string
39
+ sortNeutral: string
40
+ }
41
+ hasFilterMenu: (column: DataGridColumn) => boolean
42
+ isFilterable: (column: DataGridColumn) => boolean
43
+ isResizable: (column: DataGridColumn) => boolean
44
+ isSortable: (column: DataGridColumn) => boolean
45
+ labels: DataGridLabels
46
+ resizable: boolean
47
+ selection: boolean
48
+ selectAll: boolean
49
+ table: Table<DataGridItem>
50
+ }
51
+
52
+ export interface GridHeaderState {
53
+ // Columns with any active filter value - drives the filter button indicator.
54
+ // Null when column filters are off.
55
+ filteredColumns: string[] | null
56
+ // Visible leaf column ids in table order (pinning + columnOrder + visibility).
57
+ order: string[]
58
+ pinning: GridPinning | null
59
+ resizingColumn: string | false
60
+ sizes: Record<string, number> | null
61
+ sorting: SortingState
62
+ selectAll: {
63
+ checked: boolean
64
+ indeterminate: boolean
65
+ } | null
66
+ }
67
+
68
+ export interface GridHeader {
69
+ element: HTMLTableSectionElement
70
+ rowCount: number
71
+ update: (state: GridHeaderState) => void
72
+ dispose: () => void
73
+ }
74
+
75
+ type SortDirection = 'ascending' | 'descending' | 'neutral'
76
+
77
+ const sortDirection = (columnSort: SortingState[number] | undefined): SortDirection => {
78
+ if (!columnSort) {
79
+ return 'neutral'
80
+ }
81
+
82
+ return columnSort.desc ? 'descending' : 'ascending'
83
+ }
84
+
85
+ const iconForDirection = (direction: SortDirection, icons: GridHeaderContext['icons']): string => {
86
+ if (direction === 'ascending') {
87
+ return icons.sortAscending
88
+ }
89
+
90
+ if (direction === 'descending') {
91
+ return icons.sortDescending
92
+ }
93
+
94
+ return icons.sortNeutral
95
+ }
96
+
97
+ const createSelectAllCell = (context: GridHeaderContext): { cell: HTMLTableCellElement; input: HTMLInputElement | null } => {
98
+ const cell = document.createElement('th')
99
+ cell.classList.add(CLASS_NAME_SELECT_CELL)
100
+ cell.setAttribute('scope', 'col')
101
+
102
+ if (!context.selectAll) {
103
+ return { cell, input: null }
104
+ }
105
+
106
+ const input = document.createElement('input')
107
+ input.type = 'checkbox'
108
+ input.classList.add('form-check-input')
109
+ input.setAttribute('aria-label', context.labels.selectAllRows)
110
+ input.dataset.coreuiSelectAll = ''
111
+ cell.append(input)
112
+
113
+ return { cell, input }
114
+ }
115
+
116
+ const createResizer = (): HTMLElement => {
117
+ const resizer = document.createElement('span')
118
+ resizer.classList.add(CLASS_NAME_RESIZER)
119
+ resizer.setAttribute('aria-hidden', 'true')
120
+ resizer.dataset.coreuiResizer = ''
121
+ return resizer
122
+ }
123
+
124
+ const createLabelRow = (
125
+ context: GridHeaderContext,
126
+ cells: Map<string, HTMLTableCellElement>,
127
+ resizers: Map<string, HTMLElement>,
128
+ sortIcons: Map<string, HTMLElement>
129
+ ): {
130
+ row: HTMLTableRowElement
131
+ selectAllInput: HTMLInputElement | null
132
+ selectCell: HTMLTableCellElement | null
133
+ } => {
134
+ const row = document.createElement('tr')
135
+ let selectAllInput: HTMLInputElement | null = null
136
+ let selectCell: HTMLTableCellElement | null = null
137
+
138
+ if (context.cellNavigation) {
139
+ row.setAttribute('role', 'row')
140
+ }
141
+
142
+ if (context.selection) {
143
+ const { cell, input } = createSelectAllCell(context)
144
+ selectAllInput = input
145
+ selectCell = cell
146
+ if (context.cellNavigation) {
147
+ cell.setAttribute('role', 'columnheader')
148
+ if (input) {
149
+ input.tabIndex = -1
150
+ }
151
+ }
152
+
153
+ row.append(cell)
154
+ }
155
+
156
+ for (const column of context.columns) {
157
+ const cell = document.createElement('th')
158
+ cell.setAttribute('scope', 'col')
159
+ cell.dataset.coreuiColumn = column.key
160
+ if (context.cellNavigation) {
161
+ cell.setAttribute('role', 'columnheader')
162
+ cell.tabIndex = -1
163
+ }
164
+
165
+ if (column.style) {
166
+ Object.assign(cell.style, column.style)
167
+ }
168
+
169
+ if (column.width !== undefined) {
170
+ cell.style.width = `${column.width}px`
171
+ }
172
+
173
+ const content = document.createElement('div')
174
+ content.classList.add(CLASS_NAME_HEADER_CELL)
175
+
176
+ if (context.isSortable(column)) {
177
+ cell.classList.add(CLASS_NAME_SORTABLE)
178
+ const button = document.createElement('button')
179
+ button.type = 'button'
180
+ button.classList.add(CLASS_NAME_SORT_BUTTON)
181
+ button.append(column.label ?? column.key)
182
+ const icon = document.createElement('span')
183
+ icon.classList.add(CLASS_NAME_SORT_ICON)
184
+ icon.setAttribute('aria-hidden', 'true')
185
+ icon.innerHTML = context.icons.sortNeutral
186
+ icon.dataset.coreuiSort = 'neutral'
187
+ button.append(icon)
188
+ sortIcons.set(column.key, icon)
189
+ content.append(button)
190
+ } else {
191
+ const label = document.createElement('span')
192
+ label.classList.add(CLASS_NAME_HEADER_LABEL)
193
+ label.textContent = column.label ?? column.key
194
+ content.append(label)
195
+ }
196
+
197
+ if (context.hasFilterMenu(column)) {
198
+ const button = document.createElement('button')
199
+ button.type = 'button'
200
+ button.classList.add('btn', 'btn-ghost', 'btn-sm', 'data-grid-filter-button')
201
+ button.innerHTML = context.icons.filter
202
+ button.dataset.coreuiFilter = column.key
203
+ button.setAttribute('aria-haspopup', 'dialog')
204
+ button.setAttribute('aria-expanded', 'false')
205
+ const filterLabel = interpolate(context.labels.filterColumn, { column: column.label ?? column.key })
206
+ button.setAttribute('aria-label', filterLabel)
207
+ button.setAttribute('data-coreui-tooltip', filterLabel)
208
+ content.append(button)
209
+ }
210
+
211
+ if (context.hasMenu(column)) {
212
+ const button = document.createElement('button')
213
+ button.type = 'button'
214
+ button.classList.add('btn', 'btn-ghost', 'btn-sm', 'data-grid-menu-button')
215
+ button.innerHTML = context.icons.columnMenu
216
+ button.dataset.coreuiMenu = column.key
217
+ button.setAttribute('aria-haspopup', 'menu')
218
+ button.setAttribute('aria-expanded', 'false')
219
+ const menuLabel = interpolate(context.labels.columnMenu, { column: column.label ?? column.key })
220
+ button.setAttribute('aria-label', menuLabel)
221
+ button.setAttribute('data-coreui-tooltip', menuLabel)
222
+ content.append(button)
223
+ }
224
+
225
+ cell.append(content)
226
+
227
+ if (context.cellNavigation) {
228
+ for (const button of content.querySelectorAll('button')) {
229
+ button.tabIndex = -1
230
+ }
231
+ }
232
+
233
+ if (context.resizable && context.isResizable(column)) {
234
+ const resizer = createResizer()
235
+ resizers.set(column.key, resizer)
236
+ cell.append(resizer)
237
+ }
238
+
239
+ cells.set(column.key, cell)
240
+ row.append(cell)
241
+ }
242
+
243
+ return { row, selectAllInput, selectCell }
244
+ }
245
+
246
+ const createFilterRow = (
247
+ context: GridHeaderContext,
248
+ filterCells: Map<string, HTMLTableCellElement>,
249
+ filterSlots: DataGridSlot[]
250
+ ): { row: HTMLTableRowElement; selectCell: HTMLTableCellElement | null } => {
251
+ const row = document.createElement('tr')
252
+ row.classList.add(CLASS_NAME_FILTER_ROW)
253
+ if (context.cellNavigation) {
254
+ row.setAttribute('role', 'row')
255
+ }
256
+
257
+ let selectCell: HTMLTableCellElement | null = null
258
+
259
+ if (context.selection) {
260
+ selectCell = document.createElement('th')
261
+ row.append(selectCell)
262
+ }
263
+
264
+ for (const column of context.columns) {
265
+ const cell = document.createElement('th')
266
+ const tableColumn = context.table.getColumn(column.key)
267
+ if (context.isFilterable(column) && column.filter && tableColumn) {
268
+ const slot = column.filter({ column: tableColumn, table: context.table, labels: context.labels })
269
+ filterSlots.push(slot)
270
+ cell.append(slot.element)
271
+ }
272
+
273
+ filterCells.set(column.key, cell)
274
+ row.append(cell)
275
+ }
276
+
277
+ return { row, selectCell }
278
+ }
279
+
280
+ export const createHeader = (context: GridHeaderContext): GridHeader => {
281
+ const element = document.createElement('thead')
282
+ element.classList.add(CLASS_NAME_HEADER)
283
+
284
+ const cells = new Map<string, HTMLTableCellElement>()
285
+ const resizers = new Map<string, HTMLElement>()
286
+ const sortIcons = new Map<string, HTMLElement>()
287
+ const filterCells = new Map<string, HTMLTableCellElement>()
288
+ const filterSlots: DataGridSlot[] = []
289
+ const movableColumns = new Map(context.columns.map(column => [column.key, column.movable !== false]))
290
+
291
+ const { row, selectAllInput, selectCell } = createLabelRow(context, cells, resizers, sortIcons)
292
+ element.append(row)
293
+
294
+ let filterRowElement: HTMLTableRowElement | null = null
295
+ let filterSelectCell: HTMLTableCellElement | null = null
296
+ if (context.filterRow) {
297
+ const filterRow = createFilterRow(context, filterCells, filterSlots)
298
+ filterSelectCell = filterRow.selectCell
299
+ filterRowElement = filterRow.row
300
+ element.append(filterRow.row)
301
+ }
302
+
303
+ // Cells are created once per config column; ordering/visibility re-appends
304
+ // the existing nodes (appending moves them), so sort buttons, resizers and
305
+ // custom filter UIs keep their DOM state.
306
+ let orderSignature: string | null = null
307
+ const applyOrder = (order: string[]) => {
308
+ const visible = new Set(order)
309
+ const reorderRow = (
310
+ rowElement: HTMLTableRowElement,
311
+ cellMap: Map<string, HTMLTableCellElement>,
312
+ leading: HTMLTableCellElement | null
313
+ ) => {
314
+ const ordered: HTMLTableCellElement[] = leading ? [leading] : []
315
+ for (const id of order) {
316
+ const cell = cellMap.get(id)
317
+ if (cell) {
318
+ ordered.push(cell)
319
+ }
320
+ }
321
+
322
+ rowElement.append(...ordered)
323
+ if (context.cellNavigation) {
324
+ for (const [index, cell] of ordered.entries()) {
325
+ cell.setAttribute('aria-colindex', String(index + 1))
326
+ }
327
+ }
328
+
329
+ for (const [key, cell] of cellMap) {
330
+ if (!visible.has(key)) {
331
+ cell.remove()
332
+ }
333
+ }
334
+ }
335
+
336
+ reorderRow(row, cells, selectCell)
337
+ if (filterRowElement) {
338
+ reorderRow(filterRowElement, filterCells, filterSelectCell)
339
+ }
340
+ }
341
+
342
+ return {
343
+ element,
344
+ rowCount: context.filterRow ? 2 : 1,
345
+ update(state) {
346
+ const signature = state.order.join('|')
347
+ if (signature !== orderSignature) {
348
+ orderSignature = signature
349
+ applyOrder(state.order)
350
+ }
351
+
352
+ for (const [key, cell] of cells) {
353
+ const columnSort = state.sorting.find(sort => sort.id === key)
354
+ const direction = sortDirection(columnSort)
355
+ if (direction === 'neutral') {
356
+ cell.removeAttribute('aria-sort')
357
+ } else {
358
+ cell.setAttribute('aria-sort', direction)
359
+ }
360
+
361
+ const sortIcon = sortIcons.get(key)
362
+ if (sortIcon && sortIcon.dataset.coreuiSort !== direction) {
363
+ sortIcon.dataset.coreuiSort = direction
364
+ sortIcon.innerHTML = iconForDirection(direction, context.icons)
365
+ }
366
+
367
+ if (state.sizes && state.sizes[key] !== undefined) {
368
+ cell.style.width = `${state.sizes[key]}px`
369
+ }
370
+
371
+ if (state.pinning) {
372
+ applyPinnedCell(cell, state.pinning.columns[key] ?? null)
373
+ }
374
+ }
375
+
376
+ // A column is draggable only when reordering is on and its pinned region
377
+ // holds another movable column to swap with - reordering never crosses a
378
+ // pinning boundary, so a column alone in its region has no valid target.
379
+ if (context.draggable) {
380
+ const regionOf = (key: string): string => state.pinning?.columns[key]?.side ?? 'center'
381
+ const movablePerRegion = new Map<string, number>()
382
+ for (const key of state.order) {
383
+ if (movableColumns.get(key)) {
384
+ const region = regionOf(key)
385
+ movablePerRegion.set(region, (movablePerRegion.get(region) ?? 0) + 1)
386
+ }
387
+ }
388
+
389
+ for (const [key, cell] of cells) {
390
+ const draggable = Boolean(movableColumns.get(key)) && (movablePerRegion.get(regionOf(key)) ?? 0) > 1
391
+ cell.draggable = draggable
392
+ if (draggable) {
393
+ cell.dataset.coreuiDraggable = ''
394
+ } else {
395
+ delete cell.dataset.coreuiDraggable
396
+ }
397
+ }
398
+ }
399
+
400
+ for (const [key, resizer] of resizers) {
401
+ resizer.classList.toggle(CLASS_NAME_RESIZER_ACTIVE, state.resizingColumn === key)
402
+ }
403
+
404
+ if (state.filteredColumns) {
405
+ for (const [key, cell] of cells) {
406
+ cell
407
+ .querySelector('[data-coreui-filter]')
408
+ ?.classList.toggle('data-grid-filter-button-filtered', state.filteredColumns.includes(key))
409
+ }
410
+ }
411
+
412
+ if (state.pinning) {
413
+ for (const [key, cell] of filterCells) {
414
+ applyPinnedCell(cell, state.pinning.columns[key] ?? null)
415
+ }
416
+
417
+ if (selectCell) {
418
+ applyPinnedCell(selectCell, state.pinning.select)
419
+ }
420
+
421
+ if (filterSelectCell) {
422
+ applyPinnedCell(filterSelectCell, state.pinning.select)
423
+ }
424
+ }
425
+
426
+ if (selectAllInput && state.selectAll) {
427
+ selectAllInput.checked = state.selectAll.checked
428
+ selectAllInput.indeterminate = state.selectAll.indeterminate
429
+ }
430
+
431
+ for (const slot of filterSlots) {
432
+ slot.update?.()
433
+ }
434
+ },
435
+ dispose() {
436
+ for (const slot of filterSlots) {
437
+ slot.dispose?.()
438
+ }
439
+ }
440
+ }
441
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/loading.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { DataGridLabels } from '../types'
9
+
10
+ const CLASS_NAME_LOADING = 'data-grid-loading'
11
+
12
+ export interface GridLoading {
13
+ element: HTMLDivElement
14
+ setVisible: (visible: boolean) => void
15
+ }
16
+
17
+ export const createLoading = (labels: DataGridLabels): GridLoading => {
18
+ const element = document.createElement('div')
19
+ element.classList.add(CLASS_NAME_LOADING)
20
+ element.hidden = true
21
+
22
+ const spinner = document.createElement('div')
23
+ spinner.classList.add('spinner-border')
24
+ spinner.setAttribute('role', 'status')
25
+
26
+ const label = document.createElement('span')
27
+ label.classList.add('visually-hidden')
28
+ label.textContent = labels.loading
29
+ spinner.append(label)
30
+
31
+ element.append(spinner)
32
+
33
+ return {
34
+ element,
35
+ setVisible(visible) {
36
+ element.hidden = !visible
37
+ }
38
+ }
39
+ }
package/src/ui/menu.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/menu.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { DataGridMenuAction } from '../types'
9
+
10
+ const CLASS_NAME_MENU = 'data-grid-menu'
11
+ const CLASS_NAME_MENU_ITEM = 'data-grid-menu-item'
12
+ const CLASS_NAME_MENU_ITEM_ICON = 'data-grid-menu-item-icon'
13
+ const CLASS_NAME_MENU_DIVIDER = 'data-grid-menu-divider'
14
+
15
+ export interface GridMenuContext {
16
+ root: HTMLElement
17
+ getActions: (columnKey: string) => DataGridMenuAction[]
18
+ }
19
+
20
+ export interface GridMenu {
21
+ toggle: (button: HTMLElement, columnKey: string) => void
22
+ close: () => void
23
+ dispose: () => void
24
+ }
25
+
26
+ let menuCount = 0
27
+
28
+ export const createMenu = (context: GridMenuContext): GridMenu => {
29
+ const element = document.createElement('div')
30
+ element.classList.add(CLASS_NAME_MENU)
31
+ element.setAttribute('role', 'menu')
32
+ element.id = `data-grid-menu-${++menuCount}`
33
+
34
+ let trigger: HTMLElement | null = null
35
+
36
+ const items = (): HTMLButtonElement[] =>
37
+ [...element.querySelectorAll<HTMLButtonElement>(`.${CLASS_NAME_MENU_ITEM}`)]
38
+
39
+ const close = (refocus = false) => {
40
+ if (!trigger) {
41
+ return
42
+ }
43
+
44
+ trigger.setAttribute('aria-expanded', 'false')
45
+ trigger.removeAttribute('aria-controls')
46
+ if (refocus) {
47
+ trigger.focus()
48
+ }
49
+
50
+ trigger = null
51
+ element.remove()
52
+ document.removeEventListener('pointerdown', onOutsidePointerDown, true)
53
+ window.removeEventListener('scroll', onWindowChange, true)
54
+ window.removeEventListener('resize', onWindowChange)
55
+ }
56
+
57
+ const onOutsidePointerDown = (event: Event) => {
58
+ const target = event.target as Node
59
+ if (!element.contains(target) && !trigger?.contains(target)) {
60
+ close()
61
+ }
62
+ }
63
+
64
+ // Any scroll (viewport or page) or resize invalidates the anchored position;
65
+ // closing matches the native-select/AG Grid behavior.
66
+ const onWindowChange = (event?: Event) => {
67
+ if (event?.target instanceof Node && element.contains(event.target)) {
68
+ return
69
+ }
70
+
71
+ close()
72
+ }
73
+
74
+ const onMenuKeydown = (event: KeyboardEvent) => {
75
+ const enabled = items()
76
+ const activeIndex = enabled.indexOf(document.activeElement as HTMLButtonElement)
77
+
78
+ switch (event.key) {
79
+ case 'Escape': {
80
+ event.stopPropagation()
81
+ close(true)
82
+ break
83
+ }
84
+
85
+ case 'Tab': {
86
+ close()
87
+ break
88
+ }
89
+
90
+ case 'ArrowDown': {
91
+ event.preventDefault()
92
+ enabled[(activeIndex + 1) % enabled.length]?.focus()
93
+ break
94
+ }
95
+
96
+ case 'ArrowUp': {
97
+ event.preventDefault()
98
+ enabled[(activeIndex - 1 + enabled.length) % enabled.length]?.focus()
99
+ break
100
+ }
101
+
102
+ case 'Home': {
103
+ event.preventDefault()
104
+ enabled[0]?.focus()
105
+ break
106
+ }
107
+
108
+ case 'End': {
109
+ event.preventDefault()
110
+ enabled[enabled.length - 1]?.focus()
111
+ break
112
+ }
113
+
114
+ default:
115
+ }
116
+ }
117
+
118
+ element.addEventListener('keydown', onMenuKeydown)
119
+
120
+ const open = (button: HTMLElement, columnKey: string) => {
121
+ const actions = context.getActions(columnKey)
122
+ if (actions.length === 0) {
123
+ return
124
+ }
125
+
126
+ const nodes: Node[] = []
127
+ for (const [index, action] of actions.entries()) {
128
+ if (index > 0 && action.group !== actions[index - 1].group) {
129
+ const divider = document.createElement('div')
130
+ divider.classList.add(CLASS_NAME_MENU_DIVIDER)
131
+ divider.setAttribute('role', 'separator')
132
+ nodes.push(divider)
133
+ }
134
+
135
+ const item = document.createElement('button')
136
+ item.type = 'button'
137
+ item.classList.add(CLASS_NAME_MENU_ITEM)
138
+ item.setAttribute('role', 'menuitem')
139
+ if (action.icon) {
140
+ const icon = document.createElement('span')
141
+ icon.classList.add(CLASS_NAME_MENU_ITEM_ICON)
142
+ icon.setAttribute('aria-hidden', 'true')
143
+ icon.innerHTML = action.icon
144
+ item.append(icon)
145
+ }
146
+
147
+ item.append(action.label)
148
+ if (action.disabled) {
149
+ item.setAttribute('aria-disabled', 'true')
150
+ }
151
+
152
+ item.addEventListener('click', () => {
153
+ if (action.disabled) {
154
+ return
155
+ }
156
+
157
+ const previousTrigger = trigger
158
+ close()
159
+ action.run()
160
+ // The action may remove the trigger's header cell (hide column); the
161
+ // check has to run after the scheduled re-render.
162
+ requestAnimationFrame(() => {
163
+ // An action may have moved focus deliberately (the filter dialog);
164
+ // only restore it when it fell out of the grid.
165
+ const active = document.activeElement
166
+ if (active && active !== document.body && context.root.contains(active)) {
167
+ return
168
+ }
169
+
170
+ if (previousTrigger?.isConnected) {
171
+ previousTrigger.focus()
172
+ } else {
173
+ context.root.querySelector<HTMLElement>('[data-coreui-menu]')?.focus()
174
+ }
175
+ })
176
+ })
177
+ nodes.push(item)
178
+ }
179
+
180
+ element.replaceChildren(...nodes)
181
+
182
+ trigger = button
183
+ button.setAttribute('aria-expanded', 'true')
184
+ button.setAttribute('aria-controls', element.id)
185
+ context.root.append(element)
186
+
187
+ const rootRect = context.root.getBoundingClientRect()
188
+ const buttonRect = button.getBoundingClientRect()
189
+ element.style.top = `${buttonRect.bottom - rootRect.top}px`
190
+ // Right-align the menu to the trigger so it opens leftward — the menu button
191
+ // sits at the cell's right edge, where a left-aligned menu would overflow.
192
+ element.style.left = `${Math.max(buttonRect.right - rootRect.left - element.offsetWidth, 0)}px`
193
+
194
+ document.addEventListener('pointerdown', onOutsidePointerDown, true)
195
+ window.addEventListener('scroll', onWindowChange, true)
196
+ window.addEventListener('resize', onWindowChange)
197
+ items()[0]?.focus()
198
+ }
199
+
200
+ return {
201
+ toggle(button, columnKey) {
202
+ if (trigger === button) {
203
+ close(true)
204
+ return
205
+ }
206
+
207
+ close()
208
+ open(button, columnKey)
209
+ },
210
+ close() {
211
+ close()
212
+ },
213
+ dispose() {
214
+ close()
215
+ element.removeEventListener('keydown', onMenuKeydown)
216
+ }
217
+ }
218
+ }