@rokkit/actions 1.0.0-next.99 → 1.0.2

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/src/kbd.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Gets horizontal movement actions based on text direction
3
+ * @param {Object} handlers - Handler functions
4
+ * @param {string} [dir='ltr'] - Text direction ('ltr' or 'rtl')
5
+ * @returns {Object} Object mapping arrow keys to movement handlers
6
+ */
7
+ function getHorizontalMovementActions(handlers, dir = 'ltr') {
8
+ return dir === 'rtl'
9
+ ? { ArrowRight: handlers.previous, ArrowLeft: handlers.next }
10
+ : { ArrowLeft: handlers.previous, ArrowRight: handlers.next }
11
+ }
12
+
13
+ /**
14
+ * Gets vertical movement actions (not affected by direction)
15
+ * @param {Object} handlers - Handler functions
16
+ * @returns {Object} Object mapping arrow keys to movement handlers
17
+ */
18
+ function getVerticalMovementActions(handlers) {
19
+ return { ArrowUp: handlers.previous, ArrowDown: handlers.next }
20
+ }
21
+
22
+ /**
23
+ * Gets horizontal expand/collapse actions (not affected by direction)
24
+ * @param {Object} handlers - Handler functions
25
+ * @returns {Object} Object mapping arrow keys to expand/collapse handlers
26
+ */
27
+ function getHorizontalExpandActions(handlers) {
28
+ return { ArrowUp: handlers.collapse, ArrowDown: handlers.expand }
29
+ }
30
+
31
+ /**
32
+ * Gets vertical expand/collapse actions based on text direction
33
+ * @param {Object} handlers - Handler functions
34
+ * @param {string} [dir='ltr'] - Text direction ('ltr' or 'rtl')
35
+ * @returns {Object} Object mapping arrow keys to expand/collapse handlers
36
+ */
37
+ function getVerticalExpandActions(handlers, dir = 'ltr') {
38
+ return dir === 'rtl'
39
+ ? { ArrowRight: handlers.collapse, ArrowLeft: handlers.expand }
40
+ : { ArrowLeft: handlers.collapse, ArrowRight: handlers.expand }
41
+ }
42
+
43
+ /**
44
+ * Gets common selection actions
45
+ * @param {Object} handlers - Handler functions
46
+ * @returns {Object} Object mapping keys to selection handlers
47
+ */
48
+ function getCommonActions(handlers) {
49
+ return {
50
+ Enter: handlers.select,
51
+ ' ': handlers.select
52
+ }
53
+ }
54
+
55
+ // Default navigation options
56
+ export const defaultNavigationOptions = {
57
+ orientation: 'vertical',
58
+ dir: 'ltr',
59
+ nested: false,
60
+ enabled: true,
61
+ typeahead: false
62
+ }
63
+
64
+ /**
65
+ * Gets keyboard action handlers based on orientation and direction
66
+ * @param {Object} options - Configuration options
67
+ * @param {Object} handlers - Event handler functions
68
+ * @returns {Object} Object mapping key presses to handler functions
69
+ */
70
+ export function getKeyboardActions(options, handlers) {
71
+ const { orientation, dir, nested, enabled } = { ...defaultNavigationOptions, ...options }
72
+
73
+ if (!enabled) return {}
74
+
75
+ const common = getCommonActions(handlers)
76
+
77
+ // Determine movement actions based on orientation
78
+ const isHorizontal = orientation === 'horizontal'
79
+ const movement = isHorizontal
80
+ ? getHorizontalMovementActions(handlers, dir)
81
+ : getVerticalMovementActions(handlers)
82
+
83
+ // If not nested, we don't need expand/collapse actions
84
+ if (!nested) {
85
+ return { ...common, ...movement }
86
+ }
87
+
88
+ // Determine expand/collapse actions based on orientation
89
+ const expandCollapse = isHorizontal
90
+ ? getHorizontalExpandActions(handlers)
91
+ : getVerticalExpandActions(handlers, dir)
92
+
93
+ return { ...common, ...movement, ...expandCollapse }
94
+ }
95
+
96
+ function buildHorizontalMovementMap(dir) {
97
+ return dir === 'rtl'
98
+ ? { ArrowRight: 'previous', ArrowLeft: 'next' }
99
+ : { ArrowLeft: 'previous', ArrowRight: 'next' }
100
+ }
101
+
102
+ function buildVerticalNestedMap(dir) {
103
+ return dir === 'rtl'
104
+ ? { ArrowRight: 'collapse', ArrowLeft: 'expand' }
105
+ : { ArrowLeft: 'collapse', ArrowRight: 'expand' }
106
+ }
107
+
108
+ function buildNestedActions(isHorizontal, dir) {
109
+ if (isHorizontal) return { ArrowUp: 'collapse', ArrowDown: 'expand' }
110
+ return buildVerticalNestedMap(dir)
111
+ }
112
+
113
+ /**
114
+ * Creates a keyboard action mapping based on navigation options
115
+ *
116
+ * @param {Object} options - Navigation options
117
+ * @param {string} options.orientation - Whether navigation is horizontal or vertical
118
+ * @param {string} options.dir - Text direction ('ltr' or 'rtl')
119
+ * @param {boolean} options.nested - Whether navigation is nested
120
+ * @returns {Object} Mapping of keys to actions
121
+ */
122
+ export function createKeyboardActionMap(options) {
123
+ const { orientation, dir, nested } = options
124
+ const isHorizontal = orientation === 'horizontal'
125
+
126
+ const movementActions = isHorizontal
127
+ ? buildHorizontalMovementMap(dir)
128
+ : { ArrowUp: 'previous', ArrowDown: 'next' }
129
+
130
+ const nestedActions = nested ? buildNestedActions(isHorizontal, dir) : {}
131
+
132
+ const commonActions = {
133
+ Enter: 'select',
134
+ ' ': 'select',
135
+ Home: 'first',
136
+ End: 'last'
137
+ }
138
+
139
+ return { ...commonActions, ...movementActions, ...nestedActions }
140
+ }
141
+
142
+ /**
143
+ * Creates a keyboard action mapping for modifier keys based on navigation options
144
+ *
145
+ * @param {Object} options - Navigation options
146
+ * @param {string} options.orientation - Whether navigation is horizontal or vertical
147
+ * @returns {Object} Mapping of keys to actions
148
+ */
149
+ export function createModifierKeyboardActionMap(options) {
150
+ const isHorizontal = options.orientation === 'horizontal'
151
+ const common = { ' ': 'extend', Home: 'first', End: 'last' }
152
+ const directional = isHorizontal
153
+ ? { ArrowLeft: 'first', ArrowRight: 'last' }
154
+ : { ArrowUp: 'first', ArrowDown: 'last' }
155
+ return { ...common, ...directional }
156
+ }
157
+
158
+ /**
159
+ * Creates a keyboard action mapping for shift key combinations
160
+ *
161
+ * @returns {Object} Mapping of keys to actions
162
+ */
163
+ export function createShiftKeyboardActionMap() {
164
+ return { ' ': 'range' }
165
+ }
166
+
167
+ const KEY_LAYER_RESOLVERS = {
168
+ shift: (key, _opts) => createShiftKeyboardActionMap()[key] || null,
169
+ modifier: (key, opts) => createModifierKeyboardActionMap(opts)[key] || null,
170
+ plain: (key, opts) => createKeyboardActionMap(opts)[key] || null
171
+ }
172
+
173
+ function getKeyLayer(ctrlKey, metaKey, shiftKey) {
174
+ if (ctrlKey) return 'modifier'
175
+ if (metaKey) return 'modifier'
176
+ if (shiftKey) return 'shift'
177
+ return 'plain'
178
+ }
179
+
180
+ /**
181
+ * Gets the keyboard action for a key event
182
+ * @param {KeyboardEvent} event - The keyboard event
183
+ * @param {Object} options - Configuration options
184
+ * @returns {string|null} The action to perform, or null if no action is defined
185
+ */
186
+ export function getKeyboardAction(event, options = {}) {
187
+ const { key, ctrlKey, metaKey, shiftKey } = event
188
+ const mergedOptions = { ...defaultNavigationOptions, ...options }
189
+ const layer = getKeyLayer(ctrlKey, metaKey, shiftKey)
190
+ return KEY_LAYER_RESOLVERS[layer](key, mergedOptions)
191
+ }
@@ -0,0 +1,59 @@
1
+ import { on } from 'svelte/events'
2
+ import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'
3
+
4
+ /**
5
+ * Default key mappings
6
+ * @type {import('./types.js').KeyboardConfig}
7
+ */
8
+ const defaultKeyMappings = {
9
+ remove: ['Backspace', 'Delete'],
10
+ submit: ['Enter'],
11
+ add: /^[a-zA-Z]$/
12
+ }
13
+
14
+ /**
15
+ * Handle keyboard events
16
+ *
17
+ * @param {HTMLElement} root
18
+ * @param {import('./types.js').KeyboardConfig} options - Custom key mappings
19
+ */
20
+ export function keyboard(root, options = null) {
21
+ const keyMappings = options ?? defaultKeyMappings
22
+
23
+ /**
24
+ * Handle keyboard events
25
+ *
26
+ * @param {KeyboardEvent} event
27
+ */
28
+ const keyup = (event) => {
29
+ const { key } = event
30
+
31
+ const eventName = getEventForKey(keyMappings, key)
32
+ // verify that the target is a child of the root element?
33
+ if (eventName) {
34
+ root.dispatchEvent(new CustomEvent(eventName, { detail: key }))
35
+ }
36
+ }
37
+
38
+ const click = (event) => {
39
+ const node = getClosestAncestorWithAttribute(event.target, 'data-key')
40
+
41
+ if (node) {
42
+ const key = node.getAttribute('data-key')
43
+ const eventName = getEventForKey(keyMappings, key)
44
+
45
+ if (eventName) {
46
+ root.dispatchEvent(new CustomEvent(eventName, { detail: key }))
47
+ }
48
+ }
49
+ }
50
+
51
+ $effect(() => {
52
+ const cleanupKeyupEvent = on(root, 'keyup', keyup)
53
+ const cleanupClickEvent = on(root, 'click', click)
54
+ return () => {
55
+ cleanupKeyupEvent()
56
+ cleanupClickEvent()
57
+ }
58
+ })
59
+ }
package/src/keymap.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * List Keymap
3
+ *
4
+ * Maps keyboard inputs to semantic actions.
5
+ *
6
+ * Design principle: orientation is just a rotation of arrow key assignments.
7
+ *
8
+ * vertical ltr up/down = prev/next left/right = collapse/expand (when collapsible)
9
+ * vertical rtl up/down = prev/next right/left = collapse/expand (expand/collapse reversed)
10
+ * horizontal left/right = prev/next up/down = collapse/expand (dir ignored — use CSS flex-reverse for RTL)
11
+ *
12
+ * ─── Actions ────────────────────────────────────────────────────────────────
13
+ *
14
+ * next focus next visible item, skip disabled
15
+ * prev focus previous visible item, skip disabled
16
+ * first jump to first visible item
17
+ * last jump to last visible item
18
+ * expand when collapsible: expand collapsed group
19
+ * if already expanded: move focus to first child
20
+ * on leaf: no-op
21
+ * collapse when collapsible: collapse expanded group
22
+ * if already collapsed or leaf: move focus to parent
23
+ * at root level: no-op
24
+ * select activate the focused item
25
+ * extend toggle individual selection (multiselect ctrl/cmd + space)
26
+ * range select contiguous range (multiselect shift + space)
27
+ */
28
+
29
+ import { PLAIN_FIXED, CTRL_FIXED, SHIFT_FIXED, ARROWS } from './nav-constants.js'
30
+ export { ACTIONS } from './nav-constants.js'
31
+
32
+ function getArrows(orientation, dir) {
33
+ if (orientation === 'horizontal') return ARROWS.horizontal
34
+ return ARROWS[`vertical-${dir}`]
35
+ }
36
+
37
+ function buildPlainLayer(arrows, collapsible) {
38
+ return {
39
+ ...PLAIN_FIXED,
40
+ ...arrows.move,
41
+ ...(collapsible ? arrows.nested : {})
42
+ }
43
+ }
44
+
45
+ // ─── buildKeymap ──────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Build a complete keymap for the given options.
49
+ *
50
+ * Returns three layers — plain, shift, ctrl — each mapping key name → action name.
51
+ * Call resolveAction(event, keymap) to look up the action for a keyboard event.
52
+ *
53
+ * @param {Object} [options]
54
+ * @param {'vertical'|'horizontal'} [options.orientation='vertical']
55
+ * @param {'ltr'|'rtl'} [options.dir='ltr']
56
+ * @param {boolean} [options.collapsible=false]
57
+ * @returns {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }}
58
+ */
59
+ export function buildKeymap({ orientation = 'vertical', dir = 'ltr', collapsible = false } = {}) {
60
+ const arrows = getArrows(orientation, dir)
61
+
62
+ return {
63
+ plain: buildPlainLayer(arrows, collapsible),
64
+ shift: { ...SHIFT_FIXED },
65
+ ctrl: { ...CTRL_FIXED }
66
+ }
67
+ }
68
+
69
+ // ─── resolveAction ────────────────────────────────────────────────────────────
70
+
71
+ function pickLayer(shiftKey, ctrlKey, metaKey) {
72
+ if (ctrlKey) return 'ctrl'
73
+ if (metaKey) return 'ctrl'
74
+ if (shiftKey) return 'shift'
75
+ return 'plain'
76
+ }
77
+
78
+ /**
79
+ * Resolve the action for a keyboard event given a pre-built keymap.
80
+ * Returns null if the key has no binding.
81
+ *
82
+ * @param {KeyboardEvent} event
83
+ * @param {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }} keymap
84
+ * @returns {string|null}
85
+ */
86
+ export function resolveAction(event, keymap) {
87
+ const { key, ctrlKey, metaKey, shiftKey } = event
88
+ return keymap[pickLayer(shiftKey, ctrlKey, metaKey)][key] ?? null
89
+ }
@@ -1,3 +1,17 @@
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
+
1
15
  /**
2
16
  * EventManager class to manage event listeners on an element.
3
17
  *
@@ -5,18 +19,19 @@
5
19
  * @param {Object} handlers - An object with event names as keys and event handlers as values.
6
20
  * @returns {Object} - An object with methods to activate, reset, and update the event listeners.
7
21
  */
