@rokkit/actions 1.0.0-next.137 → 1.0.0-next.139

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.
@@ -1,16 +1,4 @@
1
- /**
2
- * Hover lift action — adds translateY + elevated shadow on hover.
3
- * Sets transition on mount, applies transform + box-shadow on mouseenter, resets on mouseleave.
4
- *
5
- * @param {HTMLElement} node
6
- * @param {HoverLiftOptions} [options]
7
- *
8
- * @typedef {Object} HoverLiftOptions
9
- * @property {string} [distance='-0.25rem'] Translate distance on hover (negative = up)
10
- * @property {string} [shadow='0 10px 25px -5px rgba(0,0,0,0.1)'] Box shadow on hover
11
- * @property {number} [duration=200] Transition duration (ms)
12
- */
13
- export function hoverLift(node: HTMLElement, options?: HoverLiftOptions): void;
1
+ export function hoverLift(node: any, options?: {}): void;
14
2
  /**
15
3
  * Hover lift action — adds translateY + elevated shadow on hover.
16
4
  * Sets transition on mount, applies transform + box-shadow on mouseenter, resets on mouseleave.
@@ -1,15 +1,4 @@
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
- export function magnetic(node: HTMLElement, options?: MagneticOptions): void;
1
+ export function magnetic(node: any, options?: {}): void;
13
2
  /**
14
3
  * Magnetic action — element shifts subtly toward the cursor on hover.
15
4
  * Calculates cursor offset from element center and translates proportionally.
@@ -1,22 +1,4 @@
1
- /**
2
- * Scroll-triggered reveal action using IntersectionObserver.
3
- * Applies CSS transitions (opacity + translate) when element enters viewport.
4
- * When stagger > 0, applies reveal to each child element independently.
5
- *
6
- * @param {HTMLElement} node
7
- * @param {RevealOptions} [options]
8
- *
9
- * @typedef {Object} RevealOptions
10
- * @property {'up' | 'down' | 'left' | 'right' | 'none'} [direction='up'] Slide direction
11
- * @property {string} [distance='1.5rem'] Slide distance (CSS unit)
12
- * @property {number} [duration=600] Animation duration (ms)
13
- * @property {number} [delay=0] Delay before animation starts (ms)
14
- * @property {number} [stagger=0] Delay increment per child in ms (0 = disabled)
15
- * @property {boolean} [once=true] Only animate once
16
- * @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
17
- * @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
18
- */
19
- export function reveal(node: HTMLElement, options?: RevealOptions): void;
1
+ export function reveal(node: any, options?: {}): void;
20
2
  /**
21
3
  * Scroll-triggered reveal action using IntersectionObserver.
22
4
  * Applies CSS transitions (opacity + translate) when element enters viewport.
@@ -1,16 +1,4 @@
1
- /**
2
- * Ripple action — material-design inspired click ripple effect.
3
- * Appends a circular expanding span at click coordinates that scales and fades out.
4
- *
5
- * @param {HTMLElement} node
6
- * @param {RippleOptions} [options]
7
- *
8
- * @typedef {Object} RippleOptions
9
- * @property {string} [color='currentColor'] Ripple color
10
- * @property {number} [opacity=0.15] Ripple opacity
11
- * @property {number} [duration=500] Ripple animation duration (ms)
12
- */
13
- export function ripple(node: HTMLElement, options?: RippleOptions): void;
1
+ export function ripple(node: any, options?: {}): void;
14
2
  /**
15
3
  * Ripple action — material-design inspired click ripple effect.
16
4
  * Appends a circular expanding span at click coordinates that scales and fades out.
package/dist/trigger.d.ts CHANGED
@@ -1,26 +1,3 @@
1
- /**
2
- * Trigger
3
- *
4
- * Manages dropdown open/close from a trigger button.
5
- * Pairs with Navigator (on the dropdown) to form a complete dropdown component.
6
- *
7
- * Responsibilities:
8
- * - click on trigger → toggle open/close
9
- * - Enter / Space → toggle open/close
10
- * - ArrowDown → open (callback can focus first item)
11
- * - ArrowUp → open (callback can focus last item)
12
- * - Escape (document) → close + return focus to trigger
13
- * - Click outside (doc) → close
14
- *
15
- * Usage:
16
- * const trigger = new Trigger(triggerEl, containerEl, {
17
- * onopen: () => { isOpen = true },
18
- * onclose: () => { isOpen = false },
19
- * onlast: () => { wrapper.last(null) } // optional: ArrowUp opens at end
20
- * })
21
- * // …
22
- * trigger.destroy()
23
- */
24
1
  export class Trigger {
25
2
  /**
26
3
  * @param {HTMLElement} trigger — the trigger button element
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.137",
3
+ "version": "1.0.0-next.139",
4
4
  "description": "Contains generic actions that can be used in various components.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,46 +10,53 @@
10
10
  * @property {string} [shadow='0 10px 25px -5px rgba(0,0,0,0.1)'] Box shadow on hover
11
11
  * @property {number} [duration=200] Transition duration (ms)
12
12
  */
13
+
14
+ function resolveHoverLiftOpts(options) {
15
+ return {
16
+ distance: '-0.25rem',
17
+ shadow: '0 10px 25px -5px rgba(0,0,0,0.1)',
18
+ duration: 200,
19
+ ...options
20
+ }
21
+ }
22
+
23
+ function isReducedMotion() {
24
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
25
+ }
26
+
27
+ function applyHoverLift(node, opts) {
28
+ const originalTransform = node.style.transform
29
+ const originalBoxShadow = node.style.boxShadow
30
+ const originalTransition = node.style.transition
31
+
32
+ node.style.transition = `transform ${opts.duration}ms ease, box-shadow ${opts.duration}ms ease`
33
+
34
+ function onEnter() {
35
+ node.style.transform = `translateY(${opts.distance})`
36
+ node.style.boxShadow = opts.shadow
37
+ }
38
+
39
+ function onLeave() {
40
+ node.style.transform = originalTransform
41
+ node.style.boxShadow = originalBoxShadow
42
+ }
43
+
44
+ node.addEventListener('mouseenter', onEnter)
45
+ node.addEventListener('mouseleave', onLeave)
46
+
47
+ return () => {
48
+ node.removeEventListener('mouseenter', onEnter)
49
+ node.removeEventListener('mouseleave', onLeave)
50
+ node.style.transform = originalTransform
51
+ node.style.boxShadow = originalBoxShadow
52
+ node.style.transition = originalTransition
53
+ }
54
+ }
55
+
13
56
  export function hoverLift(node, options = {}) {
14
57
  $effect(() => {
15
- const opts = {
16
- distance: '-0.25rem',
17
- shadow: '0 10px 25px -5px rgba(0,0,0,0.1)',
18
- duration: 200,
19
- ...options
20
- }
21
-
22
- const reducedMotion =
23
- typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
24
-
25
- if (reducedMotion) return
26
-
27
- // Store original values
28
- const originalTransform = node.style.transform
29
- const originalBoxShadow = node.style.boxShadow
30
- const originalTransition = node.style.transition
31
-
32
- node.style.transition = `transform ${opts.duration}ms ease, box-shadow ${opts.duration}ms ease`
33
-
34
- function onEnter() {
35
- node.style.transform = `translateY(${opts.distance})`
36
- node.style.boxShadow = opts.shadow
37
- }
38
-
39
- function onLeave() {
40
- node.style.transform = originalTransform
41
- node.style.boxShadow = originalBoxShadow
42
- }
43
-
44
- node.addEventListener('mouseenter', onEnter)
45
- node.addEventListener('mouseleave', onLeave)
46
-
47
- return () => {
48
- node.removeEventListener('mouseenter', onEnter)
49
- node.removeEventListener('mouseleave', onLeave)
50
- node.style.transform = originalTransform
51
- node.style.boxShadow = originalBoxShadow
52
- node.style.transition = originalTransition
53
- }
58
+ const opts = resolveHoverLiftOpts(options)
59
+ if (isReducedMotion()) return
60
+ return applyHoverLift(node, opts)
54
61
  })
55
62
  }
package/src/kbd.js CHANGED
@@ -93,6 +93,23 @@ export function getKeyboardActions(options, handlers) {
93
93
  return { ...common, ...movement, ...expandCollapse }
94
94
  }
95
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
+
96
113
  /**
97
114
  * Creates a keyboard action mapping based on navigation options
98
115
  *
@@ -106,31 +123,12 @@ export function createKeyboardActionMap(options) {
106
123
  const { orientation, dir, nested } = options
107
124
  const isHorizontal = orientation === 'horizontal'
108
125
 
109
- // Define movement actions based on orientation and direction
110
- let movementActions = {}
111
- if (isHorizontal) {
112
- movementActions =
113
- dir === 'rtl'
114
- ? { ArrowRight: 'previous', ArrowLeft: 'next' }
115
- : { ArrowLeft: 'previous', ArrowRight: 'next' }
116
- } else {
117
- movementActions = { ArrowUp: 'previous', ArrowDown: 'next' }
118
- }
126
+ const movementActions = isHorizontal
127
+ ? buildHorizontalMovementMap(dir)
128
+ : { ArrowUp: 'previous', ArrowDown: 'next' }
119
129
 
120
- // Define expand/collapse actions for nested option
121
- let nestedActions = {}
122
- if (nested) {
123
- if (isHorizontal) {
124
- nestedActions = { ArrowUp: 'collapse', ArrowDown: 'expand' }
125
- } else {
126
- nestedActions =
127
- dir === 'rtl'
128
- ? { ArrowRight: 'collapse', ArrowLeft: 'expand' }
129
- : { ArrowLeft: 'collapse', ArrowRight: 'expand' }
130
- }
131
- }
130
+ const nestedActions = nested ? buildNestedActions(isHorizontal, dir) : {}
132
131
 
133
- // Common actions regardless of options
134
132
  const commonActions = {
135
133
  Enter: 'select',
136
134
  ' ': 'select',
@@ -138,12 +136,7 @@ export function createKeyboardActionMap(options) {
138
136
  End: 'last'
139
137
  }
140
138
 
141
- // Combine all possible actions
142
- return {
143
- ...commonActions,
144
- ...movementActions,
145
- ...nestedActions
146
- }
139
+ return { ...commonActions, ...movementActions, ...nestedActions }
147
140
  }
148
141
 
149
142
  /**
@@ -171,6 +164,19 @@ export function createShiftKeyboardActionMap() {
171
164
  return { ' ': 'range' }
172
165
  }
173
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
+
174
180
  /**
175
181
  * Gets the keyboard action for a key event
176
182
  * @param {KeyboardEvent} event - The keyboard event
@@ -179,25 +185,7 @@ export function createShiftKeyboardActionMap() {
179
185
  */
180
186
  export function getKeyboardAction(event, options = {}) {
181
187
  const { key, ctrlKey, metaKey, shiftKey } = event
182
-
183
- // Use updated options with defaults
184
188
  const mergedOptions = { ...defaultNavigationOptions, ...options }
185
-
186
- // Check for shift key (range selection)
187
- if (shiftKey && !ctrlKey && !metaKey) {
188
- const shiftMap = createShiftKeyboardActionMap()
189
- return shiftMap[key] || null
190
- }
191
-
192
- // Check for modifier keys (ctrl/cmd)
193
- if (ctrlKey || metaKey) {
194
- const modifierMap = createModifierKeyboardActionMap(mergedOptions)
195
- return modifierMap[key] || null
196
- }
197
-
198
- // Get the action map based on options
199
- const actionMap = createKeyboardActionMap(mergedOptions)
200
-
201
- // Return the action or null if no matching key
202
- return actionMap[key] || null
189
+ const layer = getKeyLayer(ctrlKey, metaKey, shiftKey)
190
+ return KEY_LAYER_RESOLVERS[layer](key, mergedOptions)
203
191
  }
package/src/keymap.js CHANGED
@@ -34,6 +34,14 @@ function getArrows(orientation, dir) {
34
34
  return ARROWS[`vertical-${dir}`]
35
35
  }
36
36
 
37
+ function buildPlainLayer(arrows, collapsible) {
38
+ return {
39
+ ...PLAIN_FIXED,
40
+ ...arrows.move,
41
+ ...(collapsible ? arrows.nested : {})
42
+ }
43
+ }
44
+
37
45
  // ─── buildKeymap ──────────────────────────────────────────────────────────────
38
46
 
39
47
  /**
@@ -52,11 +60,7 @@ export function buildKeymap({ orientation = 'vertical', dir = 'ltr', collapsible
52
60
  const arrows = getArrows(orientation, dir)
53
61
 
54
62
  return {
55
- plain: {
56
- ...PLAIN_FIXED,
57
- ...arrows.move,
58
- ...(collapsible ? arrows.nested : {})
59
- },
63
+ plain: buildPlainLayer(arrows, collapsible),
60
64
  shift: { ...SHIFT_FIXED },
61
65
  ctrl: { ...CTRL_FIXED }
62
66
  }
@@ -64,6 +68,13 @@ export function buildKeymap({ orientation = 'vertical', dir = 'ltr', collapsible
64
68
 
65
69
  // ─── resolveAction ────────────────────────────────────────────────────────────
66
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
+
67
78
  /**
68
79
  * Resolve the action for a keyboard event given a pre-built keymap.
69
80
  * Returns null if the key has no binding.
@@ -74,8 +85,5 @@ export function buildKeymap({ orientation = 'vertical', dir = 'ltr', collapsible
74
85
  */
75
86
  export function resolveAction(event, keymap) {
76
87
  const { key, ctrlKey, metaKey, shiftKey } = event
77
-
78
- if (shiftKey && !ctrlKey && !metaKey) return keymap.shift[key] ?? null
79
- if (ctrlKey || metaKey) return keymap.ctrl[key] ?? null
80
- return keymap.plain[key] ?? null
88
+ return keymap[pickLayer(shiftKey, ctrlKey, metaKey)][key] ?? null
81
89
  }
@@ -9,49 +9,53 @@
9
9
  * @property {number} [strength=0.3] Maximum displacement as fraction of element size (0–1)
10
10
  * @property {number} [duration=300] Transition duration for return to center (ms)
11
11
  */
12
- export function magnetic(node, options = {}) {
13
- $effect(() => {
14
- const opts = {
15
- strength: 0.3,
16
- duration: 300,
17
- ...options
18
- }
19
-
20
- const reducedMotion =
21
- typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
22
12
 
23
- if (reducedMotion) return
13
+ function resolveMagneticOpts(options) {
14
+ return { strength: 0.3, duration: 300, ...options }
15
+ }
24
16
 
25
- const originalTransform = node.style.transform
26
- const originalTransition = node.style.transition
17
+ function isReducedMotion() {
18
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
19
+ }
27
20
 
28
- node.style.transition = `transform ${opts.duration}ms ease`
21
+ function applyMagnetic(node, opts) {
22
+ const originalTransform = node.style.transform
23
+ const originalTransition = node.style.transition
29
24
 
30
- function onMove(e) {
31
- const rect = node.getBoundingClientRect()
32
- const centerX = rect.left + rect.width / 2
33
- const centerY = rect.top + rect.height / 2
25
+ node.style.transition = `transform ${opts.duration}ms ease`
34
26
 
35
- const offsetX = (e.clientX - centerX) * opts.strength
36
- const offsetY = (e.clientY - centerY) * opts.strength
27
+ function onMove(e) {
28
+ const rect = node.getBoundingClientRect()
29
+ const centerX = rect.left + rect.width / 2
30
+ const centerY = rect.top + rect.height / 2
37
31
 
38
- node.style.transition = 'none'
39
- node.style.transform = `translate(${offsetX}px, ${offsetY}px)`
40
- }
32
+ const offsetX = (e.clientX - centerX) * opts.strength
33
+ const offsetY = (e.clientY - centerY) * opts.strength
41
34
 
42
- function onLeave() {
43
- node.style.transition = `transform ${opts.duration}ms ease`
44
- node.style.transform = originalTransform
45
- }
35
+ node.style.transition = 'none'
36
+ node.style.transform = `translate(${offsetX}px, ${offsetY}px)`
37
+ }
46
38
 
47
- node.addEventListener('mousemove', onMove)
48
- node.addEventListener('mouseleave', onLeave)
39
+ function onLeave() {
40
+ node.style.transition = `transform ${opts.duration}ms ease`
41
+ node.style.transform = originalTransform
42
+ }
43
+
44
+ node.addEventListener('mousemove', onMove)
45
+ node.addEventListener('mouseleave', onLeave)
46
+
47
+ return () => {
48
+ node.removeEventListener('mousemove', onMove)
49
+ node.removeEventListener('mouseleave', onLeave)
50
+ node.style.transform = originalTransform
51
+ node.style.transition = originalTransition
52
+ }
53
+ }
49
54
 
50
- return () => {
51
- node.removeEventListener('mousemove', onMove)
52
- node.removeEventListener('mouseleave', onLeave)
53
- node.style.transform = originalTransform
54
- node.style.transition = originalTransition
55
- }
55
+ export function magnetic(node, options = {}) {
56
+ $effect(() => {
57
+ const opts = resolveMagneticOpts(options)
58
+ if (isReducedMotion()) return
59
+ return applyMagnetic(node, opts)
56
60
  })
57
61
  }
package/src/navigator.js CHANGED
@@ -38,9 +38,9 @@ import { buildKeymap, resolveAction } from './keymap.js'
38
38
  */
39
39
  function getClickAction(event) {
40
40
  const { shiftKey, ctrlKey, metaKey, target } = event
41
-
42
- if (shiftKey && !ctrlKey && !metaKey) return 'range'
43
- if (ctrlKey || metaKey) return 'extend'
41
+ if (shiftKey) return 'range'
42
+ if (ctrlKey) return 'extend'
43
+ if (metaKey) return 'extend'
44
44
  if (target.closest('[data-accordion-trigger]')) return 'toggle'
45
45
  return 'select'
46
46
  }
@@ -201,6 +201,25 @@ export class Navigator {
201
201
 
202
202
  // ─── Typeahead ───────────────────────────────────────────────────────────
203
203
 
204
+ #isPrintableKey(key, ctrlKey, metaKey, altKey) {
205
+ if (ctrlKey) return false
206
+ if (metaKey) return false
207
+ if (altKey) return false
208
+ if (key.length !== 1) return false
209
+ return key !== ' '
210
+ }
211
+
212
+ #appendBuffer(key) {
213
+ const startAfter = this.#buffer.length === 0 ? this.#wrapper.focusedKey : null
214
+ this.#buffer += key
215
+ if (this.#bufferTimer) {
216
+ clearTimeout(this.#bufferTimer)
217
+ this.#bufferTimer = null
218
+ }
219
+ this.#bufferTimer = setTimeout(() => this.#clearTypeahead(), TYPEAHEAD_RESET_MS)
220
+ return startAfter
221
+ }
222
+
204
223
  /**
205
224
  * Handle printable character keys for typeahead search.
206
225
  * Returns true if the event was consumed.
@@ -211,20 +230,9 @@ export class Navigator {
211
230
  #tryTypeahead(event) {
212
231
  const { key, ctrlKey, metaKey, altKey } = event
213
232
 
214
- // Only single printable characters, no modifier combos
215
- if (ctrlKey || metaKey || altKey) return false
216
- if (key.length !== 1) return false
217
- if (key === ' ') return false // Space is a keymap action, not typeahead
218
-
219
- const startAfter = this.#buffer.length === 0 ? this.#wrapper.focusedKey : null
220
- this.#buffer += key
233
+ if (!this.#isPrintableKey(key, ctrlKey, metaKey, altKey)) return false
221
234
 
222
- // Cancel the existing reset timer but keep the accumulated buffer
223
- if (this.#bufferTimer) {
224
- clearTimeout(this.#bufferTimer)
225
- this.#bufferTimer = null
226
- }
227
- this.#bufferTimer = setTimeout(() => this.#clearTypeahead(), TYPEAHEAD_RESET_MS)
235
+ const startAfter = this.#appendBuffer(key)
228
236
 
229
237
  const matchKey = this.#wrapper.findByText(this.#buffer, startAfter)
230
238
  if (matchKey !== null) {
@@ -99,6 +99,98 @@ function getHandlers(wrapper) {
99
99
  toggle: (path) => wrapper.toggleExpansion(path)
100
100
  }
101
101
  }
102
+
103
+ function isTypeaheadEvent(event) {
104
+ if (event.ctrlKey) return false
105
+ if (event.metaKey) return false
106
+ if (event.altKey) return false
107
+ if (event.key.length !== 1) return false
108
+ return event.key !== ' '
109
+ }
110
+
111
+ function dispatchFocusMoveEvent(node, wrapper) {
112
+ node.dispatchEvent(
113
+ new CustomEvent('action', {
114
+ detail: {
115
+ name: 'move',
116
+ data: { value: wrapper.focused, selected: wrapper.selected }
117
+ }
118
+ })
119
+ )
120
+ }
121
+
122
+ const SCROLL_ACTIONS = new Set(['first', 'last', 'previous', 'next', 'expand', 'collapse'])
123
+
124
+ function isNativeLink(action, target) {
125
+ return action === 'select' && Boolean(target.closest('a[href]'))
126
+ }
127
+
128
+ function notifyFocusMove(node, wrapper, action, prevKey) {
129
+ if (action !== 'expand' && action !== 'collapse') return false
130
+ if (wrapper.focusedKey === prevKey) return false
131
+ dispatchFocusMoveEvent(node, wrapper)
132
+ return true
133
+ }
134
+
135
+ function runTypeahead(config, wrapper, ta, event) {
136
+ if (!config.typeahead) return
137
+ if (!wrapper.findByText) return
138
+ ta.handle(event)
139
+ }
140
+
141
+ function makeKeydownHandler(ctx, config, handlers, ta) {
142
+ const { node, wrapper } = ctx
143
+ return (event) => {
144
+ const action = getKeyboardAction(event, config)
145
+ if (isNativeLink(action, event.target)) return
146
+
147
+ const prevKey = wrapper.focusedKey
148
+ const handled = handleAction(event, handlers[action])
149
+ if (!handled) { runTypeahead(config, wrapper, ta, event); return }
150
+
151
+ ta.reset()
152
+ emitAction(node, wrapper, action, true)
153
+ notifyFocusMove(node, wrapper, action, prevKey)
154
+ if (SCROLL_ACTIONS.has(action)) setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
155
+ }
156
+ }
157
+
158
+ function getTypeaheadStart(buffer, wrapper) {
159
+ if (buffer.length === 0) return wrapper.focusedKey
160
+ return null
161
+ }
162
+
163
+ function makeTypeahead(node, wrapper) {
164
+ let buffer = ''
165
+ let timer = null
166
+
167
+ function reset() {
168
+ buffer = ''
169
+ if (timer) { clearTimeout(timer); timer = null }
170
+ }
171
+
172
+ function handle(event) {
173
+ if (!isTypeaheadEvent(event)) return false
174
+
175
+ const startAfter = getTypeaheadStart(buffer, wrapper)
176
+ buffer += event.key
177
+ if (timer) clearTimeout(timer)
178
+ timer = setTimeout(reset, 500)
179
+
180
+ const matchKey = wrapper.findByText(buffer, startAfter)
181
+ if (matchKey === null) return false
182
+ if (!wrapper.moveTo(matchKey)) return false
183
+
184
+ event.preventDefault()
185
+ event.stopPropagation()
186
+ emitAction(node, wrapper, 'first', true)
187
+ setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
188
+ return true
189
+ }
190
+
191
+ return { reset, handle }
192
+ }
193
+
102
194
  /**
103
195
  * A svelte action function that captures keyboard evvents and emits event for corresponding movements.
104
196
  *
@@ -110,88 +202,13 @@ export function navigator(node, options) {
110
202
  const { wrapper } = options
111
203
  const config = { ...defaultNavigationOptions, ...omit(['wrapper'], options) }
112
204
  const handlers = getHandlers(wrapper)
113
-
114
- // Type-ahead state
115
- let typeaheadBuffer = ''
116
- let typeaheadTimer = null
117
-
118
- function resetTypeahead() {
119
- typeaheadBuffer = ''
120
- if (typeaheadTimer) {
121
- clearTimeout(typeaheadTimer)
122
- typeaheadTimer = null
123
- }
124
- }
125
-
126
- function handleTypeahead(event) {
127
- const { key, ctrlKey, metaKey, altKey } = event
128
- if (ctrlKey || metaKey || altKey) return false
129
- if (key.length !== 1 || key === ' ') return false
130
-
131
- // Single-char repeat: start after current to cycle through matches
132
- const startAfter = typeaheadBuffer.length === 0 ? wrapper.focusedKey : null
133
-
134
- typeaheadBuffer += key
135
- if (typeaheadTimer) clearTimeout(typeaheadTimer)
136
- typeaheadTimer = setTimeout(resetTypeahead, 500)
137
-
138
- const matchKey = wrapper.findByText(typeaheadBuffer, startAfter)
139
- if (matchKey !== null && wrapper.moveTo(matchKey)) {
140
- event.preventDefault()
141
- event.stopPropagation()
142
- emitAction(node, wrapper, 'first', true) // emit 'move'
143
- setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
144
- return true
145
- }
146
- return false
147
- }
148
-
149
- const handleKeydown = (event) => {
150
- const action = getKeyboardAction(event, config)
151
- const prevKey = wrapper.focusedKey
152
-
153
- // For activation keys (Enter/Space) on anchor elements, let the browser
154
- // navigate natively. The click handler will update controller state when
155
- // the browser fires the synthetic click.
156
- if (action === 'select' && event.target.closest('a[href]')) {
157
- return
158
- }
159
-
160
- const handled = handleAction(event, handlers[action])
161
- if (handled) {
162
- resetTypeahead()
163
- emitAction(node, wrapper, action, true)
164
- // If expand/collapse moved focus, also emit move so components update DOM focus
165
- const focusMoved = ['expand', 'collapse'].includes(action) && wrapper.focusedKey !== prevKey
166
- if (focusMoved) {
167
- node.dispatchEvent(
168
- new CustomEvent('action', {
169
- detail: {
170
- name: 'move',
171
- data: { value: wrapper.focused, selected: wrapper.selected }
172
- }
173
- })
174
- )
175
- }
176
- // Scroll focused element into view for navigation and focus-moving expand/collapse
177
- if (focusMoved || ['first', 'last', 'previous', 'next'].includes(action)) {
178
- setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
179
- }
180
- return
181
- }
182
-
183
- // Type-ahead: when no navigation action matched and typeahead is enabled
184
- if (config.typeahead && wrapper.findByText) {
185
- handleTypeahead(event)
186
- }
187
- }
205
+ const ta = makeTypeahead(node, wrapper)
206
+ const handleKeydown = makeKeydownHandler({ node, wrapper }, config, handlers, ta)
188
207
 
189
208
  const handleClick = (event) => {
190
209
  const action = getClickAction(event)
191
210
  const path = getPathFromEvent(event)
192
211
 
193
- // Anchor elements with href handle navigation natively — don't preventDefault.
194
- // Still call the handler so focus/selection state stays in sync.
195
212
  if (event.target.closest('a[href]')) {
196
213
  const handler = handlers[action]
197
214
  if (handler?.(path)) emitAction(node, options.wrapper, action)
@@ -204,10 +221,6 @@ export function navigator(node, options) {
204
221
 
205
222
  $effect(() => {
206
223
  const cleanup = [on(node, 'keydown', handleKeydown), on(node, 'click', handleClick)]
207
-
208
- return () => {
209
- resetTypeahead()
210
- cleanup.forEach((fn) => fn())
211
- }
224
+ return () => { ta.reset(); cleanup.forEach((fn) => fn()) }
212
225
  })
213
226
  }
@@ -16,114 +16,132 @@
16
16
  * @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
17
17
  * @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
18
18
  */
19
- export function reveal(node, options = {}) {
20
- $effect(() => {
21
- const opts = {
22
- direction: 'up',
23
- distance: '1.5rem',
24
- duration: 600,
25
- delay: 0,
26
- stagger: 0,
27
- once: true,
28
- threshold: 0.1,
29
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
30
- ...options
31
- }
32
19
 
33
- const reducedMotion =
34
- typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
20
+ function resolveRevealOpts(options) {
21
+ return {
22
+ direction: 'up',
23
+ distance: '1.5rem',
24
+ duration: 600,
25
+ delay: 0,
26
+ stagger: 0,
27
+ once: true,
28
+ threshold: 0.1,
29
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
30
+ ...options
31
+ }
32
+ }
35
33
 
36
- const isStagger = opts.stagger > 0
34
+ function applyReveal(el, opts) {
35
+ el.style.setProperty('--reveal-duration', `${opts.duration}ms`)
36
+ el.style.setProperty('--reveal-distance', opts.distance)
37
+ el.style.setProperty('--reveal-easing', opts.easing)
38
+ el.setAttribute('data-reveal', opts.direction)
39
+ }
37
40
 
38
- function applyReveal(el) {
39
- el.style.setProperty('--reveal-duration', `${opts.duration}ms`)
40
- el.style.setProperty('--reveal-distance', opts.distance)
41
- el.style.setProperty('--reveal-easing', opts.easing)
42
- el.setAttribute('data-reveal', opts.direction)
43
- }
41
+ function cleanReveal(el) {
42
+ el.removeAttribute('data-reveal')
43
+ el.removeAttribute('data-reveal-visible')
44
+ el.style.removeProperty('--reveal-duration')
45
+ el.style.removeProperty('--reveal-distance')
46
+ el.style.removeProperty('--reveal-easing')
47
+ el.style.removeProperty('transition-delay')
48
+ }
44
49
 
45
- function cleanReveal(el) {
46
- el.removeAttribute('data-reveal')
47
- el.removeAttribute('data-reveal-visible')
48
- el.style.removeProperty('--reveal-duration')
49
- el.style.removeProperty('--reveal-distance')
50
- el.style.removeProperty('--reveal-easing')
51
- el.style.removeProperty('transition-delay')
52
- }
50
+ function initRevealElements(node, opts, isStagger) {
51
+ if (isStagger) {
52
+ Array.from(node.children).forEach((child) => applyReveal(child, opts))
53
+ } else {
54
+ applyReveal(node, opts)
55
+ if (opts.delay > 0) node.style.transitionDelay = `${opts.delay}ms`
56
+ }
57
+ }
53
58
 
59
+ function handleReducedMotion(node, isStagger) {
60
+ if (isStagger) {
61
+ Array.from(node.children).forEach((child) => child.setAttribute('data-reveal-visible', ''))
62
+ } else {
63
+ node.setAttribute('data-reveal-visible', '')
64
+ }
65
+ node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
66
+ return () => {
54
67
  if (isStagger) {
55
- Array.from(node.children).forEach((child) => applyReveal(child))
68
+ Array.from(node.children).forEach((child) => cleanReveal(child))
56
69
  } else {
57
- applyReveal(node)
58
- if (opts.delay > 0) {
59
- node.style.transitionDelay = `${opts.delay}ms`
60
- }
70
+ cleanReveal(node)
61
71
  }
72
+ }
73
+ }
62
74
 
63
- if (reducedMotion) {
64
- if (isStagger) {
65
- Array.from(node.children).forEach((child) => child.setAttribute('data-reveal-visible', ''))
66
- } else {
67
- node.setAttribute('data-reveal-visible', '')
68
- }
69
- node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
70
- return () => {
71
- if (isStagger) {
72
- Array.from(node.children).forEach((child) => cleanReveal(child))
73
- } else {
74
- cleanReveal(node)
75
+ function handleIntersectEnter(node, opts, isStagger, timers) {
76
+ if (isStagger) {
77
+ timers.forEach((t) => clearTimeout(t))
78
+ const kids = Array.from(node.children)
79
+ const newTimers = kids.map((child, i) => {
80
+ if (!child.hasAttribute('data-reveal')) applyReveal(child, opts)
81
+ return setTimeout(
82
+ () => child.setAttribute('data-reveal-visible', ''),
83
+ opts.delay + i * opts.stagger
84
+ )
85
+ })
86
+ return newTimers
87
+ }
88
+ node.setAttribute('data-reveal-visible', '')
89
+ return timers
90
+ }
91
+
92
+ function handleIntersectLeave(node, isStagger, timers) {
93
+ if (isStagger) {
94
+ timers.forEach((t) => clearTimeout(t))
95
+ Array.from(node.children).forEach((child) => child.removeAttribute('data-reveal-visible'))
96
+ return []
97
+ }
98
+ node.removeAttribute('data-reveal-visible')
99
+ return timers
100
+ }
101
+
102
+ function buildObserver(node, opts, isStagger) {
103
+ let timers = []
104
+
105
+ const observer = new IntersectionObserver(
106
+ (entries) => {
107
+ for (const entry of entries) {
108
+ if (entry.isIntersecting) {
109
+ timers = handleIntersectEnter(node, opts, isStagger, timers)
110
+ node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
111
+ if (opts.once) observer.unobserve(node)
112
+ } else if (!opts.once) {
113
+ timers = handleIntersectLeave(node, isStagger, timers)
114
+ node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: false } }))
75
115
  }
76
116
  }
117
+ },
118
+ { threshold: opts.threshold }
119
+ )
120
+
121
+ observer.observe(node)
122
+
123
+ return () => {
124
+ timers.forEach((t) => clearTimeout(t))
125
+ observer.disconnect()
126
+ if (isStagger) {
127
+ Array.from(node.children).forEach((child) => cleanReveal(child))
128
+ } else {
129
+ cleanReveal(node)
77
130
  }
131
+ }
132
+ }
78
133
 
79
- let timers = []
134
+ export function reveal(node, options = {}) {
135
+ $effect(() => {
136
+ const opts = resolveRevealOpts(options)
137
+ const reducedMotion =
138
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
139
+ const isStagger = opts.stagger > 0
80
140
 
81
- const observer = new IntersectionObserver(
82
- (entries) => {
83
- for (const entry of entries) {
84
- if (entry.isIntersecting) {
85
- if (isStagger) {
86
- timers.forEach((t) => clearTimeout(t))
87
- const kids = Array.from(node.children)
88
- timers = kids.map((child, i) => {
89
- if (!child.hasAttribute('data-reveal')) applyReveal(child)
90
- return setTimeout(
91
- () => child.setAttribute('data-reveal-visible', ''),
92
- opts.delay + i * opts.stagger
93
- )
94
- })
95
- } else {
96
- node.setAttribute('data-reveal-visible', '')
97
- }
98
- node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
99
- if (opts.once) observer.unobserve(node)
100
- } else if (!opts.once) {
101
- if (isStagger) {
102
- timers.forEach((t) => clearTimeout(t))
103
- timers = []
104
- Array.from(node.children).forEach((child) =>
105
- child.removeAttribute('data-reveal-visible')
106
- )
107
- } else {
108
- node.removeAttribute('data-reveal-visible')
109
- }
110
- node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: false } }))
111
- }
112
- }
113
- },
114
- { threshold: opts.threshold }
115
- )
141
+ initRevealElements(node, opts, isStagger)
116
142
 
117
- observer.observe(node)
143
+ if (reducedMotion) return handleReducedMotion(node, isStagger)
118
144
 
119
- return () => {
120
- timers.forEach((t) => clearTimeout(t))
121
- observer.disconnect()
122
- if (isStagger) {
123
- Array.from(node.children).forEach((child) => cleanReveal(child))
124
- } else {
125
- cleanReveal(node)
126
- }
127
- }
145
+ return buildObserver(node, opts, isStagger)
128
146
  })
129
147
  }
@@ -10,78 +10,79 @@
10
10
  * @property {number} [opacity=0.15] Ripple opacity
11
11
  * @property {number} [duration=500] Ripple animation duration (ms)
12
12
  */
13
- export function ripple(node, options = {}) {
14
- $effect(() => {
15
- const opts = {
16
- color: 'currentColor',
17
- opacity: 0.15,
18
- duration: 500,
19
- ...options
20
- }
21
13
 
22
- const reducedMotion =
23
- typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
14
+ function resolveRippleOpts(options) {
15
+ return { color: 'currentColor', opacity: 0.15, duration: 500, ...options }
16
+ }
24
17
 
25
- if (reducedMotion) return
18
+ function isReducedMotion() {
19
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
20
+ }
26
21
 
27
- // Ensure host can contain the ripple
28
- const originalOverflow = node.style.overflow
29
- const originalPosition = node.style.position
30
- const computed = getComputedStyle(node).position
31
- if (!computed || computed === 'static') {
32
- node.style.position = 'relative'
22
+ function injectRippleKeyframes() {
23
+ if (document.querySelector('#rokkit-ripple-keyframes')) return
24
+ const style = document.createElement('style')
25
+ style.id = 'rokkit-ripple-keyframes'
26
+ style.textContent = `
27
+ @keyframes rokkit-ripple {
28
+ to {
29
+ transform: scale(1);
30
+ opacity: 0;
31
+ }
33
32
  }
34
- node.style.overflow = 'hidden'
33
+ `
34
+ document.head.appendChild(style)
35
+ }
35
36
 
36
- function onClick(e) {
37
- const rect = node.getBoundingClientRect()
38
- const size = Math.max(rect.width, rect.height) * 2
39
- const x = e.clientX - rect.left - size / 2
40
- const y = e.clientY - rect.top - size / 2
37
+ function createRippleSpan(e, node, opts) {
38
+ const rect = node.getBoundingClientRect()
39
+ const size = Math.max(rect.width, rect.height) * 2
40
+ const x = e.clientX - rect.left - size / 2
41
+ const y = e.clientY - rect.top - size / 2
41
42
 
42
- const span = document.createElement('span')
43
- span.style.position = 'absolute'
44
- span.style.left = `${x}px`
45
- span.style.top = `${y}px`
46
- span.style.width = `${size}px`
47
- span.style.height = `${size}px`
48
- span.style.borderRadius = '50%'
49
- span.style.background = opts.color
50
- span.style.opacity = String(opts.opacity)
51
- span.style.transform = 'scale(0)'
52
- span.style.pointerEvents = 'none'
53
- span.style.animation = `rokkit-ripple ${opts.duration}ms ease-out forwards`
43
+ const span = document.createElement('span')
44
+ span.style.position = 'absolute'
45
+ span.style.left = `${x}px`
46
+ span.style.top = `${y}px`
47
+ span.style.width = `${size}px`
48
+ span.style.height = `${size}px`
49
+ span.style.borderRadius = '50%'
50
+ span.style.background = opts.color
51
+ span.style.opacity = String(opts.opacity)
52
+ span.style.transform = 'scale(0)'
53
+ span.style.pointerEvents = 'none'
54
+ span.style.animation = `rokkit-ripple ${opts.duration}ms ease-out forwards`
55
+ return span
56
+ }
54
57
 
55
- node.appendChild(span)
56
- span.addEventListener('animationend', () => span.remove(), { once: true })
58
+ function applyRipple(node, opts) {
59
+ const originalOverflow = node.style.overflow
60
+ const originalPosition = node.style.position
61
+ const computed = getComputedStyle(node).position
62
+ if (!computed || computed === 'static') node.style.position = 'relative'
63
+ node.style.overflow = 'hidden'
57
64
 
58
- // Fallback removal in case animationend doesn't fire (JSDOM)
59
- setTimeout(() => {
60
- if (span.parentNode) span.remove()
61
- }, opts.duration + 100)
62
- }
65
+ function onClick(e) {
66
+ const span = createRippleSpan(e, node, opts)
67
+ node.appendChild(span)
68
+ span.addEventListener('animationend', () => span.remove(), { once: true })
69
+ setTimeout(() => { if (span.parentNode) span.remove() }, opts.duration + 100)
70
+ }
63
71
 
64
- // Inject keyframes if not already present
65
- if (!document.querySelector('#rokkit-ripple-keyframes')) {
66
- const style = document.createElement('style')
67
- style.id = 'rokkit-ripple-keyframes'
68
- style.textContent = `
69
- @keyframes rokkit-ripple {
70
- to {
71
- transform: scale(1);
72
- opacity: 0;
73
- }
74
- }
75
- `
76
- document.head.appendChild(style)
77
- }
72
+ injectRippleKeyframes()
73
+ node.addEventListener('click', onClick)
78
74
 
79
- node.addEventListener('click', onClick)
75
+ return () => {
76
+ node.removeEventListener('click', onClick)
77
+ node.style.overflow = originalOverflow
78
+ node.style.position = originalPosition
79
+ }
80
+ }
80
81
 
81
- return () => {
82
- node.removeEventListener('click', onClick)
83
- node.style.overflow = originalOverflow
84
- node.style.position = originalPosition
85
- }
82
+ export function ripple(node, options = {}) {
83
+ $effect(() => {
84
+ const opts = resolveRippleOpts(options)
85
+ if (isReducedMotion()) return
86
+ return applyRipple(node, opts)
86
87
  })
87
88
  }
package/src/trigger.js CHANGED
@@ -22,6 +22,20 @@
22
22
  * trigger.destroy()
23
23
  */
24
24
 
25
+ function handleToggleKey(self) {
26
+ if (self.isOpen) self.close()
27
+ else self.open()
28
+ }
29
+
30
+ function handleArrowDown(self) {
31
+ self.open()
32
+ }
33
+
34
+ function handleArrowUp(self, onlast) {
35
+ self.open()
36
+ onlast?.()
37
+ }
38
+
25
39
  export class Trigger {
26
40
  #trigger
27
41
  #container
@@ -78,15 +92,13 @@ export class Trigger {
78
92
  const { key } = event
79
93
  if (key === 'Enter' || key === ' ') {
80
94
  event.preventDefault()
81
- if (this.isOpen) this.close()
82
- else this.open()
95
+ handleToggleKey(this)
83
96
  } else if (key === 'ArrowDown') {
84
97
  event.preventDefault()
85
- this.open()
98
+ handleArrowDown(this)
86
99
  } else if (key === 'ArrowUp') {
87
100
  event.preventDefault()
88
- this.open()
89
- this.#onlast?.()
101
+ handleArrowUp(this, this.#onlast)
90
102
  }
91
103
  }
92
104
 
package/src/utils.js CHANGED
@@ -125,6 +125,10 @@ function isAccordionTrigger(target) {
125
125
  }
126
126
  // getKeyboardAction moved to kbd.js
127
127
 
128
+ function isToggleTarget(target) {
129
+ return isNodeToggle(target) || isNodeToggle(target.parentElement) || isAccordionTrigger(target)
130
+ }
131
+
128
132
  /**
129
133
  * Determines an action based on a click event
130
134
  *
@@ -134,26 +138,9 @@ function isAccordionTrigger(target) {
134
138
  export const getClickAction = (event) => {
135
139
  const { ctrlKey, metaKey, shiftKey, target } = event
136
140
 
137
- // Check for shift key first (range selection)
138
- if (shiftKey && !ctrlKey && !metaKey) {
139
- return 'range'
140
- }
141
-
142
- // Check for modifier keys (toggle selection)
143
- if (ctrlKey || metaKey) {
144
- return 'extend'
145
- }
146
-
147
- // Check if clicked on icon with collapsed/expanded state
148
- if (isNodeToggle(target) || isNodeToggle(target.parentElement)) {
149
- return 'toggle'
150
- }
151
-
152
- // Check if clicked on accordion trigger (header area)
153
- if (isAccordionTrigger(target)) {
154
- return 'toggle'
155
- }
156
-
157
- // Default action
141
+ if (shiftKey) return 'range'
142
+ if (ctrlKey) return 'extend'
143
+ if (metaKey) return 'extend'
144
+ if (isToggleTarget(target)) return 'toggle'
158
145
  return 'select'
159
146
  }