@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/README.md +189 -1
- package/package.json +20 -31
- package/src/{delegate.js → delegate.svelte.js} +10 -10
- package/src/{dismissable.js → dismissable.svelte.js} +11 -11
- package/src/{fillable.js → fillable.svelte.js} +54 -45
- package/src/hover-lift.svelte.js +64 -0
- package/src/index.js +19 -12
- package/src/kbd.js +191 -0
- package/src/keyboard.svelte.js +59 -0
- package/src/keymap.js +89 -0
- package/src/lib/event-manager.js +35 -18
- package/src/lib/index.js +0 -2
- package/src/lib/internal.js +0 -152
- package/src/magnetic.svelte.js +63 -0
- package/src/nav-constants.js +61 -0
- package/src/navigable.svelte.js +40 -0
- package/src/navigator.js +241 -151
- package/src/navigator.svelte.js +235 -0
- package/src/{pannable.js → pannable.svelte.js} +38 -39
- package/src/reveal.svelte.js +147 -0
- package/src/ripple.svelte.js +92 -0
- package/src/skinnable.svelte.js +12 -0
- package/src/{swipeable.js → swipeable.svelte.js} +79 -89
- package/src/themable.svelte.js +46 -0
- package/src/tooltip.svelte.js +149 -0
- package/src/trigger.js +126 -0
- package/src/types.js +39 -108
- package/src/utils.js +137 -15
- package/LICENSE +0 -21
- package/src/hierarchy.js +0 -156
- package/src/lib/constants.js +0 -35
- package/src/lib/viewport.js +0 -123
- package/src/navigable.js +0 -46
- package/src/switchable.js +0 -52
- package/src/themeable.js +0 -42
- package/src/traversable.js +0 -385
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
destroy: () => removeListeners(node, listeners.primary)
|
|
51
|
+
listeners.primary = {
|
|
52
|
+
mousedown: start,
|
|
53
|
+
touchstart: start
|
|
42
54
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|