@rokkit/actions 1.0.0-next.127 → 1.0.0-next.128

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
@@ -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";
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.127",
3
+ "version": "1.0.0-next.128",
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",
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
@@ -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/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
+ }