@rokkit/actions 1.0.0-next.99 → 1.0.1

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.
@@ -0,0 +1,235 @@
1
+ import { on } from 'svelte/events'
2
+ import { omit } from 'ramda'
3
+ import { getKeyboardAction, defaultNavigationOptions } from './kbd'
4
+ import { getClickAction, getPathFromEvent } from './utils'
5
+
6
+ /**
7
+ * Scrolls the focused element into view if it exists
8
+ * @param {HTMLElement} container - The container element with the navigator action
9
+ * @param {*} wrapper - The controller/wrapper with focusedKey
10
+ */
11
+ function scrollFocusedIntoView(container, wrapper) {
12
+ let focusedElement = null
13
+
14
+ // Use focusedKey if available (most reliable)
15
+ if (wrapper.focusedKey) {
16
+ focusedElement = container.querySelector(`[data-path="${wrapper.focusedKey}"]`)
17
+ }
18
+
19
+ // Fallback: find by aria-current
20
+ if (!focusedElement) {
21
+ focusedElement = container.querySelector('[aria-current="true"]')
22
+ }
23
+
24
+ // Scroll into view if element found and method exists (may not exist in test env)
25
+ if (focusedElement?.scrollIntoView) {
26
+ focusedElement.scrollIntoView({
27
+ behavior: 'smooth',
28
+ block: 'nearest',
29
+ inline: 'nearest'
30
+ })
31
+ }
32
+ }
33
+
34
+ const EVENT_MAP = {
35
+ first: ['move'],
36
+ last: ['move'],
37
+ previous: ['move'],
38
+ next: ['move'],
39
+ select: ['move', 'select'],
40
+ extend: ['move', 'select'],
41
+ range: ['move', 'select'],
42
+ collapse: ['toggle'],
43
+ expand: ['toggle'],
44
+ toggle: ['toggle']
45
+ }
46
+
47
+ /**
48
+ * The last only indicates that if there is an array only the last event is fired.
49
+ * This is crucial because a click event needs to fire both move and select,
50
+ * however the keyboard should only fire the select event because we are already
51
+ * on the current item
52
+ *
53
+ * @param {HTMLElement} root
54
+ * @param {*} controller
55
+ * @param {*} name
56
+ */
57
+ export function emitAction(root, controller, name, lastOnly = false) {
58
+ const events = lastOnly ? EVENT_MAP[name].slice(-1) : EVENT_MAP[name]
59
+
60
+ events.forEach((event) => {
61
+ root.dispatchEvent(
62
+ new CustomEvent('action', {
63
+ detail: {
64
+ name: event,
65
+ data: { value: controller.focused, selected: controller.selected }
66
+ }
67
+ })
68
+ )
69
+ })
70
+ }
71
+
72
+ /*
73
+ * Generic action handler for keyboard events.
74
+ *
75
+ * @param {Record<string, () => void>} actions
76
+ * @param {KeyboardEvent} event
77
+ */
78
+ export function handleAction(event, handler, path) {
79
+ if (handler) {
80
+ event.preventDefault()
81
+ event.stopPropagation()
82
+
83
+ return handler(path)
84
+ }
85
+ return false
86
+ }
87
+
88
+ function getHandlers(wrapper) {
89
+ return {
90
+ first: () => wrapper.moveFirst(),
91
+ last: () => wrapper.moveLast(),
92
+ previous: () => wrapper.movePrev(),
93
+ next: () => wrapper.moveNext(),
94
+ collapse: () => wrapper.collapse(),
95
+ expand: () => wrapper.expand(),
96
+ select: (path) => wrapper.select(path),
97
+ extend: (path) => wrapper.extendSelection(path),
98
+ range: (path) => wrapper.selectRange(path),
99
+ toggle: (path) => wrapper.toggleExpansion(path)
100
+ }
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) {
150
+ runTypeahead(config, wrapper, ta, event)
151
+ return
152
+ }
153
+
154
+ ta.reset()
155
+ emitAction(node, wrapper, action, true)
156
+ notifyFocusMove(node, wrapper, action, prevKey)
157
+ if (SCROLL_ACTIONS.has(action)) setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
158
+ }
159
+ }
160
+
161
+ function getTypeaheadStart(buffer, wrapper) {
162
+ if (buffer.length === 0) return wrapper.focusedKey
163
+ return null
164
+ }
165
+
166
+ function makeTypeahead(node, wrapper) {
167
+ let buffer = ''
168
+ let timer = null
169
+
170
+ function reset() {
171
+ buffer = ''
172
+ if (timer) {
173
+ clearTimeout(timer)
174
+ timer = null
175
+ }
176
+ }
177
+
178
+ function handle(event) {
179
+ if (!isTypeaheadEvent(event)) return false
180
+
181
+ const startAfter = getTypeaheadStart(buffer, wrapper)
182
+ buffer += event.key
183
+ if (timer) clearTimeout(timer)
184
+ timer = setTimeout(reset, 500)
185
+
186
+ const matchKey = wrapper.findByText(buffer, startAfter)
187
+ if (matchKey === null) return false
188
+ if (!wrapper.moveTo(matchKey)) return false
189
+
190
+ event.preventDefault()
191
+ event.stopPropagation()
192
+ emitAction(node, wrapper, 'first', true)
193
+ setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
194
+ return true
195
+ }
196
+
197
+ return { reset, handle }
198
+ }
199
+
200
+ /**
201
+ * A svelte action function that captures keyboard evvents and emits event for corresponding movements.
202
+ *
203
+ * @param {HTMLElement} node
204
+ * @param {import('./types').NavigableOptions} options
205
+ * @returns {import('./types').SvelteActionReturn}
206
+ */
207
+ export function navigator(node, options) {
208
+ const { wrapper } = options
209
+ const config = { ...defaultNavigationOptions, ...omit(['wrapper'], options) }
210
+ const handlers = getHandlers(wrapper)
211
+ const ta = makeTypeahead(node, wrapper)
212
+ const handleKeydown = makeKeydownHandler({ node, wrapper }, config, handlers, ta)
213
+
214
+ const handleClick = (event) => {
215
+ const action = getClickAction(event)
216
+ const path = getPathFromEvent(event)
217
+
218
+ if (event.target.closest('a[href]')) {
219
+ const handler = handlers[action]
220
+ if (handler?.(path)) emitAction(node, options.wrapper, action)
221
+ return
222
+ }
223
+
224
+ const handled = handleAction(event, handlers[action], path)
225
+ if (handled) emitAction(node, options.wrapper, action)
226
+ }
227
+
228
+ $effect(() => {
229
+ const cleanup = [on(node, 'keydown', handleKeydown), on(node, 'click', handleClick)]
230
+ return () => {
231
+ ta.reset()
232
+ cleanup.forEach((fn) => fn())
233
+ }
234
+ })
235
+ }
@@ -1,25 +1,38 @@
1
1
  import { omit } from 'ramda'
