@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.
@@ -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.125",
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.31.3",
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 modifier keys first (highest priority)
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
+ }
@@ -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
- emitAction(node, options.wrapper, action, true)
117
- // Scroll focused element into view for navigation actions
118
- if (['first', 'last', 'previous', 'next'].includes(action)) {
119
- // Use a small delay to ensure DOM updates are processed
120
- setTimeout(() => scrollFocusedIntoView(node, options.wrapper), 0)
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, 'keyup', handleKeydown), on(node, 'click', handleClick)]
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
- // eslint-disable-next-line no-unused-vars
30
- const matchEvent = ([eventName, keys]) =>
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 modifier keys first (highest priority)
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
  }