@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,59 @@
1
+ // Vendored from https://github.com/coreui/coreui-pro/blob/bc4daf1cd79d527bf665d615c0a31f53ab0c3d4e/js/src/dom/data.js — sync manually
2
+ /**
3
+ * --------------------------------------------------------------------------
4
+ * CoreUI dom/data.js
5
+ * Licensed under MIT (https://github.com/coreui/coreui/blob/main/LICENSE)
6
+ *
7
+ * This is a modified version of the Bootstrap's dom/data.js
8
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
9
+ * --------------------------------------------------------------------------
10
+ */
11
+
12
+ /**
13
+ * Constants
14
+ */
15
+
16
+ const elementMap = new Map()
17
+
18
+ export default {
19
+ set(element, key, instance) {
20
+ if (!elementMap.has(element)) {
21
+ elementMap.set(element, new Map())
22
+ }
23
+
24
+ const instanceMap = elementMap.get(element)
25
+
26
+ // make it clear we only want one instance per element
27
+ // can be removed later when multiple key/instances are fine to be used
28
+ if (!instanceMap.has(key) && instanceMap.size !== 0) {
29
+ // eslint-disable-next-line no-console
30
+ console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`)
31
+ return
32
+ }
33
+
34
+ instanceMap.set(key, instance)
35
+ },
36
+
37
+ get(element, key) {
38
+ if (elementMap.has(element)) {
39
+ return elementMap.get(element).get(key) || null
40
+ }
41
+
42
+ return null
43
+ },
44
+
45
+ remove(element, key) {
46
+ if (!elementMap.has(element)) {
47
+ return
48
+ }
49
+
50
+ const instanceMap = elementMap.get(element)
51
+
52
+ instanceMap.delete(key)
53
+
54
+ // free up element references if there are no instances left for an element
55
+ if (instanceMap.size === 0) {
56
+ elementMap.delete(element)
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,11 @@
1
+ // Hand-written types for the vendored event-handler.js (see the provenance header there)
2
+ type EventHandlerFunction = (event: Event & Record<string, unknown>) => void
3
+
4
+ declare const EventHandler: {
5
+ on(element: Element | Document | Window, event: string, handler: string | EventHandlerFunction, delegationFunction?: EventHandlerFunction): void
6
+ one(element: Element | Document | Window, event: string, handler: string | EventHandlerFunction, delegationFunction?: EventHandlerFunction): void
7
+ off(element: Element | Document | Window, originalTypeEvent: string, handler?: string | EventHandlerFunction, delegationFunction?: EventHandlerFunction): void
8
+ trigger(element: Element | Document | Window, event: string, args?: Record<string, unknown>): (Event & Record<string, unknown>) | null
9
+ }
10
+
11
+ export default EventHandler
@@ -0,0 +1,331 @@
1
+ // Vendored from https://github.com/coreui/coreui-pro/blob/bc4daf1cd79d527bf665d615c0a31f53ab0c3d4e/js/src/dom/event-handler.js — sync manually
2
+ /**
3
+ * --------------------------------------------------------------------------
4
+ * CoreUI dom/event-handler.js
5
+ * Licensed under MIT (https://github.com/coreui/coreui/blob/main/LICENSE)
6
+ *
7
+ * This is a modified version of the Bootstrap's dom/event-handler.js
8
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
9
+ * --------------------------------------------------------------------------
10
+ */
11
+
12
+ import { getjQuery } from '../util/index.js'
13
+
14
+ /**
15
+ * Constants
16
+ */
17
+
18
+ const namespaceRegex = /[^.]*(?=\..*)\.|.*/
19
+ const stripNameRegex = /\..*/
20
+ const stripUidRegex = /::\d+$/
21
+ const eventRegistry = {} // Events storage
22
+ let uidEvent = 1
23
+ const customEvents = {
24
+ mouseenter: 'mouseover',
25
+ mouseleave: 'mouseout'
26
+ }
27
+
28
+ const nativeEvents = new Set([
29
+ 'click',
30
+ 'dblclick',
31
+ 'mouseup',
32
+ 'mousedown',
33
+ 'contextmenu',
34
+ 'mousewheel',
35
+ 'DOMMouseScroll',
36
+ 'mouseover',
37
+ 'mouseout',
38
+ 'mousemove',
39
+ 'selectstart',
40
+ 'selectend',
41
+ 'keydown',
42
+ 'keypress',
43
+ 'keyup',
44
+ 'orientationchange',
45
+ 'touchstart',
46
+ 'touchmove',
47
+ 'touchend',
48
+ 'touchcancel',
49
+ 'pointerdown',
50
+ 'pointermove',
51
+ 'pointerup',
52
+ 'pointerleave',
53
+ 'pointercancel',
54
+ // Local addition over the coreui-pro source: HTML5 drag & drop events used
55
+ // by the data grid's column reordering.
56
+ 'drag',
57
+ 'dragstart',
58
+ 'dragend',
59
+ 'dragenter',
60
+ 'dragleave',
61
+ 'dragover',
62
+ 'drop',
63
+ 'gesturestart',
64
+ 'gesturechange',
65
+ 'gestureend',
66
+ 'focus',
67
+ 'blur',
68
+ 'change',
69
+ 'input',
70
+ 'reset',
71
+ 'select',
72
+ 'submit',
73
+ 'focusin',
74
+ 'focusout',
75
+ 'load',
76
+ 'unload',
77
+ 'beforeunload',
78
+ 'resize',
79
+ 'move',
80
+ 'DOMContentLoaded',
81
+ 'readystatechange',
82
+ 'error',
83
+ 'abort',
84
+ 'scroll'
85
+ ])
86
+
87
+ /**
88
+ * Private methods
89
+ */
90
+
91
+ function makeEventUid(element, uid) {
92
+ return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++
93
+ }
94
+
95
+ function getElementEvents(element) {
96
+ const uid = makeEventUid(element)
97
+
98
+ element.uidEvent = uid
99
+ eventRegistry[uid] = eventRegistry[uid] || {}
100
+
101
+ return eventRegistry[uid]
102
+ }
103
+
104
+ function bootstrapHandler(element, fn) {
105
+ return function handler(event) {
106
+ hydrateObj(event, { delegateTarget: element })
107
+
108
+ if (handler.oneOff) {
109
+ EventHandler.off(element, event.type, fn)
110
+ }
111
+
112
+ return fn.apply(element, [event])
113
+ }
114
+ }
115
+
116
+ function bootstrapDelegationHandler(element, selector, fn) {
117
+ return function handler(event) {
118
+ const domElements = element.querySelectorAll(selector)
119
+
120
+ for (let { target } = event; target && target !== this; target = target.parentNode) {
121
+ for (const domElement of domElements) {
122
+ if (domElement !== target) {
123
+ continue
124
+ }
125
+
126
+ hydrateObj(event, { delegateTarget: target })
127
+
128
+ if (handler.oneOff) {
129
+ EventHandler.off(element, event.type, selector, fn)
130
+ }
131
+
132
+ return fn.apply(target, [event])
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ function findHandler(events, callable, delegationSelector = null) {
139
+ return Object.values(events)
140
+ .find(event => event.callable === callable && event.delegationSelector === delegationSelector)
141
+ }
142
+
143
+ function normalizeParameters(originalTypeEvent, handler, delegationFunction) {
144
+ const isDelegated = typeof handler === 'string'
145
+ // TODO: tooltip passes `false` instead of selector, so we need to check
146
+ const callable = isDelegated ? delegationFunction : (handler || delegationFunction)
147
+ let typeEvent = getTypeEvent(originalTypeEvent)
148
+
149
+ if (!nativeEvents.has(typeEvent)) {
150
+ typeEvent = originalTypeEvent
151
+ }
152
+
153
+ return [isDelegated, callable, typeEvent]
154
+ }
155
+
156
+ function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) {
157
+ if (typeof originalTypeEvent !== 'string' || !element) {
158
+ return
159
+ }
160
+
161
+ let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
162
+
163
+ // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position
164
+ // this prevents the handler from being dispatched the same way as mouseover or mouseout does
165
+ if (originalTypeEvent in customEvents) {
166
+ const wrapFunction = fn => {
167
+ return function (event) {
168
+ if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) {
169
+ return fn.call(this, event)
170
+ }
171
+ }
172
+ }
173
+
174
+ callable = wrapFunction(callable)
175
+ }
176
+
177
+ const events = getElementEvents(element)
178
+ const handlers = events[typeEvent] || (events[typeEvent] = {})
179
+ const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null)
180
+
181
+ if (previousFunction) {
182
+ previousFunction.oneOff = previousFunction.oneOff && oneOff
183
+
184
+ return
185
+ }
186
+
187
+ const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''))
188
+ const fn = isDelegated ?
189
+ bootstrapDelegationHandler(element, handler, callable) :
190
+ bootstrapHandler(element, callable)
191
+
192
+ fn.delegationSelector = isDelegated ? handler : null
193
+ fn.callable = callable
194
+ fn.oneOff = oneOff
195
+ fn.uidEvent = uid
196
+ handlers[uid] = fn
197
+
198
+ element.addEventListener(typeEvent, fn, isDelegated)
199
+ }
200
+
201
+ function removeHandler(element, events, typeEvent, handler, delegationSelector) {
202
+ const fn = findHandler(events[typeEvent], handler, delegationSelector)
203
+
204
+ if (!fn) {
205
+ return
206
+ }
207
+
208
+ element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))
209
+ delete events[typeEvent][fn.uidEvent]
210
+ }
211
+
212
+ function removeNamespacedHandlers(element, events, typeEvent, namespace) {
213
+ const storeElementEvent = events[typeEvent] || {}
214
+
215
+ for (const [handlerKey, event] of Object.entries(storeElementEvent)) {
216
+ if (handlerKey.includes(namespace)) {
217
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
218
+ }
219
+ }
220
+ }
221
+
222
+ function getTypeEvent(event) {
223
+ // allow to get the native events from namespaced events ('click.coreui.button' --> 'click')
224
+ event = event.replace(stripNameRegex, '')
225
+ return customEvents[event] || event
226
+ }
227
+
228
+ const EventHandler = {
229
+ on(element, event, handler, delegationFunction) {
230
+ addHandler(element, event, handler, delegationFunction, false)
231
+ },
232
+
233
+ one(element, event, handler, delegationFunction) {
234
+ addHandler(element, event, handler, delegationFunction, true)
235
+ },
236
+
237
+ off(element, originalTypeEvent, handler, delegationFunction) {
238
+ if (typeof originalTypeEvent !== 'string' || !element) {
239
+ return
240
+ }
241
+
242
+ const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
243
+ const inNamespace = typeEvent !== originalTypeEvent
244
+ const events = getElementEvents(element)
245
+ const storeElementEvent = events[typeEvent] || {}
246
+ const isNamespace = originalTypeEvent.startsWith('.')
247
+
248
+ if (typeof callable !== 'undefined') {
249
+ // Simplest case: handler is passed, remove that listener ONLY.
250
+ if (!Object.keys(storeElementEvent).length) {
251
+ return
252
+ }
253
+
254
+ removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null)
255
+ return
256
+ }
257
+
258
+ if (isNamespace) {
259
+ for (const elementEvent of Object.keys(events)) {
260
+ removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1))
261
+ }
262
+ }
263
+
264
+ for (const [keyHandlers, event] of Object.entries(storeElementEvent)) {
265
+ const handlerKey = keyHandlers.replace(stripUidRegex, '')
266
+
267
+ if (!inNamespace || originalTypeEvent.includes(handlerKey)) {
268
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
269
+ }
270
+ }
271
+ },
272
+
273
+ trigger(element, event, args) {
274
+ if (typeof event !== 'string' || !element) {
275
+ return null
276
+ }
277
+
278
+ const $ = getjQuery()
279
+ const typeEvent = getTypeEvent(event)
280
+ const inNamespace = event !== typeEvent
281
+
282
+ let jQueryEvent = null
283
+ let bubbles = true
284
+ let nativeDispatch = true
285
+ let defaultPrevented = false
286
+
287
+ if (inNamespace && $) {
288
+ jQueryEvent = $.Event(event, args)
289
+
290
+ $(element).trigger(jQueryEvent)
291
+ bubbles = !jQueryEvent.isPropagationStopped()
292
+ nativeDispatch = !jQueryEvent.isImmediatePropagationStopped()
293
+ defaultPrevented = jQueryEvent.isDefaultPrevented()
294
+ }
295
+
296
+ const evt = hydrateObj(new Event(event, { bubbles, cancelable: true }), args)
297
+
298
+ if (defaultPrevented) {
299
+ evt.preventDefault()
300
+ }
301
+
302
+ if (nativeDispatch) {
303
+ element.dispatchEvent(evt)
304
+ }
305
+
306
+ if (evt.defaultPrevented && jQueryEvent) {
307
+ jQueryEvent.preventDefault()
308
+ }
309
+
310
+ return evt
311
+ }
312
+ }
313
+
314
+ function hydrateObj(obj, meta = {}) {
315
+ for (const [key, value] of Object.entries(meta)) {
316
+ try {
317
+ obj[key] = value
318
+ } catch {
319
+ Object.defineProperty(obj, key, {
320
+ configurable: true,
321
+ get() {
322
+ return value
323
+ }
324
+ })
325
+ }
326
+ }
327
+
328
+ return obj
329
+ }
330
+
331
+ export default EventHandler
@@ -0,0 +1,9 @@
1
+ // Hand-written types for the vendored manipulator.js (see the provenance header there)
2
+ declare const Manipulator: {
3
+ setDataAttribute(element: Element, key: string, value: unknown): void
4
+ removeDataAttribute(element: Element, key: string): void
5
+ getDataAttributes(element: Element): Record<string, unknown>
6
+ getDataAttribute(element: Element, key: string): unknown
7
+ }
8
+
9
+ export default Manipulator
@@ -0,0 +1,75 @@
1
+ // Vendored from https://github.com/coreui/coreui-pro/blob/bc4daf1cd79d527bf665d615c0a31f53ab0c3d4e/js/src/dom/manipulator.js — sync manually
2
+ /**
3
+ * --------------------------------------------------------------------------
4
+ * CoreUI dom/manipulator.js
5
+ * Licensed under MIT (https://github.com/coreui/coreui/blob/main/LICENSE)
6
+ *
7
+ * This is a modified version of the Bootstrap's dom/manipulator.js
8
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
9
+ * --------------------------------------------------------------------------
10
+ */
11
+
12
+ function normalizeData(value) {
13
+ if (value === 'true') {
14
+ return true
15
+ }
16
+
17
+ if (value === 'false') {
18
+ return false
19
+ }
20
+
21
+ if (value === Number(value).toString()) {
22
+ return Number(value)
23
+ }
24
+
25
+ if (value === '' || value === 'null') {
26
+ return null
27
+ }
28
+
29
+ if (typeof value !== 'string') {
30
+ return value
31
+ }
32
+
33
+ try {
34
+ return JSON.parse(decodeURIComponent(value))
35
+ } catch {
36
+ return value
37
+ }
38
+ }
39
+
40
+ function normalizeDataKey(key) {
41
+ return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)
42
+ }
43
+
44
+ const Manipulator = {
45
+ setDataAttribute(element, key, value) {
46
+ element.setAttribute(`data-coreui-${normalizeDataKey(key)}`, value)
47
+ },
48
+
49
+ removeDataAttribute(element, key) {
50
+ element.removeAttribute(`data-coreui-${normalizeDataKey(key)}`)
51
+ },
52
+
53
+ getDataAttributes(element) {
54
+ if (!element) {
55
+ return {}
56
+ }
57
+
58
+ const attributes = {}
59
+ const coreuiKeys = Object.keys(element.dataset).filter(key => key.startsWith('coreui') && !key.startsWith('coreuiConfig'))
60
+
61
+ for (const key of coreuiKeys) {
62
+ let pureKey = key.replace(/^coreui/, '')
63
+ pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1)
64
+ attributes[pureKey] = normalizeData(element.dataset[key])
65
+ }
66
+
67
+ return attributes
68
+ },
69
+
70
+ getDataAttribute(element, key) {
71
+ return normalizeData(element.getAttribute(`data-coreui-${normalizeDataKey(key)}`))
72
+ }
73
+ }
74
+
75
+ export default Manipulator
@@ -0,0 +1,195 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid engine/active-cell.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ // The cellNavigation substrate: one grid-owned active cell (roving tabindex).
9
+ // rowIndex is an index into the current row model (page-local under
10
+ // pagination); -1 addresses the header label row. Focus is derived from this
11
+ // state, never from the DOM - the body applies it after every render pass.
12
+
13
+ export interface ActiveCell {
14
+ rowIndex: number
15
+ columnId: string
16
+ }
17
+
18
+ export interface ActiveCellContext {
19
+ // Visible leaf column ids in visual order (pinning + order + visibility).
20
+ columnIds: string[]
21
+ rowCount: number
22
+ // Rows per PageUp/PageDown step: visible viewport rows (virtualized) or the
23
+ // page size (paginated).
24
+ pageStep: number
25
+ paginated: boolean
26
+ }
27
+
28
+ export interface NavigationResult {
29
+ cell: ActiveCell
30
+ pageDelta?: -1 | 1
31
+ }
32
+
33
+ const HEADER_ROW_INDEX = -1
34
+
35
+ const clampColumn = (columnId: string, columnIds: string[]): string | null => {
36
+ if (columnIds.includes(columnId)) {
37
+ return columnId
38
+ }
39
+
40
+ return columnIds.length > 0 ? columnIds[0] : null
41
+ }
42
+
43
+ // Nulls the cell when the grid has no addressable cells; otherwise snaps it
44
+ // back into the current column set and row range.
45
+ export const clampActiveCell = (cell: ActiveCell | null, context: ActiveCellContext): ActiveCell | null => {
46
+ if (!cell) {
47
+ return null
48
+ }
49
+
50
+ const columnId = clampColumn(cell.columnId, context.columnIds)
51
+ if (columnId === null) {
52
+ return null
53
+ }
54
+
55
+ const maxRow = context.rowCount - 1
56
+ const rowIndex = cell.rowIndex < 0 ?
57
+ HEADER_ROW_INDEX :
58
+ Math.max(0, Math.min(cell.rowIndex, Math.max(maxRow, 0)))
59
+
60
+ if (rowIndex >= 0 && maxRow < 0) {
61
+ return { rowIndex: HEADER_ROW_INDEX, columnId }
62
+ }
63
+
64
+ return { rowIndex, columnId }
65
+ }
66
+
67
+ // The APG key decision table - a pure function so every edition asserts
68
+ // identical outcomes. Returns null for keys the grid does not handle.
69
+ export const resolveNavigation = (
70
+ key: string,
71
+ ctrlKey: boolean,
72
+ current: ActiveCell,
73
+ context: ActiveCellContext
74
+ ): NavigationResult | null => {
75
+ const { columnIds, rowCount, pageStep, paginated } = context
76
+ const columnIndex = columnIds.indexOf(current.columnId)
77
+ if (columnIndex === -1 || columnIds.length === 0) {
78
+ return null
79
+ }
80
+
81
+ const lastRow = rowCount - 1
82
+ const lastColumn = columnIds.length - 1
83
+ const cell = (rowIndex: number, index: number): NavigationResult => ({
84
+ cell: { rowIndex, columnId: columnIds[index] }
85
+ })
86
+
87
+ switch (key) {
88
+ case 'ArrowLeft': {
89
+ return cell(current.rowIndex, Math.max(columnIndex - 1, 0))
90
+ }
91
+
92
+ case 'ArrowRight': {
93
+ return cell(current.rowIndex, Math.min(columnIndex + 1, lastColumn))
94
+ }
95
+
96
+ case 'ArrowUp': {
97
+ return cell(Math.max(current.rowIndex - 1, HEADER_ROW_INDEX), columnIndex)
98
+ }
99
+
100
+ case 'ArrowDown': {
101
+ return cell(Math.min(current.rowIndex + 1, lastRow), columnIndex)
102
+ }
103
+
104
+ case 'Home': {
105
+ if (ctrlKey) {
106
+ return cell(rowCount > 0 ? 0 : HEADER_ROW_INDEX, 0)
107
+ }
108
+
109
+ return cell(current.rowIndex, 0)
110
+ }
111
+
112
+ case 'End': {
113
+ if (ctrlKey) {
114
+ return cell(rowCount > 0 ? lastRow : HEADER_ROW_INDEX, lastColumn)
115
+ }
116
+
117
+ return cell(current.rowIndex, lastColumn)
118
+ }
119
+
120
+ case 'PageUp': {
121
+ if (paginated && current.rowIndex === 0) {
122
+ return { ...cell(0, columnIndex), pageDelta: -1 }
123
+ }
124
+
125
+ return cell(Math.max(current.rowIndex - pageStep, 0), columnIndex)
126
+ }
127
+
128
+ case 'PageDown': {
129
+ if (paginated && current.rowIndex === lastRow) {
130
+ return { ...cell(lastRow, columnIndex), pageDelta: 1 }
131
+ }
132
+
133
+ return cell(Math.min(current.rowIndex + pageStep, lastRow), columnIndex)
134
+ }
135
+
136
+ default: {
137
+ return null
138
+ }
139
+ }
140
+ }
141
+
142
+ export interface ActiveCellManager {
143
+ get: () => ActiveCell | null
144
+ // Stores the cell; focus: true marks a pending focus request the next
145
+ // render pass consumes (set false for pointer-driven activation, where the
146
+ // browser already moved focus).
147
+ set: (cell: ActiveCell, options?: { focus?: boolean }) => void
148
+ clamp: (context: ActiveCellContext) => void
149
+ reset: () => void
150
+ consumeFocusRequest: () => boolean
151
+ }
152
+
153
+ export const createActiveCellManager = (onChange: () => void): ActiveCellManager => {
154
+ let current: ActiveCell | null = null
155
+ let focusRequested = false
156
+
157
+ return {
158
+ get: () => current,
159
+ set(cell, options = {}) {
160
+ const changed = !current || current.rowIndex !== cell.rowIndex || current.columnId !== cell.columnId
161
+ current = cell
162
+ if (options.focus) {
163
+ focusRequested = true
164
+ }
165
+
166
+ if (changed || options.focus) {
167
+ onChange()
168
+ }
169
+ },
170
+ clamp(context) {
171
+ const next = clampActiveCell(current, context)
172
+ const changed = Boolean(current) !== Boolean(next) ||
173
+ (current && next && (current.rowIndex !== next.rowIndex || current.columnId !== next.columnId))
174
+ current = next
175
+ if (changed) {
176
+ onChange()
177
+ }
178
+ },
179
+ reset() {
180
+ if (current) {
181
+ current = null
182
+ focusRequested = false
183
+ onChange()
184
+ }
185
+ },
186
+ consumeFocusRequest() {
187
+ if (!focusRequested) {
188
+ return false
189
+ }
190
+
191
+ focusRequested = false
192
+ return true
193
+ }
194
+ }
195
+ }