@rokkit/actions 1.0.0-next.99 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { removeListeners, setupListeners } from './lib'
1
+ import { EventManager } from './lib'
2
2
 
3
3
  const defaultOptions = {
4
4
  horizontal: true,
@@ -9,53 +9,65 @@ const defaultOptions = {
9
9
  }
10
10
 
11
11
  /**
12
- * A svelte action function that captures swipe actions and emits event for corresponding movements.
12
+ * Calculates and returns the distance and duration of the swipe.
13
13
  *
14
- * @param {HTMLElement} node
15
- * @param {import(./types).SwipeableOptions} options
16
- * @returns {import('./types').SvelteActionReturn}
14
+ * @param {Event} event - The event object that initiated the touchEnd.
15
+ * @param {object} track - The tracking object holding the start of the touch action.
16
+ * @returns {{distance: {x: number, y: number}, duration: number}} The distance swiped (x and y) and the duration of the swipe.
17
17
  */
18
- export function swipeable(node, options = defaultOptions) {
19
- const track = {}
20
- let listeners = {}
21
-
22
- const updateListeners = (props) => {
23
- removeListeners(node, listeners)
24
- listeners = getListeners(node, props, track)
25
- setupListeners(node, listeners, props)
26
- }
18
+ function getTouchMetrics(event, track) {
19
+ const touch = event.changedTouches ? event.changedTouches[0] : event
20
+ const distX = touch.clientX - track.startX
21
+ const distY = touch.clientY - track.startY
22
+ const duration = (new Date().getTime() - track.startTime) / 1000
23
+ return { distance: { x: distX, y: distY }, duration }
24
+ }
27
25
 
28
- options = { ...defaultOptions, ...options }
29
- updateListeners(options)
26
+ /**
27
+ * Checks if the swipe was fast enough according to the minimum speed requirement.
28
+ *
29
+ * @param {{x: number, y: number}} distance - The distance of the swipe action.
30
+ * @param {number} duration - The duration of the swipe action in seconds.
31
+ * @param {number} minSpeed - The minimum speed threshold for the swipe action.
32
+ * @returns {boolean} True if the swipe is fast enough, otherwise false.
33
+ */
34
+ function isSwipeFastEnough(distance, duration, minSpeed) {
35
+ const speed = Math.max(Math.abs(distance.x), Math.abs(distance.y)) / duration
36
+ return speed > minSpeed
37
+ }
30
38
 
31
- return {
32
- update: (data) => {
33
- options = { ...options, ...data }
34
- updateListeners(options)
35
- },
36
- destroy() {
37
- removeListeners(node, listeners)
38
- }
39
+ /**
40
+ * Returns the swipe direction based on the distance in the x and y axis.
41
+ *
42
+ * @param {number} distX - The distance in the x axis.
43
+ * @param {number} distY - The distance in the y axis.
44
+ * @returns {string} The swipe direction.
45
+ */
46
+ function getSwipeDirection(distX, distY) {
47
+ if (Math.abs(distX) > Math.abs(distY)) {
48
+ return distX > 0 ? 'Right' : 'Left'
49
+ } else {
50
+ return distY > 0 ? 'Down' : 'Up'
39
51
  }
40
52
  }
41
53
 
42
54
  /**
43
- * Returns the listeners for the swipeable action.
44
- * @param {HTMLElement} node - The node where the event is dispatched.
45
- * @param {import(./types).SwipeableOptions} options - The options for the swipe.
46
- * @param {import(./types).TouchTracker} track - The tracking object.
47
- * @returns {import(./types).Listeners}
55
+ * Determines swipe validity and direction based on horizontal/vertical preferences and thresholds.
56
+ *
57
+ * @param {{x: number, y: number}} distance - The distance of the swipe.
58
+ * @param {object} options - Configuration options such as direction preferences and thresholds.
59
+ * @returns {{isValid: boolean, direction?: string}} Object indicating whether the swipe is valid, and if so, its direction.
48
60
  */
49
- function getListeners(node, options, track) {
50
- if (!options.enabled) return {}
51
-
52
- const listeners = {
53
- touchend: (e) => touchEnd(e, node, options, track),
54
- touchstart: (e) => touchStart(e, track),
55
- mousedown: (e) => touchStart(e, track),
56
- mouseup: (e) => touchEnd(e, node, options, track)
61
+ function getSwipeDetails(distance, options) {
62
+ const isHorizontalSwipe = options.horizontal && Math.abs(distance.x) >= options.threshold
63
+ const isVerticalSwipe = options.vertical && Math.abs(distance.y) >= options.threshold
64
+ if (isHorizontalSwipe || isVerticalSwipe) {
65
+ return {
66
+ isValid: true,
67
+ direction: getSwipeDirection(distance.x, distance.y)
68
+ }
57
69
  }
58
- return listeners
70
+ return { isValid: false }
59
71
  }
60
72
 
61
73
  /**
@@ -89,62 +101,40 @@ function touchEnd(event, node, options, track) {
89
101
  }
90
102
 
91
103
  /**
92
- * Calculates and returns the distance and duration of the swipe.
93
- *
94
- * @param {Event} event - The event object that initiated the touchEnd.
95
- * @param {object} track - The tracking object holding the start of the touch action.
96
- * @returns {{distance: {x: number, y: number}, duration: number}} The distance swiped (x and y) and the duration of the swipe.
97
- */
98
- function getTouchMetrics(event, track) {
99
- const touch = event.changedTouches ? event.changedTouches[0] : event
100
- const distX = touch.clientX - track.startX
101
- const distY = touch.clientY - track.startY
102
- const duration = (new Date().getTime() - track.startTime) / 1000
103
- return { distance: { x: distX, y: distY }, duration }
104
- }
105
-
106
- /**
107
- * Checks if the swipe was fast enough according to the minimum speed requirement.
108
- *
109
- * @param {{x: number, y: number}} distance - The distance of the swipe action.
110
- * @param {number} duration - The duration of the swipe action in seconds.
111
- * @param {number} minSpeed - The minimum speed threshold for the swipe action.
112
- * @returns {boolean} True if the swipe is fast enough, otherwise false.
104
+ * Returns the listeners for the swipeable action.
105
+ * @param {HTMLElement} node - The node where the event is dispatched.
106
+ * @param {import(./types).SwipeableOptions} options - The options for the swipe.
107
+ * @param {import(./types).TouchTracker} track - The tracking object.
108
+ * @returns {import(./types).Listeners}
113
109
  */
114
- function isSwipeFastEnough(distance, duration, minSpeed) {
115
- const speed = Math.max(Math.abs(distance.x), Math.abs(distance.y)) / duration
116
- return speed > minSpeed
117
- }
110
+ function getListeners(node, options, track) {
111
+ if (!options.enabled) return {}
118
112
 
119
- /**
120
- * Determines swipe validity and direction based on horizontal/vertical preferences and thresholds.
121
- *
122
- * @param {{x: number, y: number}} distance - The distance of the swipe.
123
- * @param {object} options - Configuration options such as direction preferences and thresholds.
124
- * @returns {{isValid: boolean, direction?: string}} Object indicating whether the swipe is valid, and if so, its direction.
125
- */
126
- function getSwipeDetails(distance, options) {
127
- const isHorizontalSwipe = options.horizontal && Math.abs(distance.x) >= options.threshold
128
- const isVerticalSwipe = options.vertical && Math.abs(distance.y) >= options.threshold
129
- if (isHorizontalSwipe || isVerticalSwipe) {
130
- return {
131
- isValid: true,
132
- direction: getSwipeDirection(distance.x, distance.y)
133
- }
113
+ const listeners = {
114
+ touchend: (e) => touchEnd(e, node, options, track),
115
+ touchstart: (e) => touchStart(e, track),
116
+ mousedown: (e) => touchStart(e, track),
117
+ mouseup: (e) => touchEnd(e, node, options, track)
134
118
  }
135
- return { isValid: false }
119
+ return listeners
136
120
  }
121
+
137
122
  /**
138
- * Returns the swipe direction based on the distance in the x and y axis.
123
+ * A svelte action function that captures swipe actions and emits event for corresponding movements.
139
124
  *
140
- * @param {number} distX - The distance in the x axis.
141
- * @param {number} distY - The distance in the y axis.
142
- * @returns {string} The swipe direction.
125
+ * @param {HTMLElement} node
126
+ * @param {import(./types).SwipeableOptions} options
127
+ * @returns {import('./types').SvelteActionReturn}
143
128
  */
144
- function getSwipeDirection(distX, distY) {
145
- if (Math.abs(distX) > Math.abs(distY)) {
146
- return distX > 0 ? 'Right' : 'Left'
147
- } else {
148
- return distY > 0 ? 'Down' : 'Up'
149
- }
129
+ export function swipeable(node, options = defaultOptions) {
130
+ const track = {}
131
+ const manager = EventManager(node)
132
+
133
+ $effect(() => {
134
+ const props = { ...defaultOptions, ...options }
135
+ const listeners = getListeners(node, props, track)
136
+ manager.update(listeners, props.enabled)
137
+
138
+ return () => manager.reset()
139
+ })
150
140
  }
@@ -0,0 +1,46 @@
1
+ const DEFAULT_THEME = { style: 'rokkit', mode: 'dark', density: 'comfortable' }
2
+
3
+ /**
4
+ * Update the theme attributes when the state changes.
5
+ *
6
+ * @param {HTMLElement} root
7
+ * @param {import('./types.js').ThemableConfig} options - Custom key mappings
8
+ */
9
+ export function themable(root, options) {
10
+ const { theme = DEFAULT_THEME, storageKey } = options ?? {}
11
+
12
+ if (storageKey) {
13
+ // Initial load from storage
14
+ theme.load(storageKey)
15
+
16
+ // Save changes to storage
17
+ $effect(() => {
18
+ theme.save(storageKey)
19
+ })
20
+
21
+ // Handle storage events
22
+ const handleStorage = (event) => {
23
+ if (event.key === storageKey && event.newValue !== null) {
24
+ try {
25
+ const newTheme = JSON.parse(event.newValue)
26
+ theme.update(newTheme)
27
+ } catch (e) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn(`Failed to parse theme from storage event for key "${storageKey}"`, e)
30
+ }
31
+ }
32
+ }
33
+ // Set up storage event listener
34
+ $effect.root(() => {
35
+ window.addEventListener('storage', handleStorage)
36
+ return () => window.removeEventListener('storage', handleStorage)
37
+ })
38
+ }
39
+ $effect(() => {
40
+ root.dataset.style = theme.style
41
+ root.dataset.mode = theme.mode
42
+ root.dataset.density = theme.density
43
+
44
+ // if (storageKey) theme.save(storageKey)
45
+ })
46
+ }
@@ -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
+ }
package/src/trigger.js ADDED
@@ -0,0 +1,126 @@
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
+ function handleToggleKey(self) {
26
+ if (self.isOpen) self.close()
27
+ else self.open()
28
+ }
29
+
30
+ function handleArrowDown(self) {
31
+ self.open()
32
+ }
33
+
34
+ function handleArrowUp(self, onlast) {
35
+ self.open()
36
+ onlast?.()
37
+ }
38
+
39
+ export class Trigger {
40
+ #trigger
41
+ #container
42
+ #onopen
43
+ #onclose
44
+ #onlast
45
+ #isOpenFn
46
+
47
+ /**
48
+ * @param {HTMLElement} trigger — the trigger button element
49
+ * @param {HTMLElement} container — the menu root (for click-outside detection)
50
+ * @param {{ onopen: () => void, onclose: () => void, onlast?: () => void, isOpen: () => boolean }} callbacks
51
+ */
52
+ constructor(trigger, container, { onopen, onclose, onlast, isOpen }) {
53
+ this.#trigger = trigger
54
+ this.#container = container
55
+ this.#onopen = onopen
56
+ this.#onclose = onclose
57
+ this.#onlast = onlast
58
+ this.#isOpenFn = isOpen
59
+
60
+ trigger.addEventListener('click', this.#handleClick)
61
+ trigger.addEventListener('keydown', this.#handleKeydown)
62
+ document.addEventListener('click', this.#handleDocClick, true)
63
+ document.addEventListener('keydown', this.#handleDocKeydown)
64
+ }
65
+
66
+ get isOpen() {
67
+ return this.#isOpenFn()
68
+ }
69
+
70
+ open() {
71
+ if (this.isOpen) return
72
+ this.#onopen()
73
+ }
74
+
75
+ close() {
76
+ if (!this.isOpen) return
77
+ this.#onclose()
78
+ this.#trigger.focus()
79
+ }
80
+
81
+ // ─── Trigger element listeners ────────────────────────────────────────────
82
+
83
+ #handleClick = (event) => {
84
+ // Ignore clicks from interactive children (e.g. tag remove buttons)
85
+ const closest = event.target.closest('button, [role="button"], a, input, select, textarea')
86
+ if (closest && closest !== this.#trigger) return
87
+ if (this.isOpen) this.close()
88
+ else this.open()
89
+ }
90
+
91
+ #handleKeydown = (event) => {
92
+ const { key } = event
93
+ if (key === 'Enter' || key === ' ') {
94
+ event.preventDefault()
95
+ handleToggleKey(this)
96
+ } else if (key === 'ArrowDown') {
97
+ event.preventDefault()
98
+ handleArrowDown(this)
99
+ } else if (key === 'ArrowUp') {
100
+ event.preventDefault()
101
+ handleArrowUp(this, this.#onlast)
102
+ }
103
+ }
104
+
105
+ // ─── Document-level listeners ─────────────────────────────────────────────
106
+
107
+ #handleDocClick = (event) => {
108
+ if (!this.isOpen) return
109
+ if (!this.#container.contains(event.target)) this.close()
110
+ }
111
+
112
+ #handleDocKeydown = (event) => {
113
+ if (!this.isOpen || event.key !== 'Escape') return
114
+ event.preventDefault()
115
+ this.close()
116
+ }
117
+
118
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
119
+
120
+ destroy() {
121
+ this.#trigger.removeEventListener('click', this.#handleClick)
122
+ this.#trigger.removeEventListener('keydown', this.#handleKeydown)
123
+ document.removeEventListener('click', this.#handleDocClick, true)
124
+ document.removeEventListener('keydown', this.#handleDocKeydown)
125
+ }
126
+ }