@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.105",
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": "1.0.0-next.104"
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'
@@ -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 = defaultKeyMappings) {
21
- const keyMappings = options || defaultKeyMappings
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(document, 'keyup', keyup)
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
+ }
@@ -0,0 +1,3 @@
1
+ // skipcq: JS-E1004 - Needed for exposing all functions
2
+ export * from './internal'
3
+ export { EventManager } from './event-manager'
@@ -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 = R.find(matchEvent, R.toPairs(keyMapping))
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
+ }