@stonecrop/utilities 0.2.5

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.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@stonecrop/utilities",
3
+ "version": "0.2.5",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/utilities.js",
9
+ "require": "./dist/utilities.umd.cjs"
10
+ }
11
+ },
12
+ "main": "dist/utilities.js",
13
+ "module": "dist/utilities.js",
14
+ "types": "src/index",
15
+ "files": [
16
+ "dist/*",
17
+ "src/**/*.ts"
18
+ ],
19
+ "dependencies": {
20
+ "vue": "^3.2.47"
21
+ },
22
+ "devDependencies": {
23
+ "@histoire/plugin-vue": "^0.16.1",
24
+ "@typescript-eslint/eslint-plugin": "^5.59.5",
25
+ "@typescript-eslint/parser": "^5.59.5",
26
+ "@vitejs/plugin-vue": "^4.2.1",
27
+ "@vueuse/core": "^9.13.0",
28
+ "cypress": "^12.11.0",
29
+ "eslint": "^8.40.0",
30
+ "eslint-config-prettier": "^8.8.0",
31
+ "eslint-plugin-vue": "^9.11.1",
32
+ "histoire": "^0.16.1",
33
+ "typescript": "^5.0.4",
34
+ "vite": "^4.3.5",
35
+ "vue-router": "^4"
36
+ },
37
+ "engines": {
38
+ "node": ">=20.11.0"
39
+ },
40
+ "umd": "dist/utilities.umd.cjs",
41
+ "scripts": {
42
+ "build": "tsc -b && vite build",
43
+ "dev": "vite",
44
+ "lint": "eslint . --ext .ts,.vue",
45
+ "preview": "vite preview",
46
+ "story:build": "histoire build",
47
+ "story:dev": "histoire dev",
48
+ "story:preview": "histoire preview"
49
+ }
50
+ }
@@ -0,0 +1,440 @@
1
+ import { onMounted, onBeforeUnmount } from 'vue'
2
+ import { useElementVisibility } from '@vueuse/core'
3
+
4
+ import type { KeyboardNavigationOptions, KeypressHandlers } from 'types'
5
+
6
+ // helper functions
7
+ const isVisible = (element: HTMLElement) => {
8
+ let isVisible = useElementVisibility(element).value
9
+ isVisible = isVisible && element.offsetHeight > 0
10
+ return isVisible
11
+ }
12
+
13
+ const isFocusable = (element: HTMLElement) => {
14
+ return element.tabIndex >= 0
15
+ }
16
+
17
+ // navigation functions
18
+ const getUpCell = (event: KeyboardEvent) => {
19
+ const $target = event.target as HTMLElement
20
+ return _getUpCell($target)
21
+ }
22
+
23
+ const _getUpCell = (element: HTMLElement): HTMLElement | undefined => {
24
+ let $upCell: HTMLElement | undefined
25
+ if (element instanceof HTMLTableCellElement) {
26
+ const $prevRow = element.parentElement?.previousElementSibling as HTMLTableRowElement
27
+ if ($prevRow) {
28
+ const $prevRowCells = Array.from($prevRow.children)
29
+ const $prevCell = $prevRowCells[element.cellIndex] as HTMLElement
30
+ if ($prevCell) {
31
+ $upCell = $prevCell
32
+ }
33
+ }
34
+ } else if (element instanceof HTMLTableRowElement) {
35
+ const $prevRow = element.previousElementSibling as HTMLTableRowElement
36
+ if ($prevRow) {
37
+ $upCell = $prevRow
38
+ }
39
+ } else {
40
+ // handle other contexts
41
+ }
42
+ if ($upCell && (!isFocusable($upCell) || !isVisible($upCell))) {
43
+ return _getUpCell($upCell)
44
+ }
45
+ return $upCell
46
+ }
47
+
48
+ const getTopCell = (event: KeyboardEvent) => {
49
+ const $target = event.target as HTMLElement
50
+ let $topCell: HTMLElement | undefined
51
+ if ($target instanceof HTMLTableCellElement) {
52
+ const $table = $target.parentElement?.parentElement
53
+ if ($table) {
54
+ const $firstRow = $table.firstElementChild
55
+ const $navCell = $firstRow.children[$target.cellIndex] as HTMLElement
56
+ if ($navCell) {
57
+ $topCell = $navCell
58
+ }
59
+ }
60
+ } else if ($target instanceof HTMLTableRowElement) {
61
+ const $table = $target.parentElement as HTMLTableElement
62
+ if ($table) {
63
+ const $firstRow = $table.firstElementChild as HTMLTableRowElement
64
+ if ($firstRow) {
65
+ $topCell = $firstRow
66
+ }
67
+ }
68
+ } else {
69
+ // handle other contexts
70
+ }
71
+ if ($topCell && (!isFocusable($topCell) || !isVisible($topCell))) {
72
+ return _getDownCell($topCell)
73
+ }
74
+ return $topCell
75
+ }
76
+
77
+ const getDownCell = (event: KeyboardEvent) => {
78
+ const $target = event.target as HTMLElement
79
+ return _getDownCell($target)
80
+ }
81
+
82
+ const _getDownCell = (element: HTMLElement): HTMLElement | undefined => {
83
+ let $downCell: HTMLElement | undefined
84
+ if (element instanceof HTMLTableCellElement) {
85
+ const $nextRow = element.parentElement?.nextElementSibling
86
+ if ($nextRow) {
87
+ const $nextRowCells = Array.from($nextRow.children)
88
+ const $nextCell = $nextRowCells[element.cellIndex] as HTMLElement
89
+ if ($nextCell) {
90
+ $downCell = $nextCell
91
+ }
92
+ }
93
+ } else if (element instanceof HTMLTableRowElement) {
94
+ const $nextRow = element.nextElementSibling as HTMLTableRowElement
95
+ if ($nextRow) {
96
+ $downCell = $nextRow
97
+ }
98
+ } else {
99
+ // handle other contexts
100
+ }
101
+ if ($downCell && (!isFocusable($downCell) || !isVisible($downCell))) {
102
+ return _getDownCell($downCell)
103
+ }
104
+ return $downCell
105
+ }
106
+
107
+ const getBottomCell = (event: KeyboardEvent) => {
108
+ const $target = event.target as HTMLElement
109
+ let $bottomCell: HTMLElement | undefined
110
+ if ($target instanceof HTMLTableCellElement) {
111
+ const $table = $target.parentElement?.parentElement
112
+ if ($table) {
113
+ const $lastRow = $table.lastElementChild
114
+ const $navCell = $lastRow.children[$target.cellIndex] as HTMLElement
115
+ if ($navCell) {
116
+ $bottomCell = $navCell
117
+ }
118
+ }
119
+ } else if ($target instanceof HTMLTableRowElement) {
120
+ const $table = $target.parentElement as HTMLTableElement
121
+ if ($table) {
122
+ const $lastRow = $table.lastElementChild as HTMLTableRowElement
123
+ if ($lastRow) {
124
+ $bottomCell = $lastRow
125
+ }
126
+ }
127
+ } else {
128
+ // handle other contexts
129
+ }
130
+ if ($bottomCell && (!isFocusable($bottomCell) || !isVisible($bottomCell))) {
131
+ return _getUpCell($bottomCell)
132
+ }
133
+ return $bottomCell
134
+ }
135
+
136
+ const getPrevCell = (event: KeyboardEvent) => {
137
+ const $target = event.target as HTMLElement
138
+ return _getPrevCell($target)
139
+ }
140
+
141
+ const _getPrevCell = (element: HTMLElement): HTMLElement | undefined => {
142
+ let $prevCell: HTMLElement | undefined
143
+ if (element.previousElementSibling) {
144
+ $prevCell = element.previousElementSibling as HTMLElement
145
+ } else {
146
+ const $prevRow = element.parentElement?.previousElementSibling
147
+ $prevCell = $prevRow?.lastElementChild as HTMLElement
148
+ }
149
+ if ($prevCell && (!isFocusable($prevCell) || !isVisible($prevCell))) {
150
+ return _getPrevCell($prevCell)
151
+ }
152
+ return $prevCell
153
+ }
154
+
155
+ const getNextCell = (event: KeyboardEvent) => {
156
+ const $target = event.target as HTMLElement
157
+ return _getNextCell($target)
158
+ }
159
+
160
+ const _getNextCell = (element: HTMLElement): HTMLElement | undefined => {
161
+ let $nextCell: HTMLElement | undefined
162
+ if (element.nextElementSibling) {
163
+ $nextCell = element.nextElementSibling as HTMLElement
164
+ } else {
165
+ const $nextRow = element.parentElement?.nextElementSibling
166
+ $nextCell = $nextRow?.firstElementChild as HTMLElement
167
+ }
168
+ if ($nextCell && (!isFocusable($nextCell) || !isVisible($nextCell))) {
169
+ return _getNextCell($nextCell)
170
+ }
171
+ return $nextCell
172
+ }
173
+
174
+ const getFirstCell = (event: KeyboardEvent) => {
175
+ const $target = event.target as HTMLElement
176
+ const $parent = $target.parentElement
177
+ const $firstCell = $parent.firstElementChild as HTMLElement | null
178
+ if ($firstCell && (!isFocusable($firstCell) || !isVisible($firstCell))) {
179
+ return _getNextCell($firstCell)
180
+ }
181
+ return $firstCell
182
+ }
183
+
184
+ const getLastCell = (event: KeyboardEvent) => {
185
+ const $target = event.target as HTMLElement
186
+ const $parent = $target.parentElement
187
+ const $lastCell = $parent.lastElementChild as HTMLElement | null
188
+ if ($lastCell && (!isFocusable($lastCell) || !isVisible($lastCell))) {
189
+ return _getPrevCell($lastCell)
190
+ }
191
+ return $lastCell
192
+ }
193
+
194
+ const modifierKeys = ['alt', 'control', 'shift', 'meta']
195
+
196
+ const eventKeyMap = {
197
+ ArrowUp: 'up',
198
+ ArrowDown: 'down',
199
+ ArrowLeft: 'left',
200
+ ArrowRight: 'right',
201
+ }
202
+
203
+ export const defaultKeypressHandlers: KeypressHandlers = {
204
+ 'keydown.up': (event: KeyboardEvent) => {
205
+ const $upCell = getUpCell(event)
206
+ if ($upCell) {
207
+ event.preventDefault()
208
+ event.stopPropagation()
209
+ $upCell.focus()
210
+ }
211
+ },
212
+ 'keydown.down': (event: KeyboardEvent) => {
213
+ const $downCell = getDownCell(event)
214
+ if ($downCell) {
215
+ event.preventDefault()
216
+ event.stopPropagation()
217
+ $downCell.focus()
218
+ }
219
+ },
220
+ 'keydown.left': (event: KeyboardEvent) => {
221
+ const $prevCell = getPrevCell(event)
222
+ // prevent default edit-cell behaviour on first cell
223
+ event.preventDefault()
224
+ event.stopPropagation()
225
+ if ($prevCell) {
226
+ $prevCell.focus()
227
+ }
228
+ },
229
+ 'keydown.right': (event: KeyboardEvent) => {
230
+ const $nextCell = getNextCell(event)
231
+ // prevent default edit-cell behaviour on last cell
232
+ event.preventDefault()
233
+ event.stopPropagation()
234
+ if ($nextCell) {
235
+ $nextCell.focus()
236
+ }
237
+ },
238
+ 'keydown.control.up': (event: KeyboardEvent) => {
239
+ const $topCell = getTopCell(event)
240
+ if ($topCell) {
241
+ event.preventDefault()
242
+ event.stopPropagation()
243
+ $topCell.focus()
244
+ }
245
+ },
246
+ 'keydown.control.down': (event: KeyboardEvent) => {
247
+ const $bottomCell = getBottomCell(event)
248
+ if ($bottomCell) {
249
+ event.preventDefault()
250
+ event.stopPropagation()
251
+ $bottomCell.focus()
252
+ }
253
+ },
254
+ 'keydown.control.left': (event: KeyboardEvent) => {
255
+ const $firstCell = getFirstCell(event)
256
+ if ($firstCell) {
257
+ event.preventDefault()
258
+ event.stopPropagation()
259
+ $firstCell.focus()
260
+ }
261
+ },
262
+ 'keydown.control.right': (event: KeyboardEvent) => {
263
+ const $lastCell = getLastCell(event)
264
+ if ($lastCell) {
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ $lastCell.focus()
268
+ }
269
+ },
270
+ 'keydown.end': (event: KeyboardEvent) => {
271
+ const $lastCell = getLastCell(event)
272
+ if ($lastCell) {
273
+ event.preventDefault()
274
+ event.stopPropagation()
275
+ $lastCell.focus()
276
+ }
277
+ },
278
+ 'keydown.enter': (event: KeyboardEvent) => {
279
+ const $target = event.target as HTMLElement
280
+ if ($target instanceof HTMLTableCellElement) {
281
+ event.preventDefault()
282
+ event.stopPropagation()
283
+ const $downCell = getDownCell(event)
284
+ if ($downCell) {
285
+ $downCell.focus()
286
+ }
287
+ } else {
288
+ // handle other contexts
289
+ }
290
+ },
291
+ 'keydown.shift.enter': (event: KeyboardEvent) => {
292
+ const $target = event.target as HTMLElement
293
+ if ($target instanceof HTMLTableCellElement) {
294
+ event.preventDefault()
295
+ event.stopPropagation()
296
+ const $upCell = getUpCell(event)
297
+ if ($upCell) {
298
+ $upCell.focus()
299
+ }
300
+ } else {
301
+ // handle other contexts
302
+ }
303
+ },
304
+ 'keydown.home': (event: KeyboardEvent) => {
305
+ const $firstCell = getFirstCell(event)
306
+ if ($firstCell) {
307
+ event.preventDefault()
308
+ event.stopPropagation()
309
+ $firstCell.focus()
310
+ }
311
+ },
312
+ 'keydown.tab': (event: KeyboardEvent) => {
313
+ const $nextCell = getNextCell(event)
314
+ if ($nextCell) {
315
+ event.preventDefault()
316
+ event.stopPropagation()
317
+ $nextCell.focus()
318
+ }
319
+ },
320
+ 'keydown.shift.tab': (event: KeyboardEvent) => {
321
+ const $prevCell = getPrevCell(event)
322
+ if ($prevCell) {
323
+ event.preventDefault()
324
+ event.stopPropagation()
325
+ $prevCell.focus()
326
+ }
327
+ },
328
+ }
329
+
330
+ export function useKeyboardNav(options: KeyboardNavigationOptions[]) {
331
+ const getSelectors = (option: KeyboardNavigationOptions) => {
332
+ // get parent element
333
+ let $parent: Element | null = null
334
+ if (option.parent) {
335
+ if (typeof option.parent === 'string') {
336
+ $parent = document.querySelector(option.parent)
337
+ } else if (option.parent instanceof Element) {
338
+ $parent = option.parent
339
+ } else {
340
+ $parent = option.parent.value
341
+ }
342
+ }
343
+
344
+ // generate a list of selector(s)
345
+ let selectors: Element[] = []
346
+
347
+ if (option.selectors) {
348
+ if (typeof option.selectors === 'string') {
349
+ selectors = $parent
350
+ ? Array.from($parent.querySelectorAll(option.selectors))
351
+ : Array.from(document.querySelectorAll(option.selectors))
352
+ } else if (option.selectors instanceof Element) {
353
+ selectors.push(option.selectors)
354
+ } else {
355
+ if (Array.isArray(option.selectors.value)) {
356
+ for (const element of option.selectors.value) {
357
+ if (element instanceof Element) {
358
+ selectors.push(element)
359
+ } else {
360
+ selectors.push(element.$el as Element)
361
+ }
362
+ }
363
+ } else {
364
+ selectors.push(option.selectors.value)
365
+ }
366
+ }
367
+ } else {
368
+ const $children = Array.from($parent.children)
369
+ selectors = $children.filter((selector: HTMLElement) => {
370
+ // ignore elements not in the tab order or are not visible
371
+ return isFocusable(selector) && isVisible(selector)
372
+ })
373
+ }
374
+
375
+ return selectors
376
+ }
377
+
378
+ const getEventListener = (option: KeyboardNavigationOptions) => {
379
+ return (event: KeyboardEvent) => {
380
+ const activeKey = (eventKeyMap[event.key] as string) || event.key.toLowerCase()
381
+ if (modifierKeys.includes(activeKey)) return // ignore modifier key presses
382
+
383
+ const handlers = option.handlers || defaultKeypressHandlers
384
+ for (const key of Object.keys(handlers)) {
385
+ const [eventType, ...keys] = key.split('.')
386
+ if (eventType !== 'keydown') {
387
+ continue
388
+ }
389
+
390
+ if (keys.includes(activeKey)) {
391
+ const listener = handlers[key]
392
+
393
+ // check if the handler has modifiers, and if the modifier is active;
394
+ // this is to ensure exact key-press matches
395
+ const hasModifier = keys.filter(key => modifierKeys.includes(key))
396
+ const isModifierActive = modifierKeys.some(key => {
397
+ const modifierKey = key.charAt(0).toUpperCase() + key.slice(1)
398
+ return event.getModifierState(modifierKey)
399
+ })
400
+
401
+ if (hasModifier.length > 0) {
402
+ if (isModifierActive) {
403
+ for (const modifier of modifierKeys) {
404
+ if (keys.includes(modifier)) {
405
+ // docs: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState
406
+ const modifierKey = modifier.charAt(0).toUpperCase() + modifier.slice(1)
407
+ if (event.getModifierState(modifierKey)) {
408
+ listener(event)
409
+ }
410
+ }
411
+ }
412
+ }
413
+ } else {
414
+ if (!isModifierActive) {
415
+ listener(event)
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ onMounted(() => {
424
+ for (const option of options) {
425
+ const selectors = getSelectors(option)
426
+ for (const selector of selectors) {
427
+ selector.addEventListener('keydown', getEventListener(option))
428
+ }
429
+ }
430
+ })
431
+
432
+ onBeforeUnmount(() => {
433
+ for (const option of options) {
434
+ const selectors = getSelectors(option)
435
+ for (const selector of selectors) {
436
+ selector.removeEventListener('keydown', getEventListener(option))
437
+ }
438
+ }
439
+ })
440
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { App } from 'vue'
2
+
3
+ import { defaultKeypressHandlers, useKeyboardNav } from './composables/keyboard'
4
+
5
+ function install(app: App /* options */) {}
6
+
7
+ export { defaultKeypressHandlers, install, useKeyboardNav }