@rokkit/actions 1.0.0-next.104 → 1.0.0-next.106

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.104",
3
+ "version": "1.0.0-next.106",
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",
@@ -11,6 +11,11 @@
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
14
+ "scripts": {
15
+ "prepublishOnly": "tsc --project tsconfig.build.json",
16
+ "clean": "rm -rf dist",
17
+ "build": "bun clean && bun prepublishOnly"
18
+ },
14
19
  "files": [
15
20
  "src/**/*.js",
16
21
  "src/**/*.svelte"
@@ -25,13 +30,11 @@
25
30
  }
26
31
  },
27
32
  "dependencies": {
28
- "ramda": "^0.30.1"
33
+ "ramda": "^0.30.1",
34
+ "@rokkit/core": "latest"
29
35
  },
30
36
  "devDependencies": {
31
- "@rokkit/helpers": "1.0.0-next.104"
32
- },
33
- "scripts": {
34
- "clean": "rm -rf dist",
35
- "build": "pnpm clean && pnpm prepublishOnly"
37
+ "@rokkit/helpers": "latest",
38
+ "@rokkit/states": "latest"
36
39
  }
37
- }
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,98 @@
1
+ import { on } from 'svelte/events'
2
+ import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'
3
+ import { getPathFromKey } from '@rokkit/core'
4
+
5
+ const Horizontal = {
6
+ prev: ['ArrowLeft'],
7
+ next: ['ArrowRight'],
8
+ collapse: ['ArrowUp'],
9
+ expand: ['ArrowDown'],
10
+ select: ['Enter']
11
+ }
12
+
13
+ const Vertical = {
14
+ prev: ['ArrowUp'],
15
+ next: ['ArrowDown'],
16
+ collapse: ['ArrowLeft'],
17
+ expand: ['ArrowRight'],
18
+ select: ['Enter']
19
+ }
20
+
21
+ /**
22
+ * Get actions for the navigator
23
+ *
24
+ * @param {import('./types.js').DataWrapper} wrapper - The navigator wrapper
25
+ * @param {HTMLElement} root - The root element
26
+ * @returns {import('./types.js').NavigatorActions} - The navigator actions
27
+ */
28
+ function getActions(wrapper, root) {
29
+ const actions = {
30
+ prev: () => wrapper.movePrev(),
31
+ next: () => wrapper.moveNext(),
32
+ select: () => {
33
+ wrapper.select()
34
+ root.dispatchEvent(new CustomEvent('activate'))
35
+ },
36
+ collapse: () => wrapper.collapse?.(),
37
+ expand: () => wrapper.expand?.()
38
+ }
39
+ return actions
40
+ }
41
+
42
+ function handleIconClick(target, wrapper) {
43
+ const isIcon = target.tagName.toLowerCase() === 'rk-icon'
44
+ const state = isIcon ? target.getAttribute('data-state') : null
45
+
46
+ if (state === 'closed') {
47
+ wrapper.expand()
48
+ } else if (state === 'opened') {
49
+ wrapper.collapse()
50
+ }
51
+ return ['closed', 'opened'].includes(state)
52
+ }
53
+
54
+ /**
55
+ * Handle keyboard events
56
+ *
57
+ * @param {HTMLElement} root
58
+ * @param {import('./types.js').NavigatorConfig} options - Custom key mappings
59
+ */
60
+ export function navigator(root, { wrapper, options }) {
61
+ const keyMappings = options?.direction === 'horizontal' ? Horizontal : Vertical
62
+ const actions = getActions(wrapper, root)
63
+
64
+ /**
65
+ * Handle keyboard events
66
+ *
67
+ * @param {KeyboardEvent} event
68
+ */
69
+ const keyup = (event) => {
70
+ const { key } = event
71
+
72
+ const eventName = getEventForKey(keyMappings, key)
73
+ if (eventName) {
74
+ actions[eventName]()
75
+ }
76
+ }
77
+
78
+ const click = (event) => {
79
+ const node = getClosestAncestorWithAttribute(event.target, 'data-path')
80
+
81
+ if (node) {
82
+ const iconClicked = handleIconClick(event.target, wrapper)
83
+ const path = getPathFromKey(node.getAttribute('data-path'))
84
+ wrapper.select(path, event.ctrlKey || event.metaKey)
85
+ root.dispatchEvent(new CustomEvent('activate'))
86
+ if (!iconClicked) wrapper.toggleExpansion()
87
+ }
88
+ }
89
+
90
+ $effect(() => {
91
+ const cleanupKeyupEvent = on(root, 'keyup', keyup)
92
+ const cleanupClickEvent = on(root, 'click', click)
93
+ return () => {
94
+ cleanupKeyupEvent()
95
+ cleanupClickEvent()
96
+ }
97
+ })
98
+ }
@@ -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
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Jerry Thomas
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.