@rokkit/actions 1.0.0-next.153 → 1.0.0-next.155

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 CHANGED
@@ -15,4 +15,5 @@ export { reveal } from "./reveal.svelte.js";
15
15
  export { hoverLift } from "./hover-lift.svelte.js";
16
16
  export { magnetic } from "./magnetic.svelte.js";
17
17
  export { ripple } from "./ripple.svelte.js";
18
+ export { tooltip } from "./tooltip.svelte.js";
18
19
  export { buildKeymap, resolveAction, ACTIONS } from "./keymap.js";
@@ -2,12 +2,13 @@ export class Navigator {
2
2
  /**
3
3
  * @param {HTMLElement} root
4
4
  * @param {import('@rokkit/states').Wrapper} wrapper
5
- * @param {{ orientation?: string, dir?: string, collapsible?: boolean }} [options]
5
+ * @param {{ orientation?: string, dir?: string, collapsible?: boolean, containScroll?: boolean }} [options]
6
6
  */
7
7
  constructor(root: HTMLElement, wrapper: import("@rokkit/states").Wrapper, options?: {
8
8
  orientation?: string;
9
9
  dir?: string;
10
10
  collapsible?: boolean;
11
+ containScroll?: boolean;
11
12
  });
12
13
  destroy(): void;
13
14
  #private;
@@ -0,0 +1 @@
1
+ export function tooltip(node: any, options?: {}): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.153",
3
+ "version": "1.0.0-next.155",
4
4
  "description": "Contains generic actions that can be used in various components.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.js CHANGED
@@ -17,3 +17,4 @@ export { reveal } from './reveal.svelte.js'
17
17
  export { hoverLift } from './hover-lift.svelte.js'
18
18
  export { magnetic } from './magnetic.svelte.js'
19
19
  export { ripple } from './ripple.svelte.js'
20
+ export { tooltip } from './tooltip.svelte.js'
package/src/navigator.js CHANGED
@@ -70,6 +70,7 @@ export class Navigator {
70
70
  #root
71
71
  #wrapper
72
72
  #keymap
73
+ #containScroll
73
74
 
74
75
  // Typeahead state
75
76
  #buffer = ''
@@ -78,17 +79,21 @@ export class Navigator {
78
79
  /**
79
80
  * @param {HTMLElement} root
80
81
  * @param {import('@rokkit/states').Wrapper} wrapper
81
- * @param {{ orientation?: string, dir?: string, collapsible?: boolean }} [options]
82
+ * @param {{ orientation?: string, dir?: string, collapsible?: boolean, containScroll?: boolean }} [options]
82
83
  */
83
84
  constructor(root, wrapper, options = {}) {
84
85
  this.#root = root
85
86
  this.#wrapper = wrapper
86
87
  this.#keymap = buildKeymap(options)
88
+ this.#containScroll = options.containScroll ?? false
87
89
 
88
90
  root.addEventListener('keydown', this.#onKeydown)
89
91
  root.addEventListener('click', this.#onClick)
90
92
  root.addEventListener('focusin', this.#onFocusin)
91
93
  root.addEventListener('focusout', this.#onFocusout)
94
+ if (this.#containScroll) {
95
+ root.addEventListener('wheel', this.#onWheel, { passive: false })
96
+ }
92
97
  }
93
98
 
94
99
  destroy() {
@@ -96,6 +101,9 @@ export class Navigator {
96
101
  this.#root.removeEventListener('click', this.#onClick)
97
102
  this.#root.removeEventListener('focusin', this.#onFocusin)
98
103
  this.#root.removeEventListener('focusout', this.#onFocusout)
104
+ if (this.#containScroll) {
105
+ this.#root.removeEventListener('wheel', this.#onWheel)
106
+ }
99
107
  this.#clearTypeahead()
100
108
  }
101
109
 
@@ -161,6 +169,14 @@ export class Navigator {
161
169
  }
162
170
  }
163
171
 
172
+ // ─── Wheel ──────────────────────────────────────────────────────────────
173
+
174
+ #onWheel = (/** @type {WheelEvent} */ event) => {
175
+ // Prevent the wheel event from bubbling to parent scroll containers.
176
+ // Native scroll chaining is handled via CSS overscroll-behavior: contain.
177
+ event.stopPropagation()
178
+ }
179
+
164
180
  // ─── Focusout ───────────────────────────────────────────────────────────
165
181
 
166
182
  #onFocusout = (/** @type {FocusEvent} */ event) => {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tooltip action — attaches an accessible tooltip to any element.
3
+ *
4
+ * Triggers on mouseenter (with configurable delay) and focusin.
5
+ * Hides on mouseleave, focusout, and Escape.
6
+ * Auto-flips position when the preferred side overflows the viewport.
7
+ *
8
+ * @param {HTMLElement} node
9
+ * @param {object} [options]
10
+ * @param {string} [options.content=''] Tooltip text
11
+ * @param {'top'|'bottom'|'left'|'right'} [options.position='top'] Preferred position
12
+ * @param {number} [options.delay=300] Show delay in ms
13
+ */
14
+
15
+ const GAP = 6
16
+
17
+ function uid() {
18
+ return `tt-${Math.random().toString(36).slice(2, 9)}`
19
+ }
20
+
21
+ function getPositionedAncestor(el) {
22
+ let parent = el.parentElement
23
+ while (parent && parent !== document.body) {
24
+ const pos = getComputedStyle(parent).position
25
+ if (['relative', 'absolute', 'fixed', 'sticky'].includes(pos)) return parent
26
+ parent = parent.parentElement
27
+ }
28
+ return document.body
29
+ }
30
+
31
+ function resolveFlip(triggerRect, tooltipRect, preferred) {
32
+ const vw = window.innerWidth
33
+ const vh = window.innerHeight
34
+ const fits = {
35
+ top: triggerRect.top >= tooltipRect.height + GAP,
36
+ bottom: triggerRect.bottom + tooltipRect.height + GAP <= vh,
37
+ left: triggerRect.left >= tooltipRect.width + GAP,
38
+ right: triggerRect.right + tooltipRect.width + GAP <= vw
39
+ }
40
+ if (fits[preferred]) return preferred
41
+ const flip = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
42
+ if (fits[flip[preferred]]) return flip[preferred]
43
+ return Object.keys(fits).find((p) => fits[p]) ?? preferred
44
+ }
45
+
46
+ function positionTooltip(trigger, tooltipEl, preferred) {
47
+ const triggerRect = trigger.getBoundingClientRect()
48
+ const container = tooltipEl.parentElement
49
+ const containerRect = container.getBoundingClientRect()
50
+
51
+ // Measure tooltip without layout thrash
52
+ tooltipEl.style.visibility = 'hidden'
53
+ tooltipEl.style.position = 'absolute'
54
+ const tooltipRect = tooltipEl.getBoundingClientRect()
55
+ tooltipEl.style.visibility = ''
56
+
57
+ const pos = resolveFlip(triggerRect, tooltipRect, preferred)
58
+ tooltipEl.setAttribute('data-tooltip-position', pos)
59
+
60
+ let top, left
61
+ switch (pos) {
62
+ case 'top':
63
+ top = triggerRect.top - containerRect.top - tooltipRect.height - GAP
64
+ left = triggerRect.left - containerRect.left + (triggerRect.width - tooltipRect.width) / 2
65
+ break
66
+ case 'bottom':
67
+ top = triggerRect.bottom - containerRect.top + GAP
68
+ left = triggerRect.left - containerRect.left + (triggerRect.width - tooltipRect.width) / 2
69
+ break
70
+ case 'left':
71
+ top = triggerRect.top - containerRect.top + (triggerRect.height - tooltipRect.height) / 2
72
+ left = triggerRect.left - containerRect.left - tooltipRect.width - GAP
73
+ break
74
+ case 'right':
75
+ top = triggerRect.top - containerRect.top + (triggerRect.height - tooltipRect.height) / 2
76
+ left = triggerRect.right - containerRect.left + GAP
77
+ break
78
+ default:
79
+ top = 0
80
+ left = 0
81
+ }
82
+
83
+ tooltipEl.style.top = `${top}px`
84
+ tooltipEl.style.left = `${left}px`
85
+ }
86
+
87
+ export function tooltip(node, options = {}) {
88
+ $effect(() => {
89
+ const opts = { content: '', position: 'top', delay: 300, ...options }
90
+ const id = uid()
91
+
92
+ const el = document.createElement('div')
93
+ el.setAttribute('data-tooltip-content', '')
94
+ el.setAttribute('data-tooltip-position', opts.position)
95
+ el.setAttribute('data-tooltip-visible', 'false')
96
+ el.id = id
97
+ el.setAttribute('role', 'tooltip')
98
+ el.textContent = opts.content
99
+
100
+ node.setAttribute('data-tooltip-trigger', '')
101
+ node.setAttribute('aria-describedby', id)
102
+
103
+ const container = getPositionedAncestor(node)
104
+ container.appendChild(el)
105
+
106
+ let timer = null
107
+
108
+ function show() {
109
+ positionTooltip(node, el, opts.position)
110
+ el.setAttribute('data-tooltip-visible', 'true')
111
+ }
112
+
113
+ function hide() {
114
+ clearTimeout(timer)
115
+ el.setAttribute('data-tooltip-visible', 'false')
116
+ }
117
+
118
+ function onMouseEnter() {
119
+ timer = setTimeout(show, opts.delay)
120
+ }
121
+
122
+ function onMouseLeave() {
123
+ clearTimeout(timer)
124
+ hide()
125
+ }
126
+
127
+ function onKeydown(e) {
128
+ if (e.key === 'Escape') hide()
129
+ }
130
+
131
+ node.addEventListener('mouseenter', onMouseEnter)
132
+ node.addEventListener('mouseleave', onMouseLeave)
133
+ node.addEventListener('focusin', show)
134
+ node.addEventListener('focusout', hide)
135
+ node.addEventListener('keydown', onKeydown)
136
+
137
+ return () => {
138
+ clearTimeout(timer)
139
+ node.removeAttribute('data-tooltip-trigger')
140
+ node.removeAttribute('aria-describedby')
141
+ node.removeEventListener('mouseenter', onMouseEnter)
142
+ node.removeEventListener('mouseleave', onMouseLeave)
143
+ node.removeEventListener('focusin', show)
144
+ node.removeEventListener('focusout', hide)
145
+ node.removeEventListener('keydown', onKeydown)
146
+ el.remove()
147
+ }
148
+ })
149
+ }