@rokkit/actions 1.0.0-next.127 → 1.0.0-next.129
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/index.d.ts +3 -0
- package/dist/keymap.d.ts +35 -0
- package/dist/nav-constants.d.ts +65 -0
- package/dist/navigator.d.ts +14 -0
- package/dist/reveal.svelte.d.ts +7 -0
- package/dist/trigger.d.ts +41 -0
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/keymap.js +81 -0
- package/src/nav-constants.js +61 -0
- package/src/navigator.js +250 -0
- package/src/reveal.svelte.js +66 -22
- package/src/trigger.js +112 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
+
export { Navigator } from "./navigator.js";
|
|
3
|
+
export { Trigger } from "./trigger.js";
|
|
2
4
|
export { keyboard } from "./keyboard.svelte.js";
|
|
3
5
|
export { pannable } from "./pannable.svelte.js";
|
|
4
6
|
export { swipeable } from "./swipeable.svelte.js";
|
|
@@ -13,3 +15,4 @@ export { reveal } from "./reveal.svelte.js";
|
|
|
13
15
|
export { hoverLift } from "./hover-lift.svelte.js";
|
|
14
16
|
export { magnetic } from "./magnetic.svelte.js";
|
|
15
17
|
export { ripple } from "./ripple.svelte.js";
|
|
18
|
+
export { buildKeymap, resolveAction, ACTIONS } from "./keymap.js";
|
package/dist/keymap.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a complete keymap for the given options.
|
|
3
|
+
*
|
|
4
|
+
* Returns three layers — plain, shift, ctrl — each mapping key name → action name.
|
|
5
|
+
* Call resolveAction(event, keymap) to look up the action for a keyboard event.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} [options]
|
|
8
|
+
* @param {'vertical'|'horizontal'} [options.orientation='vertical']
|
|
9
|
+
* @param {'ltr'|'rtl'} [options.dir='ltr']
|
|
10
|
+
* @param {boolean} [options.collapsible=false]
|
|
11
|
+
* @returns {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }}
|
|
12
|
+
*/
|
|
13
|
+
export function buildKeymap({ orientation, dir, collapsible }?: {
|
|
14
|
+
orientation?: "vertical" | "horizontal" | undefined;
|
|
15
|
+
dir?: "ltr" | "rtl" | undefined;
|
|
16
|
+
collapsible?: boolean | undefined;
|
|
17
|
+
}): {
|
|
18
|
+
plain: Record<string, string>;
|
|
19
|
+
shift: Record<string, string>;
|
|
20
|
+
ctrl: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the action for a keyboard event given a pre-built keymap.
|
|
24
|
+
* Returns null if the key has no binding.
|
|
25
|
+
*
|
|
26
|
+
* @param {KeyboardEvent} event
|
|
27
|
+
* @param {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }} keymap
|
|
28
|
+
* @returns {string|null}
|
|
29
|
+
*/
|
|
30
|
+
export function resolveAction(event: KeyboardEvent, keymap: {
|
|
31
|
+
plain: Record<string, string>;
|
|
32
|
+
shift: Record<string, string>;
|
|
33
|
+
ctrl: Record<string, string>;
|
|
34
|
+
}): string | null;
|
|
35
|
+
export { ACTIONS } from "./nav-constants.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigator constants — keyboard actions, key bindings, and typeahead config.
|
|
3
|
+
* These are used by the Navigator class and keymap builder.
|
|
4
|
+
*/
|
|
5
|
+
export const ACTIONS: Readonly<{
|
|
6
|
+
next: "next";
|
|
7
|
+
prev: "prev";
|
|
8
|
+
first: "first";
|
|
9
|
+
last: "last";
|
|
10
|
+
expand: "expand";
|
|
11
|
+
collapse: "collapse";
|
|
12
|
+
select: "select";
|
|
13
|
+
extend: "extend";
|
|
14
|
+
range: "range";
|
|
15
|
+
cancel: "cancel";
|
|
16
|
+
}>;
|
|
17
|
+
export const PLAIN_FIXED: {
|
|
18
|
+
Enter: "select";
|
|
19
|
+
' ': "select";
|
|
20
|
+
Home: "first";
|
|
21
|
+
End: "last";
|
|
22
|
+
Escape: "cancel";
|
|
23
|
+
};
|
|
24
|
+
export const CTRL_FIXED: {
|
|
25
|
+
' ': "extend";
|
|
26
|
+
Home: "first";
|
|
27
|
+
End: "last";
|
|
28
|
+
};
|
|
29
|
+
export const SHIFT_FIXED: {
|
|
30
|
+
' ': "range";
|
|
31
|
+
};
|
|
32
|
+
export const ARROWS: {
|
|
33
|
+
'vertical-ltr': {
|
|
34
|
+
move: {
|
|
35
|
+
ArrowUp: "prev";
|
|
36
|
+
ArrowDown: "next";
|
|
37
|
+
};
|
|
38
|
+
nested: {
|
|
39
|
+
ArrowLeft: "collapse";
|
|
40
|
+
ArrowRight: "expand";
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
'vertical-rtl': {
|
|
44
|
+
move: {
|
|
45
|
+
ArrowUp: "prev";
|
|
46
|
+
ArrowDown: "next";
|
|
47
|
+
};
|
|
48
|
+
nested: {
|
|
49
|
+
ArrowRight: "collapse";
|
|
50
|
+
ArrowLeft: "expand";
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
horizontal: {
|
|
54
|
+
move: {
|
|
55
|
+
ArrowLeft: "prev";
|
|
56
|
+
ArrowRight: "next";
|
|
57
|
+
};
|
|
58
|
+
nested: {
|
|
59
|
+
ArrowUp: "collapse";
|
|
60
|
+
ArrowDown: "expand";
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/** Milliseconds of inactivity before the typeahead buffer resets. */
|
|
65
|
+
export const TYPEAHEAD_RESET_MS: 500;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class Navigator {
|
|
2
|
+
/**
|
|
3
|
+
* @param {HTMLElement} root
|
|
4
|
+
* @param {import('@rokkit/states').Wrapper} wrapper
|
|
5
|
+
* @param {{ orientation?: string, dir?: string, collapsible?: boolean }} [options]
|
|
6
|
+
*/
|
|
7
|
+
constructor(root: HTMLElement, wrapper: import("@rokkit/states").Wrapper, options?: {
|
|
8
|
+
orientation?: string;
|
|
9
|
+
dir?: string;
|
|
10
|
+
collapsible?: boolean;
|
|
11
|
+
});
|
|
12
|
+
destroy(): void;
|
|
13
|
+
#private;
|
|
14
|
+
}
|
package/dist/reveal.svelte.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scroll-triggered reveal action using IntersectionObserver.
|
|
3
3
|
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
4
|
+
* When stagger > 0, applies reveal to each child element independently.
|
|
4
5
|
*
|
|
5
6
|
* @param {HTMLElement} node
|
|
6
7
|
* @param {RevealOptions} [options]
|
|
@@ -10,6 +11,7 @@
|
|
|
10
11
|
* @property {string} [distance='1.5rem'] Slide distance (CSS unit)
|
|
11
12
|
* @property {number} [duration=600] Animation duration (ms)
|
|
12
13
|
* @property {number} [delay=0] Delay before animation starts (ms)
|
|
14
|
+
* @property {number} [stagger=0] Delay increment per child in ms (0 = disabled)
|
|
13
15
|
* @property {boolean} [once=true] Only animate once
|
|
14
16
|
* @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
|
|
15
17
|
* @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
|
|
@@ -18,6 +20,7 @@ export function reveal(node: HTMLElement, options?: RevealOptions): void;
|
|
|
18
20
|
/**
|
|
19
21
|
* Scroll-triggered reveal action using IntersectionObserver.
|
|
20
22
|
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
23
|
+
* When stagger > 0, applies reveal to each child element independently.
|
|
21
24
|
*/
|
|
22
25
|
export type RevealOptions = {
|
|
23
26
|
/**
|
|
@@ -36,6 +39,10 @@ export type RevealOptions = {
|
|
|
36
39
|
* Delay before animation starts (ms)
|
|
37
40
|
*/
|
|
38
41
|
delay?: number | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Delay increment per child in ms (0 = disabled)
|
|
44
|
+
*/
|
|
45
|
+
stagger?: number | undefined;
|
|
39
46
|
/**
|
|
40
47
|
* Only animate once
|
|
41
48
|
*/
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
export class Trigger {
|
|
25
|
+
/**
|
|
26
|
+
* @param {HTMLElement} trigger — the trigger button element
|
|
27
|
+
* @param {HTMLElement} container — the menu root (for click-outside detection)
|
|
28
|
+
* @param {{ onopen: () => void, onclose: () => void, onlast?: () => void, isOpen: () => boolean }} callbacks
|
|
29
|
+
*/
|
|
30
|
+
constructor(trigger: HTMLElement, container: HTMLElement, { onopen, onclose, onlast, isOpen }: {
|
|
31
|
+
onopen: () => void;
|
|
32
|
+
onclose: () => void;
|
|
33
|
+
onlast?: () => void;
|
|
34
|
+
isOpen: () => boolean;
|
|
35
|
+
});
|
|
36
|
+
get isOpen(): boolean;
|
|
37
|
+
open(): void;
|
|
38
|
+
close(): void;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
#private;
|
|
41
|
+
}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// skipcq: JS-E1004 - Needed for exposing all types
|
|
2
2
|
export * from './types.js'
|
|
3
|
+
export { Navigator } from './navigator.js'
|
|
4
|
+
export { Trigger } from './trigger.js'
|
|
5
|
+
export { buildKeymap, resolveAction, ACTIONS } from './keymap.js'
|
|
3
6
|
export { keyboard } from './keyboard.svelte.js'
|
|
4
7
|
export { pannable } from './pannable.svelte.js'
|
|
5
8
|
export { swipeable } from './swipeable.svelte.js'
|
package/src/keymap.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Keymap
|
|
3
|
+
*
|
|
4
|
+
* Maps keyboard inputs to semantic actions.
|
|
5
|
+
*
|
|
6
|
+
* Design principle: orientation is just a rotation of arrow key assignments.
|
|
7
|
+
*
|
|
8
|
+
* vertical ltr up/down = prev/next left/right = collapse/expand (when collapsible)
|
|
9
|
+
* vertical rtl up/down = prev/next right/left = collapse/expand (expand/collapse reversed)
|
|
10
|
+
* horizontal left/right = prev/next up/down = collapse/expand (dir ignored — use CSS flex-reverse for RTL)
|
|
11
|
+
*
|
|
12
|
+
* ─── Actions ────────────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* next focus next visible item, skip disabled
|
|
15
|
+
* prev focus previous visible item, skip disabled
|
|
16
|
+
* first jump to first visible item
|
|
17
|
+
* last jump to last visible item
|
|
18
|
+
* expand when collapsible: expand collapsed group
|
|
19
|
+
* if already expanded: move focus to first child
|
|
20
|
+
* on leaf: no-op
|
|
21
|
+
* collapse when collapsible: collapse expanded group
|
|
22
|
+
* if already collapsed or leaf: move focus to parent
|
|
23
|
+
* at root level: no-op
|
|
24
|
+
* select activate the focused item
|
|
25
|
+
* extend toggle individual selection (multiselect ctrl/cmd + space)
|
|
26
|
+
* range select contiguous range (multiselect shift + space)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { PLAIN_FIXED, CTRL_FIXED, SHIFT_FIXED, ARROWS } from './nav-constants.js'
|
|
30
|
+
export { ACTIONS } from './nav-constants.js'
|
|
31
|
+
|
|
32
|
+
function getArrows(orientation, dir) {
|
|
33
|
+
if (orientation === 'horizontal') return ARROWS.horizontal
|
|
34
|
+
return ARROWS[`vertical-${dir}`]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── buildKeymap ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a complete keymap for the given options.
|
|
41
|
+
*
|
|
42
|
+
* Returns three layers — plain, shift, ctrl — each mapping key name → action name.
|
|
43
|
+
* Call resolveAction(event, keymap) to look up the action for a keyboard event.
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} [options]
|
|
46
|
+
* @param {'vertical'|'horizontal'} [options.orientation='vertical']
|
|
47
|
+
* @param {'ltr'|'rtl'} [options.dir='ltr']
|
|
48
|
+
* @param {boolean} [options.collapsible=false]
|
|
49
|
+
* @returns {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }}
|
|
50
|
+
*/
|
|
51
|
+
export function buildKeymap({ orientation = 'vertical', dir = 'ltr', collapsible = false } = {}) {
|
|
52
|
+
const arrows = getArrows(orientation, dir)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
plain: {
|
|
56
|
+
...PLAIN_FIXED,
|
|
57
|
+
...arrows.move,
|
|
58
|
+
...(collapsible ? arrows.nested : {})
|
|
59
|
+
},
|
|
60
|
+
shift: { ...SHIFT_FIXED },
|
|
61
|
+
ctrl: { ...CTRL_FIXED }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── resolveAction ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the action for a keyboard event given a pre-built keymap.
|
|
69
|
+
* Returns null if the key has no binding.
|
|
70
|
+
*
|
|
71
|
+
* @param {KeyboardEvent} event
|
|
72
|
+
* @param {{ plain: Record<string, string>, shift: Record<string, string>, ctrl: Record<string, string> }} keymap
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function resolveAction(event, keymap) {
|
|
76
|
+
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
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigator constants — keyboard actions, key bindings, and typeahead config.
|
|
3
|
+
* These are used by the Navigator class and keymap builder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── Navigator actions ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export const ACTIONS = Object.freeze({
|
|
9
|
+
next: 'next',
|
|
10
|
+
prev: 'prev',
|
|
11
|
+
first: 'first',
|
|
12
|
+
last: 'last',
|
|
13
|
+
expand: 'expand',
|
|
14
|
+
collapse: 'collapse',
|
|
15
|
+
select: 'select',
|
|
16
|
+
extend: 'extend',
|
|
17
|
+
range: 'range',
|
|
18
|
+
cancel: 'cancel' // Escape — close dropdown, deselect, or dismiss
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// ─── Keymap: fixed key bindings (orientation-independent) ────────────────────
|
|
22
|
+
|
|
23
|
+
export const PLAIN_FIXED = {
|
|
24
|
+
Enter: ACTIONS.select,
|
|
25
|
+
' ': ACTIONS.select,
|
|
26
|
+
Home: ACTIONS.first,
|
|
27
|
+
End: ACTIONS.last,
|
|
28
|
+
Escape: ACTIONS.cancel
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CTRL_FIXED = {
|
|
32
|
+
' ': ACTIONS.extend,
|
|
33
|
+
Home: ACTIONS.first,
|
|
34
|
+
End: ACTIONS.last
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const SHIFT_FIXED = {
|
|
38
|
+
' ': ACTIONS.range
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Keymap: arrow key assignments per orientation/direction ──────────────────
|
|
42
|
+
|
|
43
|
+
export const ARROWS = {
|
|
44
|
+
'vertical-ltr': {
|
|
45
|
+
move: { ArrowUp: ACTIONS.prev, ArrowDown: ACTIONS.next },
|
|
46
|
+
nested: { ArrowLeft: ACTIONS.collapse, ArrowRight: ACTIONS.expand }
|
|
47
|
+
},
|
|
48
|
+
'vertical-rtl': {
|
|
49
|
+
move: { ArrowUp: ACTIONS.prev, ArrowDown: ACTIONS.next },
|
|
50
|
+
nested: { ArrowRight: ACTIONS.collapse, ArrowLeft: ACTIONS.expand }
|
|
51
|
+
},
|
|
52
|
+
horizontal: {
|
|
53
|
+
move: { ArrowLeft: ACTIONS.prev, ArrowRight: ACTIONS.next },
|
|
54
|
+
nested: { ArrowUp: ACTIONS.collapse, ArrowDown: ACTIONS.expand }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Typeahead ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Milliseconds of inactivity before the typeahead buffer resets. */
|
|
61
|
+
export const TYPEAHEAD_RESET_MS = 500
|
package/src/navigator.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigator
|
|
3
|
+
*
|
|
4
|
+
* Wires DOM events on a root element to Wrapper actions.
|
|
5
|
+
* Designed as a plain class so it works as a Svelte action or standalone.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - keydown → keymap lookup → wrapper action (+ scrollIntoView)
|
|
9
|
+
* - click → click action lookup → wrapper action
|
|
10
|
+
* - focusin → find nearest data-path → wrapper.moveTo(path)
|
|
11
|
+
* if no data-path found (tabbed into container) → redirect to focusedKey
|
|
12
|
+
* - focusout → detect when focus leaves the list entirely → call wrapper.blur()
|
|
13
|
+
* - typeahead → buffer printable chars → wrapper.findByText → wrapper.moveTo
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* const nav = new Navigator(rootEl, wrapper, { collapsible: true })
|
|
17
|
+
* // …
|
|
18
|
+
* nav.destroy()
|
|
19
|
+
*
|
|
20
|
+
* Or as a Svelte action (use inside $effect):
|
|
21
|
+
* $effect(() => {
|
|
22
|
+
* const nav = new Navigator(node, wrapper, options)
|
|
23
|
+
* return () => nav.destroy()
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { TYPEAHEAD_RESET_MS } from './nav-constants.js'
|
|
28
|
+
import { buildKeymap, resolveAction } from './keymap.js'
|
|
29
|
+
|
|
30
|
+
// ─── Click action resolution ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Determine the action for a mouse click based on modifiers and target.
|
|
34
|
+
* Group headers marked with data-accordion-trigger dispatch 'toggle'.
|
|
35
|
+
*
|
|
36
|
+
* @param {MouseEvent} event
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function getClickAction(event) {
|
|
40
|
+
const { shiftKey, ctrlKey, metaKey, target } = event
|
|
41
|
+
|
|
42
|
+
if (shiftKey && !ctrlKey && !metaKey) return 'range'
|
|
43
|
+
if (ctrlKey || metaKey) return 'extend'
|
|
44
|
+
if (target.closest('[data-accordion-trigger]')) return 'toggle'
|
|
45
|
+
return 'select'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Path resolution ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Walk up the DOM from target to find the nearest element with data-path.
|
|
52
|
+
* Returns null if none found within root.
|
|
53
|
+
*
|
|
54
|
+
* @param {EventTarget} target
|
|
55
|
+
* @param {HTMLElement} root
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
function getPath(target, root) {
|
|
59
|
+
let el = /** @type {HTMLElement|null} */ (target)
|
|
60
|
+
while (el && el !== root) {
|
|
61
|
+
if (el.dataset?.path !== undefined) return el.dataset.path
|
|
62
|
+
el = el.parentElement
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Navigator ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export class Navigator {
|
|
70
|
+
#root
|
|
71
|
+
#wrapper
|
|
72
|
+
#keymap
|
|
73
|
+
|
|
74
|
+
// Typeahead state
|
|
75
|
+
#buffer = ''
|
|
76
|
+
#bufferTimer = null
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {HTMLElement} root
|
|
80
|
+
* @param {import('@rokkit/states').Wrapper} wrapper
|
|
81
|
+
* @param {{ orientation?: string, dir?: string, collapsible?: boolean }} [options]
|
|
82
|
+
*/
|
|
83
|
+
constructor(root, wrapper, options = {}) {
|
|
84
|
+
this.#root = root
|
|
85
|
+
this.#wrapper = wrapper
|
|
86
|
+
this.#keymap = buildKeymap(options)
|
|
87
|
+
|
|
88
|
+
root.addEventListener('keydown', this.#onKeydown)
|
|
89
|
+
root.addEventListener('click', this.#onClick)
|
|
90
|
+
root.addEventListener('focusin', this.#onFocusin)
|
|
91
|
+
root.addEventListener('focusout', this.#onFocusout)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
destroy() {
|
|
95
|
+
this.#root.removeEventListener('keydown', this.#onKeydown)
|
|
96
|
+
this.#root.removeEventListener('click', this.#onClick)
|
|
97
|
+
this.#root.removeEventListener('focusin', this.#onFocusin)
|
|
98
|
+
this.#root.removeEventListener('focusout', this.#onFocusout)
|
|
99
|
+
this.#clearTypeahead()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Keydown ────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
#onKeydown = (/** @type {KeyboardEvent} */ event) => {
|
|
105
|
+
// Typeahead: single printable character (no modifiers except shift for caps)
|
|
106
|
+
if (this.#tryTypeahead(event)) return
|
|
107
|
+
|
|
108
|
+
const action = resolveAction(event, this.#keymap)
|
|
109
|
+
if (!action) return
|
|
110
|
+
|
|
111
|
+
// Links handle Enter/Space natively — browser fires a synthetic click
|
|
112
|
+
if (action === 'select' && event.target.closest('a[href]')) return
|
|
113
|
+
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
event.stopPropagation()
|
|
116
|
+
|
|
117
|
+
// Resolve current path from the focused element so all actions get context
|
|
118
|
+
const path = getPath(document.activeElement, this.#root)
|
|
119
|
+
this.#dispatch(action, path)
|
|
120
|
+
|
|
121
|
+
// Scroll focused item into view after keyboard navigation
|
|
122
|
+
this.#syncFocus()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Click ──────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
#onClick = (/** @type {MouseEvent} */ event) => {
|
|
128
|
+
const path = getPath(event.target, this.#root)
|
|
129
|
+
if (path === null) return
|
|
130
|
+
|
|
131
|
+
const action = getClickAction(event)
|
|
132
|
+
|
|
133
|
+
// Links: let browser navigate naturally, still update state
|
|
134
|
+
if (!event.target.closest('a[href]')) {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.#dispatch(action, path)
|
|
139
|
+
// No scrollIntoView — user clicked where they wanted
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Focusin ────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
#onFocusin = (/** @type {FocusEvent} */ event) => {
|
|
145
|
+
const path = getPath(event.target, this.#root)
|
|
146
|
+
|
|
147
|
+
if (path !== null) {
|
|
148
|
+
// Focused a specific item (click, programmatic focus, or tab with roving tabindex)
|
|
149
|
+
this.#wrapper.moveTo(path)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Focused the container itself (user tabbed in, no roving tabindex item yet)
|
|
154
|
+
// Redirect focus to the currently focused item, or first item if none
|
|
155
|
+
const targetKey = this.#wrapper.focusedKey
|
|
156
|
+
const selector = targetKey
|
|
157
|
+
? `[data-path="${targetKey}"]`
|
|
158
|
+
: '[data-path]:not([disabled])'
|
|
159
|
+
const el = /** @type {HTMLElement|null} */ (this.#root.querySelector(selector))
|
|
160
|
+
if (el) {
|
|
161
|
+
el.focus()
|
|
162
|
+
// focusin will re-fire with the item as target, handled above
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Focusout ───────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
#onFocusout = (/** @type {FocusEvent} */ event) => {
|
|
169
|
+
// relatedTarget is the element receiving focus next
|
|
170
|
+
// If it's null or outside this root, focus left the list
|
|
171
|
+
const next = /** @type {Node|null} */ (event.relatedTarget)
|
|
172
|
+
if (!next || !this.#root.contains(next)) {
|
|
173
|
+
// Focus left the list — wrapper can react (e.g. close a dropdown)
|
|
174
|
+
this.#wrapper.blur?.()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Dispatch ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Call wrapper[action](path) for every action.
|
|
182
|
+
* Movement methods (next/prev/first/last/expand/collapse) ignore the path.
|
|
183
|
+
* Selection methods (select/extend/range/toggle) use it.
|
|
184
|
+
* If path is null for a selection action the wrapper falls back to focusedKey.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} action
|
|
187
|
+
* @param {string|null} path
|
|
188
|
+
*/
|
|
189
|
+
#dispatch(action, path) {
|
|
190
|
+
this.#wrapper[action]?.(path)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Focus + scroll ──────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
#syncFocus() {
|
|
196
|
+
const key = this.#wrapper.focusedKey
|
|
197
|
+
if (!key) return
|
|
198
|
+
const el = /** @type {HTMLElement|null} */ (this.#root.querySelector(`[data-path="${key}"]`))
|
|
199
|
+
if (!el) return
|
|
200
|
+
if (el !== document.activeElement) el.focus()
|
|
201
|
+
el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Typeahead ───────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle printable character keys for typeahead search.
|
|
208
|
+
* Returns true if the event was consumed.
|
|
209
|
+
*
|
|
210
|
+
* @param {KeyboardEvent} event
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
#tryTypeahead(event) {
|
|
214
|
+
const { key, ctrlKey, metaKey, altKey } = event
|
|
215
|
+
|
|
216
|
+
// Only single printable characters, no modifier combos
|
|
217
|
+
if (ctrlKey || metaKey || altKey) return false
|
|
218
|
+
if (key.length !== 1) return false
|
|
219
|
+
if (key === ' ') return false // Space is a keymap action, not typeahead
|
|
220
|
+
|
|
221
|
+
const startAfter = this.#buffer.length === 0 ? this.#wrapper.focusedKey : null
|
|
222
|
+
this.#buffer += key
|
|
223
|
+
|
|
224
|
+
// Cancel the existing reset timer but keep the accumulated buffer
|
|
225
|
+
if (this.#bufferTimer) {
|
|
226
|
+
clearTimeout(this.#bufferTimer)
|
|
227
|
+
this.#bufferTimer = null
|
|
228
|
+
}
|
|
229
|
+
this.#bufferTimer = setTimeout(() => this.#clearTypeahead(), TYPEAHEAD_RESET_MS)
|
|
230
|
+
|
|
231
|
+
const matchKey = this.#wrapper.findByText(this.#buffer, startAfter)
|
|
232
|
+
if (matchKey !== null) {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
event.stopPropagation()
|
|
235
|
+
this.#wrapper.moveTo(matchKey)
|
|
236
|
+
this.#syncFocus()
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#clearTypeahead() {
|
|
244
|
+
this.#buffer = ''
|
|
245
|
+
if (this.#bufferTimer) {
|
|
246
|
+
clearTimeout(this.#bufferTimer)
|
|
247
|
+
this.#bufferTimer = null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/reveal.svelte.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scroll-triggered reveal action using IntersectionObserver.
|
|
3
3
|
* Applies CSS transitions (opacity + translate) when element enters viewport.
|
|
4
|
+
* When stagger > 0, applies reveal to each child element independently.
|
|
4
5
|
*
|
|
5
6
|
* @param {HTMLElement} node
|
|
6
7
|
* @param {RevealOptions} [options]
|
|
@@ -10,6 +11,7 @@
|
|
|
10
11
|
* @property {string} [distance='1.5rem'] Slide distance (CSS unit)
|
|
11
12
|
* @property {number} [duration=600] Animation duration (ms)
|
|
12
13
|
* @property {number} [delay=0] Delay before animation starts (ms)
|
|
14
|
+
* @property {number} [stagger=0] Delay increment per child in ms (0 = disabled)
|
|
13
15
|
* @property {boolean} [once=true] Only animate once
|
|
14
16
|
* @property {number} [threshold=0.1] IntersectionObserver threshold (0–1)
|
|
15
17
|
* @property {string} [easing='cubic-bezier(0.4, 0, 0.2, 1)'] CSS easing function
|
|
@@ -21,6 +23,7 @@ export function reveal(node, options = {}) {
|
|
|
21
23
|
distance: '1.5rem',
|
|
22
24
|
duration: 600,
|
|
23
25
|
delay: 0,
|
|
26
|
+
stagger: 0,
|
|
24
27
|
once: true,
|
|
25
28
|
threshold: 0.1,
|
|
26
29
|
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
@@ -31,39 +34,80 @@ export function reveal(node, options = {}) {
|
|
|
31
34
|
typeof window !== 'undefined' &&
|
|
32
35
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
33
36
|
|
|
34
|
-
|
|
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)
|
|
37
|
+
const isStagger = opts.stagger > 0
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
function applyReveal(el) {
|
|
40
|
+
el.style.setProperty('--reveal-duration', `${opts.duration}ms`)
|
|
41
|
+
el.style.setProperty('--reveal-distance', opts.distance)
|
|
42
|
+
el.style.setProperty('--reveal-easing', opts.easing)
|
|
43
|
+
el.setAttribute('data-reveal', opts.direction)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanReveal(el) {
|
|
47
|
+
el.removeAttribute('data-reveal')
|
|
48
|
+
el.removeAttribute('data-reveal-visible')
|
|
49
|
+
el.style.removeProperty('--reveal-duration')
|
|
50
|
+
el.style.removeProperty('--reveal-distance')
|
|
51
|
+
el.style.removeProperty('--reveal-easing')
|
|
52
|
+
el.style.removeProperty('transition-delay')
|
|
53
|
+
}
|
|
41
54
|
|
|
42
|
-
if (
|
|
43
|
-
node.
|
|
55
|
+
if (isStagger) {
|
|
56
|
+
Array.from(node.children).forEach((child) => applyReveal(child))
|
|
57
|
+
} else {
|
|
58
|
+
applyReveal(node)
|
|
59
|
+
if (opts.delay > 0) {
|
|
60
|
+
node.style.transitionDelay = `${opts.delay}ms`
|
|
61
|
+
}
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
if (reducedMotion) {
|
|
47
|
-
|
|
65
|
+
if (isStagger) {
|
|
66
|
+
Array.from(node.children).forEach((child) => child.setAttribute('data-reveal-visible', ''))
|
|
67
|
+
} else {
|
|
68
|
+
node.setAttribute('data-reveal-visible', '')
|
|
69
|
+
}
|
|
48
70
|
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
|
|
49
71
|
return () => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
if (isStagger) {
|
|
73
|
+
Array.from(node.children).forEach((child) => cleanReveal(child))
|
|
74
|
+
} else {
|
|
75
|
+
cleanReveal(node)
|
|
76
|
+
}
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
|
|
80
|
+
let timers = []
|
|
81
|
+
|
|
58
82
|
const observer = new IntersectionObserver(
|
|
59
83
|
(entries) => {
|
|
60
84
|
for (const entry of entries) {
|
|
61
85
|
if (entry.isIntersecting) {
|
|
62
|
-
|
|
86
|
+
if (isStagger) {
|
|
87
|
+
timers.forEach((t) => clearTimeout(t))
|
|
88
|
+
const kids = Array.from(node.children)
|
|
89
|
+
timers = kids.map((child, i) => {
|
|
90
|
+
if (!child.hasAttribute('data-reveal')) applyReveal(child)
|
|
91
|
+
return setTimeout(
|
|
92
|
+
() => child.setAttribute('data-reveal-visible', ''),
|
|
93
|
+
opts.delay + i * opts.stagger
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
} else {
|
|
97
|
+
node.setAttribute('data-reveal-visible', '')
|
|
98
|
+
}
|
|
63
99
|
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: true } }))
|
|
64
100
|
if (opts.once) observer.unobserve(node)
|
|
65
101
|
} else if (!opts.once) {
|
|
66
|
-
|
|
102
|
+
if (isStagger) {
|
|
103
|
+
timers.forEach((t) => clearTimeout(t))
|
|
104
|
+
timers = []
|
|
105
|
+
Array.from(node.children).forEach((child) =>
|
|
106
|
+
child.removeAttribute('data-reveal-visible')
|
|
107
|
+
)
|
|
108
|
+
} else {
|
|
109
|
+
node.removeAttribute('data-reveal-visible')
|
|
110
|
+
}
|
|
67
111
|
node.dispatchEvent(new CustomEvent('reveal', { detail: { visible: false } }))
|
|
68
112
|
}
|
|
69
113
|
}
|
|
@@ -74,13 +118,13 @@ export function reveal(node, options = {}) {
|
|
|
74
118
|
observer.observe(node)
|
|
75
119
|
|
|
76
120
|
return () => {
|
|
121
|
+
timers.forEach((t) => clearTimeout(t))
|
|
77
122
|
observer.disconnect()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (opts.delay > 0) node.style.removeProperty('transition-delay')
|
|
123
|
+
if (isStagger) {
|
|
124
|
+
Array.from(node.children).forEach((child) => cleanReveal(child))
|
|
125
|
+
} else {
|
|
126
|
+
cleanReveal(node)
|
|
127
|
+
}
|
|
84
128
|
}
|
|
85
129
|
})
|
|
86
130
|
}
|
package/src/trigger.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
|
|
25
|
+
export class Trigger {
|
|
26
|
+
#trigger
|
|
27
|
+
#container
|
|
28
|
+
#onopen
|
|
29
|
+
#onclose
|
|
30
|
+
#onlast
|
|
31
|
+
#isOpenFn
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {HTMLElement} trigger — the trigger button element
|
|
35
|
+
* @param {HTMLElement} container — the menu root (for click-outside detection)
|
|
36
|
+
* @param {{ onopen: () => void, onclose: () => void, onlast?: () => void, isOpen: () => boolean }} callbacks
|
|
37
|
+
*/
|
|
38
|
+
constructor(trigger, container, { onopen, onclose, onlast, isOpen }) {
|
|
39
|
+
this.#trigger = trigger
|
|
40
|
+
this.#container = container
|
|
41
|
+
this.#onopen = onopen
|
|
42
|
+
this.#onclose = onclose
|
|
43
|
+
this.#onlast = onlast
|
|
44
|
+
this.#isOpenFn = isOpen
|
|
45
|
+
|
|
46
|
+
trigger.addEventListener('click', this.#handleClick)
|
|
47
|
+
trigger.addEventListener('keydown', this.#handleKeydown)
|
|
48
|
+
document.addEventListener('click', this.#handleDocClick, true)
|
|
49
|
+
document.addEventListener('keydown', this.#handleDocKeydown)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get isOpen() { return this.#isOpenFn() }
|
|
53
|
+
|
|
54
|
+
open() {
|
|
55
|
+
if (this.isOpen) return
|
|
56
|
+
this.#onopen()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
close() {
|
|
60
|
+
if (!this.isOpen) return
|
|
61
|
+
this.#onclose()
|
|
62
|
+
this.#trigger.focus()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Trigger element listeners ────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
#handleClick = (event) => {
|
|
68
|
+
// Ignore clicks from interactive children (e.g. tag remove buttons)
|
|
69
|
+
const closest = event.target.closest('button, [role="button"], a, input, select, textarea')
|
|
70
|
+
if (closest && closest !== this.#trigger) return
|
|
71
|
+
if (this.isOpen) this.close()
|
|
72
|
+
else this.open()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#handleKeydown = (event) => {
|
|
76
|
+
const { key } = event
|
|
77
|
+
if (key === 'Enter' || key === ' ') {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
if (this.isOpen) this.close()
|
|
80
|
+
else this.open()
|
|
81
|
+
} else if (key === 'ArrowDown') {
|
|
82
|
+
event.preventDefault()
|
|
83
|
+
this.open()
|
|
84
|
+
} else if (key === 'ArrowUp') {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
this.open()
|
|
87
|
+
this.#onlast?.()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Document-level listeners ─────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
#handleDocClick = (event) => {
|
|
94
|
+
if (!this.isOpen) return
|
|
95
|
+
if (!this.#container.contains(event.target)) this.close()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#handleDocKeydown = (event) => {
|
|
99
|
+
if (!this.isOpen || event.key !== 'Escape') return
|
|
100
|
+
event.preventDefault()
|
|
101
|
+
this.close()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
destroy() {
|
|
107
|
+
this.#trigger.removeEventListener('click', this.#handleClick)
|
|
108
|
+
this.#trigger.removeEventListener('keydown', this.#handleKeydown)
|
|
109
|
+
document.removeEventListener('click', this.#handleDocClick, true)
|
|
110
|
+
document.removeEventListener('keydown', this.#handleDocKeydown)
|
|
111
|
+
}
|
|
112
|
+
}
|