@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.
- package/README.md +189 -1
- package/package.json +20 -31
- package/src/{delegate.js → delegate.svelte.js} +10 -10
- package/src/{dismissable.js → dismissable.svelte.js} +11 -11
- package/src/{fillable.js → fillable.svelte.js} +54 -45
- package/src/hover-lift.svelte.js +64 -0
- package/src/index.js +19 -12
- package/src/kbd.js +191 -0
- package/src/keyboard.svelte.js +59 -0
- package/src/keymap.js +89 -0
- package/src/lib/event-manager.js +35 -18
- package/src/lib/index.js +0 -2
- package/src/lib/internal.js +0 -152
- package/src/magnetic.svelte.js +63 -0
- package/src/nav-constants.js +61 -0
- package/src/navigable.svelte.js +40 -0
- package/src/navigator.js +241 -151
- package/src/navigator.svelte.js +235 -0
- package/src/{pannable.js → pannable.svelte.js} +38 -39
- package/src/reveal.svelte.js +147 -0
- package/src/ripple.svelte.js +92 -0
- package/src/skinnable.svelte.js +12 -0
- package/src/{swipeable.js → swipeable.svelte.js} +79 -89
- package/src/themable.svelte.js +46 -0
- package/src/tooltip.svelte.js +149 -0
- package/src/trigger.js +126 -0
- package/src/types.js +39 -108
- package/src/utils.js +137 -15
- package/LICENSE +0 -21
- package/src/hierarchy.js +0 -156
- package/src/lib/constants.js +0 -35
- package/src/lib/viewport.js +0 -123
- package/src/navigable.js +0 -46
- package/src/switchable.js +0 -52
- package/src/themeable.js +0 -42
- package/src/traversable.js +0 -385
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
12
|
+
* Calculates and returns the distance and duration of the swipe.
|
|
13
13
|
*
|
|
14
|
-
* @param {
|
|
15
|
-
* @param {
|
|
16
|
-
* @returns {
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {
|
|
47
|
-
* @returns {
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* @param {
|
|
95
|
-
* @param {
|
|
96
|
-
* @returns {
|
|
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
|
|
115
|
-
|
|
116
|
-
return speed > minSpeed
|
|
117
|
-
}
|
|
110
|
+
function getListeners(node, options, track) {
|
|
111
|
+
if (!options.enabled) return {}
|
|
118
112
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
119
|
+
return listeners
|
|
136
120
|
}
|
|
121
|
+
|
|
137
122
|
/**
|
|
138
|
-
*
|
|
123
|
+
* A svelte action function that captures swipe actions and emits event for corresponding movements.
|
|
139
124
|
*
|
|
140
|
-
* @param {
|
|
141
|
-
* @param {
|
|
142
|
-
* @returns {
|
|
125
|
+
* @param {HTMLElement} node
|
|
126
|
+
* @param {import(./types).SwipeableOptions} options
|
|
127
|
+
* @returns {import('./types').SvelteActionReturn}
|
|
143
128
|
*/
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|