8
- export function EventManager(element, handlers = {}) {
9
- let listening = false
22
+ export function EventManager(element, handlers = {}, enabled = true) {
23
+ const registeredEvents = {}
24
+ const options = { handlers, enabled }
10
25
 
11
26
  /**
12
27
  * Activate the event listeners.
13
28
  */
14
29
  function activate() {
15
- if (!listening) {
16
- Object.entries(handlers).forEach(([event, handler]) =>
17
- element.addEventListener(event, handler)
18
- )
19
- listening = true
30
+ if (options.enabled) {
31
+ Object.entries(options.handlers).forEach(([event, handler]) => {
32
+ resetEvent(event, registeredEvents)
33
+ registeredEvents[event] = on(element, event, handler)
34
+ })
20
35
  }
21
36
  }
22
37
 
@@ -24,12 +39,9 @@ export function EventManager(element, handlers = {}) {
24
39
  * Reset the event listeners.
25
40
  */
26
41
  function reset() {
27
- if (listening) {
28
- Object.entries(handlers).forEach(([event, handler]) =>
29
- element.removeEventListener(event, handler)
30
- )
31
- listening = false
32
- }
42
+ Object.keys(registeredEvents).forEach((event) => {
43
+ resetEvent(event, registeredEvents)
44
+ })
33
45
  }
34
46
 
35
47
  /**
@@ -39,12 +51,17 @@ export function EventManager(element, handlers = {}) {
39
51
  * @param {boolean} enabled - Whether to enable or disable the event listeners.
40
52
  */
41
53
  function update(newHandlers = handlers, enabled = true) {
42
- if (listening !== enabled || handlers !== newHandlers) {
43
- reset()
44
- handlers = newHandlers
45
- if (enabled) activate()
54
+ const hasChanged = handlers !== newHandlers
55
+
56
+ if (!enabled) reset()
57
+
58
+ if (hasChanged) {
59
+ options.handlers = newHandlers
60
+ options.enabled = enabled
61
+ activate()
46
62
  }
47
63
  }
48
64
 
49
- return { activate, reset, update }
65
+ activate()
66
+ return { reset, update }
50
67
  }
package/src/lib/index.js CHANGED
@@ -1,5 +1,3 @@
1
- export { dimensionAttributes, defaultResizerOptions, defaultVirtualListOptions } from './constants'
2
1
  // skipcq: JS-E1004 - Needed for exposing all functions
3
2
  export * from './internal'
4
3
  export { EventManager } from './event-manager'
5
- export { virtualListViewport } from './viewport'
@@ -1,58 +1,3 @@
1
- import { compact, hasChildren, isExpanded } from '@rokkit/core'
2
-
3
- /**
4
- * Emits a custom event with the given data.
5
- *
6
- * @param {HTMLElement} element
7
- * @param {string} event
8
- * @param {*} data
9
- * @returns {void}
10
- */
11
- export function emit(element, event, data) {
12
- element.dispatchEvent(new CustomEvent(event, { detail: data }))
13
- }
14
-
15
- /**
16
- * Maps keyboard events to actions based on the given handlers and options.
17
- *
18
- * @param {import('../types').ActionHandlers} handlers
19
- * @param {import('../types').NavigationOptions} options
20
- * @returns {import('../types').KeyboardActions}
21
- */
22
- export function mapKeyboardEventsToActions(handlers, options) {
23
- const { next, previous, select, escape } = handlers
24
- const { horizontal, nested } = {
25
- horizontal: false,
26
- nested: false,
27
- ...options
28
- }
29
- const expand = nested ? handlers.expand : null
30
- const collapse = nested ? handlers.collapse : null
31
-
32
- return compact({
33
- ArrowDown: horizontal ? expand : next,
34
- ArrowUp: horizontal ? collapse : previous,
35
- ArrowRight: horizontal ? next : expand,
36
- ArrowLeft: horizontal ? previous : collapse,
37
- Enter: select,
38
- Escape: escape,
39
- ' ': select
40
- })
41
- }
42
-
43
- /**
44
- * Finds the closest ancestor of the given element that has the given attribute.
45
- *
46
- * @param {HTMLElement} element
47
- * @param {string} attribute
48
- * @returns {HTMLElement|null}
49
- */
50
- export function getClosestAncestorWithAttribute(element, attribute) {
51
- if (!element) return null
52
- if (element.getAttribute(attribute)) return element
53
- return getClosestAncestorWithAttribute(element.parentElement, attribute)
54
- }
55
-
56
1
  /**
57
2
  * Sets up event handlers based on the given options.
58
3
  * Returns whether or not the event handlers are listening.
@@ -86,100 +31,3 @@ export function removeListeners(element, listeners) {
86
31
  })
87
32
  }
88
33
  }
89
-
90
- /**
91
- * Handles the click event.
92
- * @param {HTMLElement} element - The root element.
93
- * @param {CurrentItem} current - A reference to the current Item
94
- * @returns {CurrentItem} The updated current item.
95
- */
96
- export function handleItemClick(element, current) {
97
- const { item, fields, position } = current
98
- const detail = { item, position }
99
-
100
- if (hasChildren(item, fields)) {
101
- if (isExpanded(item, fields)) {
102
- item[fields.isOpen] = false
103
- emit(element, 'collapse', detail)
104
- } else {
105
- item[fields.isOpen] = true
106
- emit(element, 'expand', detail)
107
- }
108
- } else {
109
- emit(element, 'select', detail)
110
- }
111
- return current
112
- }
113
-
114
- /**
115
- * Caclulates sum of array values between the given bounds.
116
- * If a value is null, the default size is used.
117
- *
118
- * @param {Array<number|null>} sizes
119
- * @param {number} lower
120
- * @param {number} upper
121
- * @param {number} [defaultSize]
122
- * @returns {number}
123
- */
124
- export function calculateSum(sizes, lower, upper, defaultSize = 40, gap = 0) {
125
- return (
126
- sizes
127
- .slice(lower, upper)
128
- .map((size) => size ?? defaultSize)
129
- .reduce((acc, size) => acc + size + gap, 0) - gap
130
- )
131
- }
132
-
133
- /**
134
- * Updates the sizes array with the given values.
135
- *
136
- * @param {Array<number|null>} sizes
137
- * @param {Array<number>} values
138
- * @param {number} [offset]
139
- * @returns {Array<number|null>}
140
- */
141
- export function updateSizes(sizes, values, offset = 0) {
142
- const result = [...sizes.slice(0, offset), ...values, ...sizes.slice(offset + values.length)]
143
-
144
- return result
145
- }
146
-
147
- /**
148
- * Adjusts the viewport to ensure that the bounds contain the given number of items.
149
- *
150
- * @param {import('../types').Bounds} current
151
- * @param {number} count
152
- * @param {number} visibleCount
153
- * @returns {import('../types').Bounds}
154
- */
155
- export function fixViewportForVisibileCount(current, count, visibleCount) {
156
- let { lower, upper } = current
157
- if (lower < 0) lower = 0
158
- if (lower + visibleCount > count) {
159
- upper = count
160
- lower = Math.max(0, upper - visibleCount)
161
- } else if (lower + visibleCount !== upper) {
162
- upper = lower + visibleCount
163
- }
164
- return { lower, upper }
165
- }
166
-
167
- /**
168
- * Adjusts the viewport to ensure the given index is visible.
169
- *
170
- * @param {number} index
171
- * @param {import('../types').Bounds} current
172
- * @param {number} visibleCount
173
- * @returns {import('../types').Bounds}
174
- */
175
- export function fitIndexInViewport(index, current, visibleCount) {
176
- let { lower, upper } = current
177
- if (index >= upper) {
178
- upper = index + 1
179
- lower = upper - visibleCount
180
- } else if (index < lower) {
181
- lower = index
182
- upper = lower + visibleCount
183
- }
184
- return { lower, upper }
185
- }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Magnetic action — element shifts subtly toward the cursor on hover.
3
+ * Calculates cursor offset from element center and translates proportionally.
4
+ *
5
+ * @param {HTMLElement} node
6
+ * @param {MagneticOptions} [options]
7
+ *
8
+ * @typedef {Object} MagneticOptions
9
+ * @property {number} [strength=0.3] Maximum displacement as fraction of element size (0–1)
10
+ * @property {number} [duration=300] Transition duration for return to center (ms)
11
+ */
12
+
13
+ function resolveMagneticOpts(options) {
14
+ return { strength: 0.3, duration: 300, ...options }
15
+ }
16
+
17
+ function isReducedMotion() {
18
+ return (
19
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
20
+ )
21
+ }
22
+
23
+ function applyMagnetic(node, opts) {
24
+ const originalTransform = node.style.transform
25
+ const originalTransition = node.style.transition
26
+
27
+ node.style.transition = `transform ${opts.duration}ms ease`
28
+
29
+ function onMove(e) {
30
+ const rect = node.getBoundingClientRect()
31
+ const centerX = rect.left + rect.width / 2
32
+ const centerY = rect.top + rect.height / 2
33
+
34
+ const offsetX = (e.clientX - centerX) * opts.strength
35
+ const offsetY = (e.clientY - centerY) * opts.strength
36
+
37
+ node.style.transition = 'none'
38
+ node.style.transform = `translate(${offsetX}px, ${offsetY}px)`
39
+ }
40
+
41
+ function onLeave() {
42
+ node.style.transition = `transform ${opts.duration}ms ease`
43
+ node.style.transform = originalTransform
44
+ }
45
+
46
+ node.addEventListener('mousemove', onMove)
47
+ node.addEventListener('mouseleave', onLeave)
48
+
49
+ return () => {
50
+ node.removeEventListener('mousemove', onMove)
51
+ node.removeEventListener('mouseleave', onLeave)
52
+ node.style.transform = originalTransform
53
+ node.style.transition = originalTransition
54
+ }
55
+ }
56
+
57
+ export function magnetic(node, options = {}) {
58
+ $effect(() => {
59
+ const opts = resolveMagneticOpts(options)
60
+ if (isReducedMotion()) return
61
+ return applyMagnetic(node, opts)
62
+ })
63
+ }