@rokkit/actions 1.0.0-next.125 → 1.0.0-next.127
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/dist/hover-lift.svelte.d.ts +31 -0
- package/dist/index.d.ts +4 -0
- package/dist/kbd.d.ts +7 -0
- package/dist/magnetic.svelte.d.ts +26 -0
- package/dist/reveal.svelte.d.ts +51 -0
- package/dist/ripple.svelte.d.ts +31 -0
- package/package.json +2 -2
- package/src/hover-lift.svelte.js +56 -0
- package/src/index.js +4 -0
- package/src/kbd.js +19 -3
- package/src/magnetic.svelte.js +58 -0
- package/src/navigator.svelte.js +87 -13
- package/src/reveal.svelte.js +86 -0
- package/src/ripple.svelte.js +88 -0
- package/src/utils.js +38 -5
|
@@ -0,0 +1,31 @@
|
|
|
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;
|
|
14
|
+
/**
|
|
15
|
+
* Hover lift action — adds translateY + elevated shadow on hover.
|
|
16
|
+
* Sets transition on mount, applies transform + box-shadow on mouseenter, resets on mouseleave.
|
|
17
|
+
*/
|
|
18
|
+
export type HoverLiftOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* Translate distance on hover (negative = up)
|
|
21
|
+
*/
|
|
22
|
+
distance?: string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Box shadow on hover
|
|
25
|
+
*/
|
|
26
|
+
shadow?: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Transition duration (ms)
|
|
29
|
+
*/
|
|
30
|
+
duration?: number | undefined;
|
|
31
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,7 @@ export { dismissable } from "./dismissable.svelte.js";
|
|
|
9
9
|
export { navigable } from "./navigable.svelte.js";
|
|
10
10
|
export { fillable } from "./fillable.svelte.js";
|
|
11
11
|
export { delegateKeyboardEvents } from "./delegate.svelte.js";
|
|
12
|
+
export { reveal } from "./reveal.svelte.js";
|
|
13
|
+
export { hoverLift } from "./hover-lift.svelte.js";
|
|
14
|
+
export { magnetic } from "./magnetic.svelte.js";
|
|
15
|
+
export { ripple } from "./ripple.svelte.js";
|
package/dist/kbd.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export function createKeyboardActionMap(options: {
|
|
|
29
29
|
export function createModifierKeyboardActionMap(options: {
|
|
30
30
|
orientation: string;
|
|
31
31
|
}): Object;
|
|
32
|
+
/**
|
|
33
|
+
* Creates a keyboard action mapping for shift key combinations
|
|
34
|
+
*
|
|
35
|
+
* @returns {Object} Mapping of keys to actions
|
|
36
|
+
*/
|
|
37
|
+
export function createShiftKeyboardActionMap(): Object;
|
|
32
38
|
/**
|
|
33
39
|
* Gets the keyboard action for a key event
|
|
34
40
|
* @param {KeyboardEvent} event - The keyboard event
|
|
@@ -41,4 +47,5 @@ export namespace defaultNavigationOptions {
|
|
|
41
47
|
let dir: string;
|
|
42
48
|
let nested: boolean;
|
|
43
49
|
let enabled: boolean;
|
|
50
|
+
let typeahead: boolean;
|
|
44
51
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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;
|
|
13
|
+
/**
|
|
14
|
+
* Magnetic action — element shifts subtly toward the cursor on hover.
|
|
15
|
+
* Calculates cursor offset from element center and translates proportionally.
|
|
16
|
+
*/
|
|
17
|
+
export type MagneticOptions = {
|
|
18
|
+
/**
|
|
19
|
+
* Maximum displacement as fraction of element size (0–1)
|
|
20
|
+
*/
|
|
21
|
+
strength?: number | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Transition duration for return to center (ms)
|
|
24
|
+
*/
|
|
25
|
+
duration?: number | undefined;
|
|
26
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-triggered reveal action using IntersectionObserver.
|
|
3
|
+
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
4
|
+
*
|
|
5
|
+
* @param {HTMLElement} node
|
|
6
|
+
* @param {RevealOptions} [options]
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} RevealOptions
|
|
9
|
+
* @property {'up' | 'down' | 'left' | 'right' | 'none'} [direction='up'] Slide direction
|
|
10
|
+
* @property {string} [distance='1.5rem'] Slide distance (CSS unit)
|
|
11
|
+
* @property {number} [duration=600] Animation duration (ms)
|
|
12
|
+
* @property {number} [delay=0] Delay before animation starts (ms)
|
|
13
|
+
* @property {boolean} [once=true] Only animate once
|
|
14
|
+
* @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
|
|
15
|
+
* @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
|
|
16
|
+
*/
|
|
17
|
+
export function reveal(node: HTMLElement, options?: RevealOptions): void;
|
|
18
|
+
/**
|
|
19
|
+
* Scroll-triggered reveal action using IntersectionObserver.
|
|
20
|
+
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
21
|
+
*/
|
|
22
|
+
export type RevealOptions = {
|
|
23
|
+
/**
|
|
24
|
+
* Slide direction
|
|
25
|
+
*/
|
|
26
|
+
direction?: "up" | "down" | "left" | "right" | "none" | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Slide distance (CSS unit)
|
|
29
|
+
*/
|
|
30
|
+
distance?: string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Animation duration (ms)
|
|
33
|
+
*/
|
|
34
|
+
duration?: number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Delay before animation starts (ms)
|
|
37
|
+
*/
|
|
38
|
+
delay?: number | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Only animate once
|
|
41
|
+
*/
|
|
42
|
+
once?: boolean | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* IntersectionObserver threshold (0–1)
|
|
45
|
+
*/
|
|
46
|
+
threshold?: number | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* CSS easing function
|
|
49
|
+
*/
|
|
50
|
+
easing?: string | undefined;
|
|
51
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
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;
|
|
14
|
+
/**
|
|
15
|
+
* Ripple action — material-design inspired click ripple effect.
|
|
16
|
+
* Appends a circular expanding span at click coordinates that scales and fades out.
|
|
17
|
+
*/
|
|
18
|
+
export type RippleOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* Ripple color
|
|
21
|
+
*/
|
|
22
|
+
color?: string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Ripple opacity
|
|
25
|
+
*/
|
|
26
|
+
opacity?: number | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Ripple animation duration (ms)
|
|
29
|
+
*/
|
|
30
|
+
duration?: number | undefined;
|
|
31
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/actions",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.127",
|
|
4
4
|
"description": "Contains generic actions that can be used in various components.",
|
|
5
5
|
"author": "Jerry Thomas <me@jerrythomas.name>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"ramda": "^0.
|
|
32
|
+
"ramda": "^0.32.0",
|
|
33
33
|
"@rokkit/core": "latest"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
@@ -0,0 +1,56 @@
|
|
|
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, options = {}) {
|
|
14
|
+
$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' &&
|
|
24
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
25
|
+
|
|
26
|
+
if (reducedMotion) return
|
|
27
|
+
|
|
28
|
+
// Store original values
|
|
29
|
+
const originalTransform = node.style.transform
|
|
30
|
+
const originalBoxShadow = node.style.boxShadow
|
|
31
|
+
const originalTransition = node.style.transition
|
|
32
|
+
|
|
33
|
+
node.style.transition = `transform ${opts.duration}ms ease, box-shadow ${opts.duration}ms ease`
|
|
34
|
+
|
|
35
|
+
function onEnter() {
|
|
36
|
+
node.style.transform = `translateY(${opts.distance})`
|
|
37
|
+
node.style.boxShadow = opts.shadow
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onLeave() {
|
|
41
|
+
node.style.transform = originalTransform
|
|
42
|
+
node.style.boxShadow = originalBoxShadow
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
node.addEventListener('mouseenter', onEnter)
|
|
46
|
+
node.addEventListener('mouseleave', onLeave)
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
node.removeEventListener('mouseenter', onEnter)
|
|
50
|
+
node.removeEventListener('mouseleave', onLeave)
|
|
51
|
+
node.style.transform = originalTransform
|
|
52
|
+
node.style.boxShadow = originalBoxShadow
|
|
53
|
+
node.style.transition = originalTransition
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,3 +10,7 @@ export { dismissable } from './dismissable.svelte.js'
|
|
|
10
10
|
export { navigable } from './navigable.svelte.js'
|
|
11
11
|
export { fillable } from './fillable.svelte.js'
|
|
12
12
|
export { delegateKeyboardEvents } from './delegate.svelte.js'
|
|
13
|
+
export { reveal } from './reveal.svelte.js'
|
|
14
|
+
export { hoverLift } from './hover-lift.svelte.js'
|
|
15
|
+
export { magnetic } from './magnetic.svelte.js'
|
|
16
|
+
export { ripple } from './ripple.svelte.js'
|
package/src/kbd.js
CHANGED
|
@@ -57,7 +57,8 @@ export const defaultNavigationOptions = {
|
|
|
57
57
|
orientation: 'vertical',
|
|
58
58
|
dir: 'ltr',
|
|
59
59
|
nested: false,
|
|
60
|
-
enabled: true
|
|
60
|
+
enabled: true,
|
|
61
|
+
typeahead: false
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/**
|
|
@@ -161,6 +162,15 @@ export function createModifierKeyboardActionMap(options) {
|
|
|
161
162
|
return { ...common, ...directional }
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Creates a keyboard action mapping for shift key combinations
|
|
167
|
+
*
|
|
168
|
+
* @returns {Object} Mapping of keys to actions
|
|
169
|
+
*/
|
|
170
|
+
export function createShiftKeyboardActionMap() {
|
|
171
|
+
return { ' ': 'range' }
|
|
172
|
+
}
|
|
173
|
+
|
|
164
174
|
/**
|
|
165
175
|
* Gets the keyboard action for a key event
|
|
166
176
|
* @param {KeyboardEvent} event - The keyboard event
|
|
@@ -168,12 +178,18 @@ export function createModifierKeyboardActionMap(options) {
|
|
|
168
178
|
* @returns {string|null} The action to perform, or null if no action is defined
|
|
169
179
|
*/
|
|
170
180
|
export function getKeyboardAction(event, options = {}) {
|
|
171
|
-
const { key, ctrlKey, metaKey } = event
|
|
181
|
+
const { key, ctrlKey, metaKey, shiftKey } = event
|
|
172
182
|
|
|
173
183
|
// Use updated options with defaults
|
|
174
184
|
const mergedOptions = { ...defaultNavigationOptions, ...options }
|
|
175
185
|
|
|
176
|
-
// Check for
|
|
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)
|
|
177
193
|
if (ctrlKey || metaKey) {
|
|
178
194
|
const modifierMap = createModifierKeyboardActionMap(mergedOptions)
|
|
179
195
|
return modifierMap[key] || null
|
|
@@ -0,0 +1,58 @@
|
|
|
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, options = {}) {
|
|
13
|
+
$effect(() => {
|
|
14
|
+
const opts = {
|
|
15
|
+
strength: 0.3,
|
|
16
|
+
duration: 300,
|
|
17
|
+
...options
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const reducedMotion =
|
|
21
|
+
typeof window !== 'undefined' &&
|
|
22
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
23
|
+
|
|
24
|
+
if (reducedMotion) return
|
|
25
|
+
|
|
26
|
+
const originalTransform = node.style.transform
|
|
27
|
+
const originalTransition = node.style.transition
|
|
28
|
+
|
|
29
|
+
node.style.transition = `transform ${opts.duration}ms ease`
|
|
30
|
+
|
|
31
|
+
function onMove(e) {
|
|
32
|
+
const rect = node.getBoundingClientRect()
|
|
33
|
+
const centerX = rect.left + rect.width / 2
|
|
34
|
+
const centerY = rect.top + rect.height / 2
|
|
35
|
+
|
|
36
|
+
const offsetX = (e.clientX - centerX) * opts.strength
|
|
37
|
+
const offsetY = (e.clientY - centerY) * opts.strength
|
|
38
|
+
|
|
39
|
+
node.style.transition = 'none'
|
|
40
|
+
node.style.transform = `translate(${offsetX}px, ${offsetY}px)`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function onLeave() {
|
|
44
|
+
node.style.transition = `transform ${opts.duration}ms ease`
|
|
45
|
+
node.style.transform = originalTransform
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
node.addEventListener('mousemove', onMove)
|
|
49
|
+
node.addEventListener('mouseleave', onLeave)
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
node.removeEventListener('mousemove', onMove)
|
|
53
|
+
node.removeEventListener('mouseleave', onLeave)
|
|
54
|
+
node.style.transform = originalTransform
|
|
55
|
+
node.style.transition = originalTransition
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
package/src/navigator.svelte.js
CHANGED
|
@@ -15,16 +15,16 @@ function scrollFocusedIntoView(container, wrapper) {
|
|
|
15
15
|
if (wrapper.focusedKey) {
|
|
16
16
|
focusedElement = container.querySelector(`[data-path="${wrapper.focusedKey}"]`)
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
// Fallback: find by aria-current
|
|
20
20
|
if (!focusedElement) {
|
|
21
21
|
focusedElement = container.querySelector('[aria-current="true"]')
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
// Scroll into view if element found
|
|
25
|
-
if (focusedElement) {
|
|
26
|
-
focusedElement.scrollIntoView({
|
|
27
|
-
behavior: 'smooth',
|
|
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
28
|
block: 'nearest',
|
|
29
29
|
inline: 'nearest'
|
|
30
30
|
})
|
|
@@ -38,6 +38,7 @@ const EVENT_MAP = {
|
|
|
38
38
|
next: ['move'],
|
|
39
39
|
select: ['move', 'select'],
|
|
40
40
|
extend: ['move', 'select'],
|
|
41
|
+
range: ['move', 'select'],
|
|
41
42
|
collapse: ['toggle'],
|
|
42
43
|
expand: ['toggle'],
|
|
43
44
|
toggle: ['toggle']
|
|
@@ -94,6 +95,7 @@ function getHandlers(wrapper) {
|
|
|
94
95
|
expand: () => wrapper.expand(),
|
|
95
96
|
select: (path) => wrapper.select(path),
|
|
96
97
|
extend: (path) => wrapper.extendSelection(path),
|
|
98
|
+
range: (path) => wrapper.selectRange(path),
|
|
97
99
|
toggle: (path) => wrapper.toggleExpansion(path)
|
|
98
100
|
}
|
|
99
101
|
}
|
|
@@ -109,31 +111,103 @@ export function navigator(node, options) {
|
|
|
109
111
|
const config = { ...defaultNavigationOptions, ...omit(['wrapper'], options) }
|
|
110
112
|
const handlers = getHandlers(wrapper)
|
|
111
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
|
+
|
|
112
149
|
const handleKeydown = (event) => {
|
|
113
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
|
+
|
|
114
160
|
const handled = handleAction(event, handlers[action])
|
|
115
161
|
if (handled) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
166
|
+
['expand', 'collapse'].includes(action) && wrapper.focusedKey !== prevKey
|
|
167
|
+
if (focusMoved) {
|
|
168
|
+
node.dispatchEvent(
|
|
169
|
+
new CustomEvent('action', {
|
|
170
|
+
detail: {
|
|
171
|
+
name: 'move',
|
|
172
|
+
data: { value: wrapper.focused, selected: wrapper.selected }
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
// Scroll focused element into view for navigation and focus-moving expand/collapse
|
|
178
|
+
if (focusMoved || ['first', 'last', 'previous', 'next'].includes(action)) {
|
|
179
|
+
setTimeout(() => scrollFocusedIntoView(node, wrapper), 0)
|
|
121
180
|
}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Type-ahead: when no navigation action matched and typeahead is enabled
|
|
185
|
+
if (config.typeahead && wrapper.findByText) {
|
|
186
|
+
handleTypeahead(event)
|
|
122
187
|
}
|
|
123
188
|
}
|
|
124
189
|
|
|
125
190
|
const handleClick = (event) => {
|
|
126
191
|
const action = getClickAction(event)
|
|
127
192
|
const path = getPathFromEvent(event)
|
|
128
|
-
const handled = handleAction(event, handlers[action], path)
|
|
129
193
|
|
|
194
|
+
// Anchor elements with href handle navigation natively — don't preventDefault.
|
|
195
|
+
// Still call the handler so focus/selection state stays in sync.
|
|
196
|
+
if (event.target.closest('a[href]')) {
|
|
197
|
+
const handler = handlers[action]
|
|
198
|
+
if (handler?.(path)) emitAction(node, options.wrapper, action)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const handled = handleAction(event, handlers[action], path)
|
|
130
203
|
if (handled) emitAction(node, options.wrapper, action)
|
|
131
204
|
}
|
|
132
205
|
|
|
133
206
|
$effect(() => {
|
|
134
|
-
const cleanup = [on(node, '
|
|
207
|
+
const cleanup = [on(node, 'keydown', handleKeydown), on(node, 'click', handleClick)]
|
|
135
208
|
|
|
136
209
|
return () => {
|
|
210
|
+
resetTypeahead()
|
|
137
211
|
cleanup.forEach((fn) => fn())
|
|
138
212
|
}
|
|
139
213
|
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-triggered reveal action using IntersectionObserver.
|
|
3
|
+
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
4
|
+
*
|
|
5
|
+
* @param {HTMLElement} node
|
|
6
|
+
* @param {RevealOptions} [options]
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} RevealOptions
|
|
9
|
+
* @property {'up' | 'down' | 'left' | 'right' | 'none'} [direction='up'] Slide direction
|
|
10
|
+
* @property {string} [distance='1.5rem'] Slide distance (CSS unit)
|
|
11
|
+
* @property {number} [duration=600] Animation duration (ms)
|
|
12
|
+
* @property {number} [delay=0] Delay before animation starts (ms)
|
|
13
|
+
* @property {boolean} [once=true] Only animate once
|
|
14
|
+
* @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
|
|
15
|
+
* @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
|
|
16
|
+
*/
|
|
17
|
+
export function reveal(node, options = {}) {
|
|
18
|
+
$effect(() => {
|
|
19
|
+
const opts = {
|
|
20
|
+
direction: 'up',
|
|
21
|
+
distance: '1.5rem',
|
|
22
|
+
duration: 600,
|
|
23
|
+
delay: 0,
|
|
24
|
+
once: true,
|
|
25
|
+
threshold: 0.1,
|
|
26
|
+
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
27
|
+
...options
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const reducedMotion =
|
|
31
|
+
typeof window !== 'undefined' &&
|
|
32
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
33
|
+
|
|
34
|
+
// Set CSS custom properties for the transition
|
|
35
|
+
node.style.setProperty('--reveal-duration', `${opts.duration}ms`)
|
|
36
|
+
node.style.setProperty('--reveal-distance', opts.distance)
|
|
37
|
+
node.style.setProperty('--reveal-easing', opts.easing)
|
|
38
|
+
|
|
39
|
+
// Apply direction attribute (CSS uses this for initial translate)
|
|
40
|
+
node.setAttribute('data-reveal', opts.direction)
|
|
41
|
+
|
|
42
|
+
if (opts.delay > 0) {
|
|
43
|
+
node.style.transitionDelay = `${opts.delay}ms`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (reducedMotion) {
|
|
47
|
+
node.setAttribute('data-reveal-visible', '')
|
|
48
|
+
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
|
|
49
|
+
return () => {
|
|
50
|
+
node.removeAttribute('data-reveal')
|
|
51
|
+
node.removeAttribute('data-reveal-visible')
|
|
52
|
+
node.style.removeProperty('--reveal-duration')
|
|
53
|
+
node.style.removeProperty('--reveal-distance')
|
|
54
|
+
node.style.removeProperty('--reveal-easing')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const observer = new IntersectionObserver(
|
|
59
|
+
(entries) => {
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.isIntersecting) {
|
|
62
|
+
node.setAttribute('data-reveal-visible', '')
|
|
63
|
+
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
|
|
64
|
+
if (opts.once) observer.unobserve(node)
|
|
65
|
+
} else if (!opts.once) {
|
|
66
|
+
node.removeAttribute('data-reveal-visible')
|
|
67
|
+
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: false } }))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{ threshold: opts.threshold }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
observer.observe(node)
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
observer.disconnect()
|
|
78
|
+
node.removeAttribute('data-reveal')
|
|
79
|
+
node.removeAttribute('data-reveal-visible')
|
|
80
|
+
node.style.removeProperty('--reveal-duration')
|
|
81
|
+
node.style.removeProperty('--reveal-distance')
|
|
82
|
+
node.style.removeProperty('--reveal-easing')
|
|
83
|
+
if (opts.delay > 0) node.style.removeProperty('transition-delay')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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, options = {}) {
|
|
14
|
+
$effect(() => {
|
|
15
|
+
const opts = {
|
|
16
|
+
color: 'currentColor',
|
|
17
|
+
opacity: 0.15,
|
|
18
|
+
duration: 500,
|
|
19
|
+
...options
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const reducedMotion =
|
|
23
|
+
typeof window !== 'undefined' &&
|
|
24
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
25
|
+
|
|
26
|
+
if (reducedMotion) return
|
|
27
|
+
|
|
28
|
+
// Ensure host can contain the ripple
|
|
29
|
+
const originalOverflow = node.style.overflow
|
|
30
|
+
const originalPosition = node.style.position
|
|
31
|
+
const computed = getComputedStyle(node).position
|
|
32
|
+
if (!computed || computed === 'static') {
|
|
33
|
+
node.style.position = 'relative'
|
|
34
|
+
}
|
|
35
|
+
node.style.overflow = 'hidden'
|
|
36
|
+
|
|
37
|
+
function onClick(e) {
|
|
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
|
|
42
|
+
|
|
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
|
+
|
|
56
|
+
node.appendChild(span)
|
|
57
|
+
span.addEventListener('animationend', () => span.remove(), { once: true })
|
|
58
|
+
|
|
59
|
+
// Fallback removal in case animationend doesn't fire (JSDOM)
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (span.parentNode) span.remove()
|
|
62
|
+
}, opts.duration + 100)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inject keyframes if not already present
|
|
66
|
+
if (!document.querySelector('#rokkit-ripple-keyframes')) {
|
|
67
|
+
const style = document.createElement('style')
|
|
68
|
+
style.id = 'rokkit-ripple-keyframes'
|
|
69
|
+
style.textContent = `
|
|
70
|
+
@keyframes rokkit-ripple {
|
|
71
|
+
to {
|
|
72
|
+
transform: scale(1);
|
|
73
|
+
opacity: 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`
|
|
77
|
+
document.head.appendChild(style)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
node.addEventListener('click', onClick)
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
node.removeEventListener('click', onClick)
|
|
84
|
+
node.style.overflow = originalOverflow
|
|
85
|
+
node.style.position = originalPosition
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -26,8 +26,8 @@ export function getClosestAncestorWithAttribute(element, attribute) {
|
|
|
26
26
|
* @returns {string|null} - The event name or null if no match is found.
|
|
27
27
|
*/
|
|
28
28
|
export const getEventForKey = (keyMapping, key) => {
|
|
29
|
-
|
|
30
|
-
const matchEvent = ([
|
|
29
|
+
|
|
30
|
+
const matchEvent = ([_eventName, keys]) =>
|
|
31
31
|
(Array.isArray(keys) && keys.includes(key)) || (keys instanceof RegExp && keys.test(key))
|
|
32
32
|
|
|
33
33
|
const event = find(matchEvent, toPairs(keyMapping))
|
|
@@ -92,7 +92,7 @@ export function getPathFromEvent(event) {
|
|
|
92
92
|
/**
|
|
93
93
|
* Identifies if an element is a collapsible icon
|
|
94
94
|
* @param {HTMLElement} target
|
|
95
|
-
* @returns
|
|
95
|
+
* @returns {boolean}
|
|
96
96
|
*/
|
|
97
97
|
function isNodeToggle(target) {
|
|
98
98
|
return (
|
|
@@ -101,6 +101,29 @@ function isNodeToggle(target) {
|
|
|
101
101
|
['closed', 'opened'].includes(target.getAttribute('data-state'))
|
|
102
102
|
)
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Finds the closest ancestor (or self) that has the given attribute
|
|
107
|
+
* @param {HTMLElement} element
|
|
108
|
+
* @param {string} attribute
|
|
109
|
+
* @returns {HTMLElement|null}
|
|
110
|
+
*/
|
|
111
|
+
function findClosestWithAttribute(element, attribute) {
|
|
112
|
+
if (!element) return null
|
|
113
|
+
if (element.hasAttribute && element.hasAttribute(attribute)) return element
|
|
114
|
+
return findClosestWithAttribute(element.parentElement, attribute)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Identifies if an element or its ancestors is an accordion/tree trigger
|
|
119
|
+
* @param {HTMLElement} target
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
function isAccordionTrigger(target) {
|
|
123
|
+
if (!target) return false
|
|
124
|
+
const trigger = findClosestWithAttribute(target, 'data-accordion-trigger')
|
|
125
|
+
return trigger !== null
|
|
126
|
+
}
|
|
104
127
|
// getKeyboardAction moved to kbd.js
|
|
105
128
|
|
|
106
129
|
/**
|
|
@@ -110,9 +133,14 @@ function isNodeToggle(target) {
|
|
|
110
133
|
* @returns {string} The determined action
|
|
111
134
|
*/
|
|
112
135
|
export const getClickAction = (event) => {
|
|
113
|
-
const { ctrlKey, metaKey, target } = event
|
|
136
|
+
const { ctrlKey, metaKey, shiftKey, target } = event
|
|
114
137
|
|
|
115
|
-
// Check for
|
|
138
|
+
// Check for shift key first (range selection)
|
|
139
|
+
if (shiftKey && !ctrlKey && !metaKey) {
|
|
140
|
+
return 'range'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check for modifier keys (toggle selection)
|
|
116
144
|
if (ctrlKey || metaKey) {
|
|
117
145
|
return 'extend'
|
|
118
146
|
}
|
|
@@ -122,6 +150,11 @@ export const getClickAction = (event) => {
|
|
|
122
150
|
return 'toggle'
|
|
123
151
|
}
|
|
124
152
|
|
|
153
|
+
// Check if clicked on accordion trigger (header area)
|
|
154
|
+
if (isAccordionTrigger(target)) {
|
|
155
|
+
return 'toggle'
|
|
156
|
+
}
|
|
157
|
+
|
|
125
158
|
// Default action
|
|
126
159
|
return 'select'
|
|
127
160
|
}
|