2
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
+
3
28
  /**
4
29
  * Makes an element pannable with mouse or touch events.
5
30
  *
6
31
  * @param {HTMLElement} node The DOM element to apply the panning action.
7
- * @returns {import('./types').SvelteActionReturn}
8
32
  */
9
33
  export function pannable(node) {
10
34
  let coords = { x: 0, y: 0 }
11
- const listeners = {
12
- primary: {
13
- mousedown: start,
14
- touchstart: start
15
- },
16
- secondary: {
17
- mousemove: move,
18
- mouseup: stop,
19
- touchmove: move,
20
- touchend: stop
21
- }
22
- }
35
+ const listeners = { primary: {}, secondary: {} }
23
36
 
24
37
  function start(event) {
25
38
  coords = handleEvent(node, event, 'panstart', coords)
@@ -35,33 +48,19 @@ export function pannable(node) {
35
48
  removeListeners(window, listeners.secondary)
36
49
  }
37
50
 
38
- setupListeners(node, listeners.primary)
39
-
40
- return {
41
- destroy: () => removeListeners(node, listeners.primary)
51
+ listeners.primary = {
52
+ mousedown: start,
53
+ touchstart: start
42
54
  }
43
- }
44
-
45
- /**
46
- * Handles the panning event.
47
- *
48
- * @param {HTMLElement} node - The node where the event is dispatched.
49
- * @param {Event} event - The event object.
50
- * @param {string} name - The name of the event.
51
- * @param {import('./types').Coords} coords - The previous coordinates of the event.
52
- */
53
- function handleEvent(node, event, name, coords) {
54
- const x = event.clientX || event.touches[0].clientX
55
- const y = event.clientY || event.touches[0].clientY
56
- const detail = { x, y }
57
-
58
- if (name === 'panmove') {
59
- detail.dx = x - coords.x
60
- detail.dy = y - coords.y
55
+ listeners.secondary = {
56
+ mousemove: move,
57
+ mouseup: stop,
58
+ touchmove: move,
59
+ touchend: stop
61
60
  }
62
61
 
63
- event.stopPropagation()
64
- event.preventDefault()
65
- node.dispatchEvent(new CustomEvent(name, { detail }))
66
- return omit(['dx', 'dy'], detail)
62
+ $effect(() => {
63
+ setupListeners(node, listeners.primary)
64
+ return () => removeListeners(node, listeners.primary)
65
+ })
67
66
  }
@@ -0,0 +1,147 @@
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
+
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
+ }
33
+
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
+ }
40
+
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
+ }
49
+
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
+ }
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 () => {
67
+ if (isStagger) {
68
+ Array.from(node.children).forEach((child) => cleanReveal(child))
69
+ } else {
70
+ cleanReveal(node)
71
+ }
72
+ }
73
+ }
74
+
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 } }))
115
+ }
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)
130
+ }
131
+ }
132
+ }
133
+
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
140
+
141
+ initRevealElements(node, opts, isStagger)
142
+
143
+ if (reducedMotion) return handleReducedMotion(node, isStagger)
144
+
145
+ return buildObserver(node, opts, isStagger)
146
+ })
147
+ }
@@ -0,0 +1,92 @@
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
+
14
+ function resolveRippleOpts(options) {
15
+ return { color: 'currentColor', opacity: 0.15, duration: 500, ...options }
16
+ }
17
+
18
+ function isReducedMotion() {
19
+ return (
20
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
21
+ )
22
+ }
23
+
24
+ function injectRippleKeyframes() {
25
+ if (document.querySelector('#rokkit-ripple-keyframes')) return
26
+ const style = document.createElement('style')
27
+ style.id = 'rokkit-ripple-keyframes'
28
+ style.textContent = `
29
+ @keyframes rokkit-ripple {
30
+ to {
31
+ transform: scale(1);
32
+ opacity: 0;
33
+ }
34
+ }
35
+ `
36
+ document.head.appendChild(style)
37
+ }
38
+
39
+ function createRippleSpan(e, node, opts) {
40
+ const rect = node.getBoundingClientRect()
41
+ const size = Math.max(rect.width, rect.height) * 2
42
+ const x = e.clientX - rect.left - size / 2
43
+ const y = e.clientY - rect.top - size / 2
44
+
45
+ const span = document.createElement('span')
46
+ span.style.position = 'absolute'
47
+ span.style.left = `${x}px`
48
+ span.style.top = `${y}px`
49
+ span.style.width = `${size}px`
50
+ span.style.height = `${size}px`
51
+ span.style.borderRadius = '50%'
52
+ span.style.background = opts.color
53
+ span.style.opacity = String(opts.opacity)
54
+ span.style.transform = 'scale(0)'
55
+ span.style.pointerEvents = 'none'
56
+ span.style.animation = `rokkit-ripple ${opts.duration}ms ease-out forwards`
57
+ return span
58
+ }
59
+
60
+ function applyRipple(node, opts) {
61
+ const originalOverflow = node.style.overflow
62
+ const originalPosition = node.style.position
63
+ const computed = getComputedStyle(node).position
64
+ if (!computed || computed === 'static') node.style.position = 'relative'
65
+ node.style.overflow = 'hidden'
66
+
67
+ function onClick(e) {
68
+ const span = createRippleSpan(e, node, opts)
69
+ node.appendChild(span)
70
+ span.addEventListener('animationend', () => span.remove(), { once: true })
71
+ setTimeout(() => {
72
+ if (span.parentNode) span.remove()
73
+ }, opts.duration + 100)
74
+ }
75
+
76
+ injectRippleKeyframes()
77
+ node.addEventListener('click', onClick)
78
+
79
+ return () => {
80
+ node.removeEventListener('click', onClick)
81
+ node.style.overflow = originalOverflow
82
+ node.style.position = originalPosition
83
+ }
84
+ }
85
+
86
+ export function ripple(node, options = {}) {
87
+ $effect(() => {
88
+ const opts = resolveRippleOpts(options)
89
+ if (isReducedMotion()) return
90
+ return applyRipple(node, opts)
91
+ })
92
+ }
@@ -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
+ }