@rokkit/actions 1.0.0-next.105 → 1.0.0-next.107
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 +5 -3
- package/src/delegate.svelte.js +34 -0
- package/src/dismissable.svelte.js +33 -0
- package/src/fillable.svelte.js +115 -0
- package/src/index.js +9 -0
- package/src/keyboard.svelte.js +5 -4
- package/src/lib/event-manager.js +67 -0
- package/src/lib/index.js +3 -0
- package/src/lib/internal.js +33 -0
- package/src/navigable.svelte.js +31 -0
- package/src/navigator.svelte.js +259 -0
- package/src/pannable.svelte.js +66 -0
- package/src/skinnable.svelte.js +12 -0
- package/src/swipeable.svelte.js +140 -0
- package/src/themable.svelte.js +46 -0
- package/src/types.js +40 -0
- package/src/utils.js +43 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/actions",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.107",
|
|
4
4
|
"description": "Contains generic actions that can be used in various components.",
|
|
5
5
|
"author": "Jerry Thomas <me@jerrythomas.name>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"ramda": "^0.30.1"
|
|
33
|
+
"ramda": "^0.30.1",
|
|
34
|
+
"@rokkit/core": "latest"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
|
-
"@rokkit/helpers": "
|
|
37
|
+
"@rokkit/helpers": "latest",
|
|
38
|
+
"@rokkit/states": "latest"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { EventManager } from './lib'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Svelte action function for forwarding keyboard events from a parent element to a child.
|
|
5
|
+
* The child is selected using a CSS selector passed in the options object.
|
|
6
|
+
* Optionally, you can specify which keyboard events you want to forward: "keydown", "keyup", and/or "keypress".
|
|
7
|
+
* By default, all three events are forwarded.
|
|
8
|
+
*
|
|
9
|
+
* @param {HTMLElement} element - The parent element from which keyboard events will be forwarded.
|
|
10
|
+
* @param {import('./types').PushDownOptions} options - The options object.
|
|
11
|
+
* @returns {{destroy: Function}}
|
|
12
|
+
*/
|
|
13
|
+
export function delegateKeyboardEvents(
|
|
14
|
+
element,
|
|
15
|
+
{ selector, events = ['keydown', 'keyup', 'keypress'] }
|
|
16
|
+
) {
|
|
17
|
+
const child = element.querySelector(selector)
|
|
18
|
+
const handlers = {}
|
|
19
|
+
const manager = EventManager(element)
|
|
20
|
+
|
|
21
|
+
function forwardEvent(event) {
|
|
22
|
+
child.dispatchEvent(new KeyboardEvent(event.type, event))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (child) {
|
|
27
|
+
events.forEach((event) => {
|
|
28
|
+
handlers[event] = forwardEvent
|
|
29
|
+
})
|
|
30
|
+
manager.update(handlers)
|
|
31
|
+
}
|
|
32
|
+
return () => manager.reset()
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { on } from 'svelte/events'
|
|
2
|
+
const KEYCODE_ESC = 27
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A svelte action function that captures clicks outside the element or escape keypress
|
|
6
|
+
* emits a `dismiss` event. This is useful for closing a modal or dropdown.
|
|
7
|
+
*
|
|
8
|
+
* @param {HTMLElement} node
|
|
9
|
+
*/
|
|
10
|
+
export function dismissable(node) {
|
|
11
|
+
const handleClick = (event) => {
|
|
12
|
+
if (node && !node.contains(event.target) && !event.defaultPrevented) {
|
|
13
|
+
node.dispatchEvent(new CustomEvent('dismiss'))
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const keyup = (event) => {
|
|
18
|
+
if (event.keyCode === KEYCODE_ESC || event.key === 'Escape') {
|
|
19
|
+
event.stopPropagation()
|
|
20
|
+
node.dispatchEvent(new CustomEvent('dismiss', { detail: node }))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$effect(() => {
|
|
25
|
+
const cleanupClickEvent = on(document, 'click', handleClick)
|
|
26
|
+
const cleanupKeyupEvent = on(document, 'keyup', keyup)
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
cleanupClickEvent()
|
|
30
|
+
cleanupKeyupEvent()
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { on } from 'svelte/events'
|
|
2
|
+
/**
|
|
3
|
+
* Initialize empty fillable element style and add listener for click
|
|
4
|
+
*
|
|
5
|
+
* @param {HTMLCollection} blanks
|
|
6
|
+
* @param {EventListener} click
|
|
7
|
+
*/
|
|
8
|
+
function initialize(blanks, click) {
|
|
9
|
+
const registry = []
|
|
10
|
+
Array.from(blanks).forEach((blank, ref) => {
|
|
11
|
+
blank.innerHTML = '?'
|
|
12
|
+
blank.classList.add('empty')
|
|
13
|
+
blank.name = `fill-${ref}`
|
|
14
|
+
blank['data-index'] = ref
|
|
15
|
+
const cleanup = on(blank, 'click', click)
|
|
16
|
+
registry.push(cleanup)
|
|
17
|
+
})
|
|
18
|
+
return registry
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fill current blank with provided option
|
|
23
|
+
*
|
|
24
|
+
* @param {HTMLCollection} blanks
|
|
25
|
+
* @param {Array<import('./types.js').FillableData>} options
|
|
26
|
+
* @param {*} current
|
|
27
|
+
*/
|
|
28
|
+
function fill(blanks, { options, current }, node) {
|
|
29
|
+
if (current > -1 && current < Object.keys(blanks).length) {
|
|
30
|
+
const index = options.findIndex(({ actualIndex }) => actualIndex === current)
|
|
31
|
+
if (index > -1) {
|
|
32
|
+
blanks[current].innerHTML = options[index].value
|
|
33
|
+
blanks[current].classList.remove('empty')
|
|
34
|
+
blanks[current].classList.add('filled')
|
|
35
|
+
node.dispatchEvent(
|
|
36
|
+
new CustomEvent('fill', {
|
|
37
|
+
detail: {
|
|
38
|
+
index: current,
|
|
39
|
+
value: options[index].value
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Clear all fillable elements
|
|
49
|
+
*
|
|
50
|
+
* @param {EventListener} event
|
|
51
|
+
* @param {HTMLElement} node
|
|
52
|
+
*/
|
|
53
|
+
function clear(event, node, options) {
|
|
54
|
+
const item = options.find(({ value }) => value === event.target.innerHTML)
|
|
55
|
+
event.target.innerHTML = '?'
|
|
56
|
+
event.target.classList.remove('filled')
|
|
57
|
+
event.target.classList.remove('pass')
|
|
58
|
+
event.target.classList.remove('fail')
|
|
59
|
+
event.target.classList.add('empty')
|
|
60
|
+
|
|
61
|
+
node.dispatchEvent(
|
|
62
|
+
new CustomEvent('remove', {
|
|
63
|
+
detail: {
|
|
64
|
+
index: event.target['data-index'],
|
|
65
|
+
value: item.value
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate the filled values
|
|
73
|
+
*
|
|
74
|
+
* @param {HTMLCollection} blanks
|
|
75
|
+
* @param {import('./types').FillOptions} data
|
|
76
|
+
*/
|
|
77
|
+
function validate(blanks, data) {
|
|
78
|
+
Object.keys(blanks).forEach((_, ref) => {
|
|
79
|
+
const index = data.options.findIndex(({ actualIndex }) => actualIndex === ref)
|
|
80
|
+
if (index > -1)
|
|
81
|
+
blanks[ref].classList.add(
|
|
82
|
+
data.options[index].expectedIndex === data.options[index].actualIndex ? 'pass' : 'fail'
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Action for filling a <del>?</del> element in html block.
|
|
89
|
+
*
|
|
90
|
+
* @param {HTMLElement} node
|
|
91
|
+
* @param {import('./types').FillOptions} options
|
|
92
|
+
* @returns
|
|
93
|
+
*/
|
|
94
|
+
export function fillable(node, data) {
|
|
95
|
+
const blanks = node.getElementsByTagName('del')
|
|
96
|
+
|
|
97
|
+
function click(event) {
|
|
98
|
+
if (event.target.innerHTML !== '?') {
|
|
99
|
+
clear(event, node, data.options)
|
|
100
|
+
} else {
|
|
101
|
+
data.current = event.target['data-index']
|
|
102
|
+
fill(blanks, data, node)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
$effect(() => {
|
|
107
|
+
const registry = initialize(blanks, click)
|
|
108
|
+
|
|
109
|
+
if (data.check) validate(blanks, data)
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
registry.forEach((cleanup) => cleanup())
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
// skipcq: JS-E1004 - Needed for exposing all types
|
|
2
2
|
export * from './types.js'
|
|
3
3
|
export { keyboard } from './keyboard.svelte.js'
|
|
4
|
+
export { pannable } from './pannable.svelte.js'
|
|
5
|
+
export { swipeable } from './swipeable.svelte.js'
|
|
6
|
+
export { navigator } from './navigator.svelte.js'
|
|
7
|
+
export { themable } from './themable.svelte.js'
|
|
8
|
+
export { skinnable } from './skinnable.svelte.js'
|
|
9
|
+
export { dismissable } from './dismissable.svelte.js'
|
|
10
|
+
export { navigable } from './navigable.svelte.js'
|
|
11
|
+
export { fillable } from './fillable.svelte.js'
|
|
12
|
+
export { delegateKeyboardEvents } from './delegate.svelte.js'
|
package/src/keyboard.svelte.js
CHANGED
|
@@ -17,8 +17,8 @@ const defaultKeyMappings = {
|
|
|
17
17
|
* @param {HTMLElement} root
|
|
18
18
|
* @param {import('./types.js').KeyboardConfig} options - Custom key mappings
|
|
19
19
|
*/
|
|
20
|
-
export function keyboard(root, options =
|
|
21
|
-
const keyMappings = options
|
|
20
|
+
export function keyboard(root, options = null) {
|
|
21
|
+
const keyMappings = options ?? defaultKeyMappings
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Handle keyboard events
|
|
@@ -27,8 +27,9 @@ export function keyboard(root, options = defaultKeyMappings) {
|
|
|
27
27
|
*/
|
|
28
28
|
const keyup = (event) => {
|
|
29
29
|
const { key } = event
|
|
30
|
-
const eventName = getEventForKey(keyMappings, key)
|
|
31
30
|
|
|
31
|
+
const eventName = getEventForKey(keyMappings, key)
|
|
32
|
+
// verify that the target is a child of the root element?
|
|
32
33
|
if (eventName) {
|
|
33
34
|
root.dispatchEvent(new CustomEvent(eventName, { detail: key }))
|
|
34
35
|
}
|
|
@@ -48,7 +49,7 @@ export function keyboard(root, options = defaultKeyMappings) {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
$effect(() => {
|
|
51
|
-
const cleanupKeyupEvent = on(
|
|
52
|
+
const cleanupKeyupEvent = on(root, 'keyup', keyup)
|
|
52
53
|
const cleanupClickEvent = on(root, 'click', click)
|
|
53
54
|
return () => {
|
|
54
55
|
cleanupKeyupEvent()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { on } from 'svelte/events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reset an event listener.
|
|
5
|
+
* @param {string} event - The event name.
|
|
6
|
+
* @param {Object} registry - The object containing the event listeners.
|
|
7
|
+
*/
|
|
8
|
+
function resetEvent(event, registry) {
|
|
9
|
+
if (typeof registry[event] === 'function') {
|
|
10
|
+
registry[event]()
|
|
11
|
+
delete registry[event]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* EventManager class to manage event listeners on an element.
|
|
17
|
+
*
|
|
18
|
+
* @param {HTMLElement} element - The element to listen for events on.
|
|
19
|
+
* @param {Object} handlers - An object with event names as keys and event handlers as values.
|
|
20
|
+
* @returns {Object} - An object with methods to activate, reset, and update the event listeners.
|
|
21
|
+
*/
|
|
22
|
+
export function EventManager(element, handlers = {}, enabled = true) {
|
|
23
|
+
const registeredEvents = {}
|
|
24
|
+
const options = { handlers, enabled }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Activate the event listeners.
|
|
28
|
+
*/
|
|
29
|
+
function activate() {
|
|
30
|
+
if (options.enabled) {
|
|
31
|
+
Object.entries(options.handlers).forEach(([event, handler]) => {
|
|
32
|
+
resetEvent(event, registeredEvents)
|
|
33
|
+
registeredEvents[event] = on(element, event, handler)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reset the event listeners.
|
|
40
|
+
*/
|
|
41
|
+
function reset() {
|
|
42
|
+
Object.keys(registeredEvents).forEach((event) => {
|
|
43
|
+
resetEvent(event, registeredEvents)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Update the event listeners.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} newHandlers - An object with event names as keys and event handlers as values.
|
|
51
|
+
* @param {boolean} enabled - Whether to enable or disable the event listeners.
|
|
52
|
+
*/
|
|
53
|
+
function update(newHandlers = handlers, enabled = true) {
|
|
54
|
+
const hasChanged = handlers !== newHandlers
|
|
55
|
+
|
|
56
|
+
if (!enabled) reset()
|
|
57
|
+
|
|
58
|
+
if (hasChanged) {
|
|
59
|
+
options.handlers = newHandlers
|
|
60
|
+
options.enabled = enabled
|
|
61
|
+
activate()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
activate()
|
|
66
|
+
return { reset, update }
|
|
67
|
+
}
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets up event handlers based on the given options.
|
|
3
|
+
* Returns whether or not the event handlers are listening.
|
|
4
|
+
*
|
|
5
|
+
* @param {HTMLElement} element
|
|
6
|
+
* @param {import('../types').EventHandlers} listeners
|
|
7
|
+
* @param {import('../types').TraversableOptions} options
|
|
8
|
+
* @returns {void}
|
|
9
|
+
*/
|
|
10
|
+
export function setupListeners(element, listeners, options) {
|
|
11
|
+
const { enabled } = { enabled: true, ...options }
|
|
12
|
+
if (enabled) {
|
|
13
|
+
Object.entries(listeners).forEach(([event, listener]) =>
|
|
14
|
+
element.addEventListener(event, listener)
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Removes event handlers based on the given options.
|
|
21
|
+
* Returns whether or not the event handlers are listening.
|
|
22
|
+
*
|
|
23
|
+
* @param {HTMLElement} element
|
|
24
|
+
* @param {import('../types').EventHandlers} listeners
|
|
25
|
+
* @returns {void}
|
|
26
|
+
*/
|
|
27
|
+
export function removeListeners(element, listeners) {
|
|
28
|
+
if (listeners) {
|
|
29
|
+
Object.entries(listeners).forEach(([event, listener]) => {
|
|
30
|
+
element.removeEventListener(event, listener)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { on } from 'svelte/events'
|
|
2
|
+
import { handleAction, getKeyboardActions } from './utils'
|
|
3
|
+
|
|
4
|
+
const defaultOptions = { horizontal: true, nested: false, enabled: true }
|
|
5
|
+
/**
|
|
6
|
+
* A svelte action function that captures keyboard evvents and emits event for corresponding movements.
|
|
7
|
+
*
|
|
8
|
+
* @param {HTMLElement} node
|
|
9
|
+
* @param {import('./types').NavigableOptions} options
|
|
10
|
+
* @returns {import('./types').SvelteActionReturn}
|
|
11
|
+
*/
|
|
12
|
+
export function navigable(node, options) {
|
|
13
|
+
const handlers = {
|
|
14
|
+
previous: () => node.dispatchEvent(new CustomEvent('previous')),
|
|
15
|
+
next: () => node.dispatchEvent(new CustomEvent('next')),
|
|
16
|
+
collapse: () => node.dispatchEvent(new CustomEvent('collapse')),
|
|
17
|
+
expand: () => node.dispatchEvent(new CustomEvent('expand')),
|
|
18
|
+
select: () => node.dispatchEvent(new CustomEvent('select'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let actions = {}
|
|
22
|
+
const handleKeydown = (event) => handleAction(actions, event)
|
|
23
|
+
|
|
24
|
+
$effect(() => {
|
|
25
|
+
const props = { ...defaultOptions, ...options }
|
|
26
|
+
actions = getKeyboardActions(props, handlers)
|
|
27
|
+
const cleanup = on(node, 'keyup', handleKeydown)
|
|
28
|
+
|
|
29
|
+
return () => cleanup()
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { on } from 'svelte/events'
|
|
2
|
+
import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'
|
|
3
|
+
import { getPathFromKey } from '@rokkit/core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Key mappings for different navigation directions
|
|
7
|
+
*/
|
|
8
|
+
const KEY_MAPPINGS = {
|
|
9
|
+
horizontal: {
|
|
10
|
+
prev: ['ArrowLeft'],
|
|
11
|
+
next: ['ArrowRight'],
|
|
12
|
+
collapse: ['ArrowUp'],
|
|
13
|
+
expand: ['ArrowDown'],
|
|
14
|
+
select: ['Enter'],
|
|
15
|
+
toggle: ['Space'],
|
|
16
|
+
delete: ['Delete', 'Backspace']
|
|
17
|
+
},
|
|
18
|
+
vertical: {
|
|
19
|
+
prev: ['ArrowUp'],
|
|
20
|
+
next: ['ArrowDown'],
|
|
21
|
+
collapse: ['ArrowLeft'],
|
|
22
|
+
expand: ['ArrowRight'],
|
|
23
|
+
select: ['Enter'],
|
|
24
|
+
toggle: ['Space'],
|
|
25
|
+
delete: ['Delete', 'Backspace']
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Navigator class to handle keyboard and mouse navigation
|
|
31
|
+
* @class
|
|
32
|
+
*/
|
|
33
|
+
class NavigatorController {
|
|
34
|
+
/**
|
|
35
|
+
* @param {HTMLElement} root - The root element
|
|
36
|
+
* @param {Object} wrapper - The data wrapper object
|
|
37
|
+
* @param {Object} options - Configuration options
|
|
38
|
+
*/
|
|
39
|
+
constructor(root, wrapper, options = {}) {
|
|
40
|
+
this.root = root
|
|
41
|
+
this.wrapper = wrapper
|
|
42
|
+
this.options = options
|
|
43
|
+
this.keyMappings = KEY_MAPPINGS[options?.direction || 'vertical']
|
|
44
|
+
|
|
45
|
+
this.handleKeyUp = this.handleKeyUp.bind(this)
|
|
46
|
+
this.handleClick = this.handleClick.bind(this)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize event listeners
|
|
51
|
+
*/
|
|
52
|
+
init() {
|
|
53
|
+
return [on(this.root, 'keyup', this.handleKeyUp), on(this.root, 'click', this.handleClick)]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if modifier keys are pressed
|
|
58
|
+
* @param {Event} event - The event object
|
|
59
|
+
* @returns {boolean} - Whether modifier keys are pressed
|
|
60
|
+
*/
|
|
61
|
+
hasModifierKey(event) {
|
|
62
|
+
return event.ctrlKey || event.metaKey || event.shiftKey
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handle keyboard events
|
|
67
|
+
* @param {KeyboardEvent} event - The keyboard event
|
|
68
|
+
*/
|
|
69
|
+
handleKeyUp(event) {
|
|
70
|
+
const { key } = event
|
|
71
|
+
|
|
72
|
+
const eventName = getEventForKey(this.keyMappings, key)
|
|
73
|
+
|
|
74
|
+
if (!eventName) return
|
|
75
|
+
|
|
76
|
+
const handled = this.processKeyAction(eventName, event)
|
|
77
|
+
|
|
78
|
+
if (handled) {
|
|
79
|
+
event.stopPropagation()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Process a key action based on its type
|
|
85
|
+
* @param {string} eventName - The mapped event name
|
|
86
|
+
* @param {KeyboardEvent} event - The original keyboard event
|
|
87
|
+
* @returns {boolean} - Whether the event was handled
|
|
88
|
+
*/
|
|
89
|
+
processKeyAction(eventName, event) {
|
|
90
|
+
const actionHandlers = {
|
|
91
|
+
prev: () => this.handleNavigationKey('prev', this.hasModifierKey(event)),
|
|
92
|
+
next: () => this.handleNavigationKey('next', this.hasModifierKey(event)),
|
|
93
|
+
expand: () => this.executeAction('expand'),
|
|
94
|
+
collapse: () => this.executeAction('collapse'),
|
|
95
|
+
select: () => this.executeAction('select'),
|
|
96
|
+
toggle: () => this.executeAction('extend'),
|
|
97
|
+
delete: () => this.executeAction('delete')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handler = actionHandlers[eventName]
|
|
101
|
+
if (handler) {
|
|
102
|
+
return handler() !== false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle navigation key presses (arrows)
|
|
110
|
+
* @param {string} direction - The direction to move ('prev' or 'next')
|
|
111
|
+
* @param {boolean} hasModifier - Whether modifier keys are pressed
|
|
112
|
+
* @returns {boolean} - Whether the action was handled
|
|
113
|
+
*/
|
|
114
|
+
handleNavigationKey(direction, hasModifier) {
|
|
115
|
+
// First move in the specified direction
|
|
116
|
+
const moved = this.executeAction(direction)
|
|
117
|
+
|
|
118
|
+
if (!moved) return false
|
|
119
|
+
|
|
120
|
+
// If modifier key is pressed and multiSelect is enabled, extend selection
|
|
121
|
+
if (hasModifier && this.wrapper.multiSelect) {
|
|
122
|
+
this.executeAction('extend')
|
|
123
|
+
} else {
|
|
124
|
+
// Otherwise just select the current item
|
|
125
|
+
this.executeAction('select')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Always emit a move event for navigation
|
|
129
|
+
this.emitActionEvent('move')
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Handle click events
|
|
135
|
+
* @param {MouseEvent} event - The click event
|
|
136
|
+
*/
|
|
137
|
+
handleClick(event) {
|
|
138
|
+
const node = getClosestAncestorWithAttribute(event.target, 'data-path')
|
|
139
|
+
|
|
140
|
+
if (!node) return
|
|
141
|
+
|
|
142
|
+
const path = getPathFromKey(node.getAttribute('data-path'))
|
|
143
|
+
// Check if click was on a toggle icon
|
|
144
|
+
const toggleIconClicked = this.handleToggleIconClick(path, event.target)
|
|
145
|
+
|
|
146
|
+
if (toggleIconClicked) return
|
|
147
|
+
|
|
148
|
+
// Move to the clicked item
|
|
149
|
+
this.wrapper.moveTo(path)
|
|
150
|
+
|
|
151
|
+
// Handle selection based on modifier keys
|
|
152
|
+
if (this.hasModifierKey(event) && this.wrapper.multiSelect) {
|
|
153
|
+
this.executeAction('extend', path)
|
|
154
|
+
} else {
|
|
155
|
+
this.executeAction('select', path)
|
|
156
|
+
}
|
|
157
|
+
this.executeAction('toggle', path)
|
|
158
|
+
event.stopPropagation()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle clicks on toggle icons
|
|
163
|
+
* @param {number[]} path - The path of the item to perform the action on
|
|
164
|
+
* @param {HTMLElement} target - The clicked element
|
|
165
|
+
* @returns {boolean} - Whether a toggle icon was clicked and handled
|
|
166
|
+
*/
|
|
167
|
+
handleToggleIconClick(path, target) {
|
|
168
|
+
const isIcon = target.tagName.toLowerCase() === 'rk-icon'
|
|
169
|
+
const state = target.getAttribute('data-state')
|
|
170
|
+
if (!isIcon || !['closed', 'opened'].includes(state)) return false
|
|
171
|
+
|
|
172
|
+
return this.executeAction('toggle', path)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Execute an action if available
|
|
177
|
+
* @param {string} actionName - The name of the action to execute
|
|
178
|
+
* @param {number[]} path - The path of the item to perform the action on
|
|
179
|
+
* @returns {boolean} - Whether the action was executed
|
|
180
|
+
*/
|
|
181
|
+
executeAction(actionName, path) {
|
|
182
|
+
// Get the action function based on action name
|
|
183
|
+
const action = this.getActionFunction(actionName)
|
|
184
|
+
|
|
185
|
+
if (action) {
|
|
186
|
+
const executed = path ? action(path) : action()
|
|
187
|
+
if (executed) this.emitActionEvent(actionName)
|
|
188
|
+
|
|
189
|
+
return executed
|
|
190
|
+
}
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the appropriate action function based on action name and conditions
|
|
196
|
+
* @param {string} actionName - The name of the action
|
|
197
|
+
* @returns {Function|null} - The action function or null if not available
|
|
198
|
+
*/
|
|
199
|
+
getActionFunction(actionName) {
|
|
200
|
+
// Basic navigation and selection actions always available
|
|
201
|
+
const actions = {
|
|
202
|
+
prev: () => this.wrapper.movePrev(),
|
|
203
|
+
next: () => this.wrapper.moveNext(),
|
|
204
|
+
select: (path) => this.wrapper.select(path),
|
|
205
|
+
collapse: (path) => this.wrapper.collapse(path),
|
|
206
|
+
expand: (path) => this.wrapper.expand(path),
|
|
207
|
+
toggle: (path) => this.wrapper.toggleExpansion(path),
|
|
208
|
+
extend: (path) => this.wrapper.extendSelection(path),
|
|
209
|
+
delete: () => this.wrapper.delete()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return actions[actionName] || null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Emit an action event
|
|
217
|
+
* @param {string} eventName - The name of the event to emit
|
|
218
|
+
*/
|
|
219
|
+
emitActionEvent(eventName) {
|
|
220
|
+
const data = {
|
|
221
|
+
path: this.wrapper.currentNode?.path,
|
|
222
|
+
value: this.wrapper.currentNode?.value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// For select events, include selected nodes
|
|
226
|
+
if (['select', 'extend'].includes(eventName)) {
|
|
227
|
+
data.selected = Array.from(this.wrapper.selectedNodes.values()).map((node) => node.value)
|
|
228
|
+
// Normalize selection events to 'select'
|
|
229
|
+
eventName = 'select'
|
|
230
|
+
} else if (['prev', 'next'].includes(eventName)) {
|
|
231
|
+
eventName = 'move'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.root.dispatchEvent(
|
|
235
|
+
new CustomEvent('action', {
|
|
236
|
+
detail: {
|
|
237
|
+
eventName,
|
|
238
|
+
data
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Navigator action for Svelte components
|
|
247
|
+
* @param {HTMLElement} root - The root element
|
|
248
|
+
* @param {Object} params - Parameters including wrapper and options
|
|
249
|
+
*/
|
|
250
|
+
export function navigator(root, { wrapper, options }) {
|
|
251
|
+
const controller = new NavigatorController(root, wrapper, options)
|
|
252
|
+
|
|
253
|
+
$effect(() => {
|
|
254
|
+
const cleanupFunctions = controller.init()
|
|
255
|
+
return () => {
|
|
256
|
+
cleanupFunctions.forEach((cleanup) => cleanup())
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { omit } from 'ramda'
|
|
2
|
+
import { removeListeners, setupListeners } from './lib'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handles the panning event.
|
|
6
|
+
*
|
|
7
|
+
* @param {HTMLElement} node - The node where the event is dispatched.
|
|
8
|
+
* @param {Event} event - The event object.
|
|
9
|
+
* @param {string} name - The name of the event.
|
|
10
|
+
* @param {import('./types').Coords} coords - The previous coordinates of the event.
|
|
11
|
+
*/
|
|
12
|
+
function handleEvent(node, event, name, coords) {
|
|
13
|
+
const x = event.clientX ?? event.touches[0].clientX
|
|
14
|
+
const y = event.clientY ?? event.touches[0].clientY
|
|
15
|
+
const detail = { x, y }
|
|
16
|
+
|
|
17
|
+
if (name === 'panmove') {
|
|
18
|
+
detail.dx = x - coords.x
|
|
19
|
+
detail.dy = y - coords.y
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
event.stopPropagation()
|
|
23
|
+
event.preventDefault()
|
|
24
|
+
node.dispatchEvent(new CustomEvent(name, { detail }))
|
|
25
|
+
return omit(['dx', 'dy'], detail)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Makes an element pannable with mouse or touch events.
|
|
30
|
+
*
|
|
31
|
+
* @param {HTMLElement} node The DOM element to apply the panning action.
|
|
32
|
+
*/
|
|
33
|
+
export function pannable(node) {
|
|
34
|
+
let coords = { x: 0, y: 0 }
|
|
35
|
+
const listeners = { primary: {}, secondary: {} }
|
|
36
|
+
|
|
37
|
+
function start(event) {
|
|
38
|
+
coords = handleEvent(node, event, 'panstart', coords)
|
|
39
|
+
setupListeners(window, listeners.secondary)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function move(event) {
|
|
43
|
+
coords = handleEvent(node, event, 'panmove', coords)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stop(event) {
|
|
47
|
+
coords = handleEvent(node, event, 'panend', coords)
|
|
48
|
+
removeListeners(window, listeners.secondary)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
listeners.primary = {
|
|
52
|
+
mousedown: start,
|
|
53
|
+
touchstart: start
|
|
54
|
+
}
|
|
55
|
+
listeners.secondary = {
|
|
56
|
+
mousemove: move,
|
|
57
|
+
mouseup: stop,
|
|
58
|
+
touchmove: move,
|
|
59
|
+
touchend: stop
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$effect(() => {
|
|
63
|
+
setupListeners(node, listeners.primary)
|
|
64
|
+
return () => removeListeners(node, listeners.primary)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies theme variables to an element
|
|
3
|
+
* @param {HTMLElement} node - Element to apply variables to
|
|
4
|
+
* @param {Object.<string, string>} variables - CSS variables and their values
|
|
5
|
+
*/
|
|
6
|
+
export function skinnable(node, variables) {
|
|
7
|
+
$effect(() => {
|
|
8
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
9
|
+
node.style.setProperty(key, value)
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { EventManager } from './lib'
|
|
2
|
+
|
|
3
|
+
const defaultOptions = {
|
|
4
|
+
horizontal: true,
|
|
5
|
+
vertical: false,
|
|
6
|
+
threshold: 100,
|
|
7
|
+
enabled: true,
|
|
8
|
+
minSpeed: 300
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculates and returns the distance and duration of the swipe.
|
|
13
|
+
*
|
|
14
|
+
* @param {Event} event - The event object that initiated the touchEnd.
|
|
15
|
+
* @param {object} track - The tracking object holding the start of the touch action.
|
|
16
|
+
* @returns {{distance: {x: number, y: number}, duration: number}} The distance swiped (x and y) and the duration of the swipe.
|
|
17
|
+
*/
|
|
18
|
+
function getTouchMetrics(event, track) {
|
|
19
|
+
const touch = event.changedTouches ? event.changedTouches[0] : event
|
|
20
|
+
const distX = touch.clientX - track.startX
|
|
21
|
+
const distY = touch.clientY - track.startY
|
|
22
|
+
const duration = (new Date().getTime() - track.startTime) / 1000
|
|
23
|
+
return { distance: { x: distX, y: distY }, duration }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Checks if the swipe was fast enough according to the minimum speed requirement.
|
|
28
|
+
*
|
|
29
|
+
* @param {{x: number, y: number}} distance - The distance of the swipe action.
|
|
30
|
+
* @param {number} duration - The duration of the swipe action in seconds.
|
|
31
|
+
* @param {number} minSpeed - The minimum speed threshold for the swipe action.
|
|
32
|
+
* @returns {boolean} True if the swipe is fast enough, otherwise false.
|
|
33
|
+
*/
|
|
34
|
+
function isSwipeFastEnough(distance, duration, minSpeed) {
|
|
35
|
+
const speed = Math.max(Math.abs(distance.x), Math.abs(distance.y)) / duration
|
|
36
|
+
return speed > minSpeed
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the swipe direction based on the distance in the x and y axis.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} distX - The distance in the x axis.
|
|
43
|
+
* @param {number} distY - The distance in the y axis.
|
|
44
|
+
* @returns {string} The swipe direction.
|
|
45
|
+
*/
|
|
46
|
+
function getSwipeDirection(distX, distY) {
|
|
47
|
+
if (Math.abs(distX) > Math.abs(distY)) {
|
|
48
|
+
return distX > 0 ? 'Right' : 'Left'
|
|
49
|
+
} else {
|
|
50
|
+
return distY > 0 ? 'Down' : 'Up'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Determines swipe validity and direction based on horizontal/vertical preferences and thresholds.
|
|
56
|
+
*
|
|
57
|
+
* @param {{x: number, y: number}} distance - The distance of the swipe.
|
|
58
|
+
* @param {object} options - Configuration options such as direction preferences and thresholds.
|
|
59
|
+
* @returns {{isValid: boolean, direction?: string}} Object indicating whether the swipe is valid, and if so, its direction.
|
|
60
|
+
*/
|
|
61
|
+
function getSwipeDetails(distance, options) {
|
|
62
|
+
const isHorizontalSwipe = options.horizontal && Math.abs(distance.x) >= options.threshold
|
|
63
|
+
const isVerticalSwipe = options.vertical && Math.abs(distance.y) >= options.threshold
|
|
64
|
+
if (isHorizontalSwipe || isVerticalSwipe) {
|
|
65
|
+
return {
|
|
66
|
+
isValid: true,
|
|
67
|
+
direction: getSwipeDirection(distance.x, distance.y)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { isValid: false }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Handles the touch start event.
|
|
75
|
+
*
|
|
76
|
+
* @param {Event} event
|
|
77
|
+
* @param {import(./types).TouchTracker} track
|
|
78
|
+
*/
|
|
79
|
+
function touchStart(event, track) {
|
|
80
|
+
const touch = event.touches ? event.touches[0] : event
|
|
81
|
+
track.startX = touch.clientX
|
|
82
|
+
track.startY = touch.clientY
|
|
83
|
+
track.startTime = new Date().getTime()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Handles the touch end event and triggers a swipe event if the criteria are met.
|
|
88
|
+
*
|
|
89
|
+
* @param {Event} event - The event object representing the touch or mouse event.
|
|
90
|
+
* @param {HTMLElement} node - The HTML element on which the swipe event will be dispatched.
|
|
91
|
+
* @param {object} options - Configuration options for determining swipe behavior.
|
|
92
|
+
* @param {object} track - An object tracking the start point and time of the touch or swipe action.
|
|
93
|
+
*/
|
|
94
|
+
function touchEnd(event, node, options, track) {
|
|
95
|
+
const { distance, duration } = getTouchMetrics(event, track)
|
|
96
|
+
if (!isSwipeFastEnough(distance, duration, options.minSpeed)) return
|
|
97
|
+
|
|
98
|
+
const swipeDetails = getSwipeDetails(distance, options)
|
|
99
|
+
if (!swipeDetails.isValid) return
|
|
100
|
+
node.dispatchEvent(new CustomEvent(`swipe${swipeDetails.direction}`))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns the listeners for the swipeable action.
|
|
105
|
+
* @param {HTMLElement} node - The node where the event is dispatched.
|
|
106
|
+
* @param {import(./types).SwipeableOptions} options - The options for the swipe.
|
|
107
|
+
* @param {import(./types).TouchTracker} track - The tracking object.
|
|
108
|
+
* @returns {import(./types).Listeners}
|
|
109
|
+
*/
|
|
110
|
+
function getListeners(node, options, track) {
|
|
111
|
+
if (!options.enabled) return {}
|
|
112
|
+
|
|
113
|
+
const listeners = {
|
|
114
|
+
touchend: (e) => touchEnd(e, node, options, track),
|
|
115
|
+
touchstart: (e) => touchStart(e, track),
|
|
116
|
+
mousedown: (e) => touchStart(e, track),
|
|
117
|
+
mouseup: (e) => touchEnd(e, node, options, track)
|
|
118
|
+
}
|
|
119
|
+
return listeners
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A svelte action function that captures swipe actions and emits event for corresponding movements.
|
|
124
|
+
*
|
|
125
|
+
* @param {HTMLElement} node
|
|
126
|
+
* @param {import(./types).SwipeableOptions} options
|
|
127
|
+
* @returns {import('./types').SvelteActionReturn}
|
|
128
|
+
*/
|
|
129
|
+
export function swipeable(node, options = defaultOptions) {
|
|
130
|
+
const track = {}
|
|
131
|
+
const manager = EventManager(node)
|
|
132
|
+
|
|
133
|
+
$effect(() => {
|
|
134
|
+
const props = { ...defaultOptions, ...options }
|
|
135
|
+
const listeners = getListeners(node, props, track)
|
|
136
|
+
manager.update(listeners, props.enabled)
|
|
137
|
+
|
|
138
|
+
return () => manager.reset()
|
|
139
|
+
})
|
|
140
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const DEFAULT_THEME = { style: 'rokkit', mode: 'dark', density: 'comfortable' }
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Update the theme attributes when the state changes.
|
|
5
|
+
*
|
|
6
|
+
* @param {HTMLElement} root
|
|
7
|
+
* @param {import('./types.js').ThemableConfig} options - Custom key mappings
|
|
8
|
+
*/
|
|
9
|
+
export function themable(root, options) {
|
|
10
|
+
const { theme = DEFAULT_THEME, storageKey } = options ?? {}
|
|
11
|
+
|
|
12
|
+
if (storageKey) {
|
|
13
|
+
// Initial load from storage
|
|
14
|
+
theme.load(storageKey)
|
|
15
|
+
|
|
16
|
+
// Save changes to storage
|
|
17
|
+
$effect(() => {
|
|
18
|
+
theme.save(storageKey)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Handle storage events
|
|
22
|
+
const handleStorage = (event) => {
|
|
23
|
+
if (event.key === storageKey && event.newValue !== null) {
|
|
24
|
+
try {
|
|
25
|
+
const newTheme = JSON.parse(event.newValue)
|
|
26
|
+
theme.update(newTheme)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.warn(`Failed to parse theme from storage event for key "${storageKey}"`, e)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Set up storage event listener
|
|
34
|
+
$effect.root(() => {
|
|
35
|
+
window.addEventListener('storage', handleStorage)
|
|
36
|
+
return () => window.removeEventListener('storage', handleStorage)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
$effect(() => {
|
|
40
|
+
root.dataset.style = theme.style
|
|
41
|
+
root.dataset.mode = theme.mode
|
|
42
|
+
root.dataset.density = theme.density
|
|
43
|
+
|
|
44
|
+
// if (storageKey) theme.save(storageKey)
|
|
45
|
+
})
|
|
46
|
+
}
|
package/src/types.js
CHANGED
|
@@ -8,3 +8,43 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* @typedef {Object<string, (string[]|RegExp) >} KeyboardConfig
|
|
10
10
|
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {'vertical'|'horizontal'} Direction
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} NavigatorOptions
|
|
18
|
+
* @property {boolean} enabled - Whether the navigator is enabled
|
|
19
|
+
* @property {Direction} direction - Whether the navigator is vertical or horizontal
|
|
20
|
+
* @property {boolean} multiselect - Whether the navigator supports multiple selections
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} DataWrapper
|
|
25
|
+
* @property {Function} moveNext
|
|
26
|
+
* @property {Function} movePrev
|
|
27
|
+
* @property {Function} moveFirst
|
|
28
|
+
* @property {Function} moveLast
|
|
29
|
+
* @property {Function} expand
|
|
30
|
+
* @property {Function} collapse
|
|
31
|
+
* @property {Function} select
|
|
32
|
+
* @property {Function} toggleExpansion
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} NavigatorActions
|
|
37
|
+
* @property {Function} next
|
|
38
|
+
* @property {Function} prev
|
|
39
|
+
* @property {Function} first
|
|
40
|
+
* @property {Function} last
|
|
41
|
+
* @property {Function} expand
|
|
42
|
+
* @property {Function} collapse
|
|
43
|
+
* @property {Function} select
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} NavigatorConfig
|
|
48
|
+
* @property {Navigator} wrapper - Whether the navigator is enabled
|
|
49
|
+
* @property {NavigatorOptions} options - Whether the navigator is vertical or horizontal
|
|
50
|
+
*/
|
package/src/utils.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { find, toPairs } from 'ramda'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Finds the closest ancestor of the given element that has the given attribute.
|
|
3
5
|
*
|
|
@@ -11,8 +13,6 @@ export function getClosestAncestorWithAttribute(element, attribute) {
|
|
|
11
13
|
return getClosestAncestorWithAttribute(element.parentElement, attribute)
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
import * as R from 'ramda'
|
|
15
|
-
|
|
16
16
|
/**
|
|
17
17
|
* Get the event name for a given key.
|
|
18
18
|
* @param {import('./types.js').KeyboardConfig} keyMapping
|
|
@@ -20,9 +20,49 @@ import * as R from 'ramda'
|
|
|
20
20
|
* @returns {string|null} - The event name or null if no match is found.
|
|
21
21
|
*/
|
|
22
22
|
export const getEventForKey = (keyMapping, key) => {
|
|
23
|
+
// eslint-disable-next-line no-unused-vars
|
|
23
24
|
const matchEvent = ([eventName, keys]) =>
|
|
24
25
|
(Array.isArray(keys) && keys.includes(key)) || (keys instanceof RegExp && keys.test(key))
|
|
25
26
|
|
|
26
|
-
const event =
|
|
27
|
+
const event = find(matchEvent, toPairs(keyMapping))
|
|
27
28
|
return event ? event[0] : null
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* Generic action handler for keyboard events.
|
|
33
|
+
*
|
|
34
|
+
* @param {Record<string, () => void>} actions
|
|
35
|
+
* @param {KeyboardEvent} event
|
|
36
|
+
*/
|
|
37
|
+
export function handleAction(actions, event) {
|
|
38
|
+
if (event.key in actions) {
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
event.stopPropagation()
|
|
41
|
+
actions[event.key]()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Maps keys to actions based on the configuration.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('./types').NavigableOptions} options
|
|
49
|
+
* @param {import('./types').NavigableHandlers} handlers
|
|
50
|
+
*/
|
|
51
|
+
export function getKeyboardActions(options, handlers) {
|
|
52
|
+
if (!options.enabled) return {}
|
|
53
|
+
|
|
54
|
+
const movement = options.horizontal
|
|
55
|
+
? { ArrowLeft: handlers.previous, ArrowRight: handlers.next }
|
|
56
|
+
: { ArrowUp: handlers.previous, ArrowDown: handlers.next }
|
|
57
|
+
const change = options.nested
|
|
58
|
+
? options.horizontal
|
|
59
|
+
? { ArrowUp: handlers.collapse, ArrowDown: handlers.expand }
|
|
60
|
+
: { ArrowLeft: handlers.collapse, ArrowRight: handlers.expand }
|
|
61
|
+
: {}
|
|
62
|
+
return {
|
|
63
|
+
Enter: handlers.select,
|
|
64
|
+
' ': handlers.select,
|
|
65
|
+
...movement,
|
|
66
|
+
...change
|
|
67
|
+
}
|
|
68
|
+
}
|