@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.
@@ -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,40 @@
1
+ import { on } from 'svelte/events'
2
+ import { getKeyboardActions, defaultNavigationOptions } from './kbd'
3
+
4
+ // Handle keyboard events
5
+ function handleAction(actions, event) {
6
+ if (event.key in actions) {
7
+ event.preventDefault()
8
+ event.stopPropagation()
9
+ actions[event.key]()
10
+ }
11
+ }
12
+ /**
13
+ * A svelte action function that captures keyboard evvents and emits event for corresponding movements.
14
+ *
15
+ * @param {HTMLElement} node
16
+ * @param {import('./types').NavigableOptions} options
17
+ * @returns {import('./types').SvelteActionReturn}
18
+ */
19
+ export function navigable(node, options) {
20
+ const handlers = {
21
+ previous: () => node.dispatchEvent(new CustomEvent('previous')),
22
+ next: () => node.dispatchEvent(new CustomEvent('next')),
23
+ collapse: () => node.dispatchEvent(new CustomEvent('collapse')),
24
+ expand: () => node.dispatchEvent(new CustomEvent('expand')),
25
+ select: () => node.dispatchEvent(new CustomEvent('select'))
26
+ }
27
+
28
+ let actions = {}
29
+ const handleKeydown = (event) => handleAction(actions, event)
30
+
31
+ $effect(() => {
32
+ // Use defaultNavigationOptions from kbd.js
33
+ const props = { ...defaultNavigationOptions, ...options }
34
+ actions = getKeyboardActions(props, handlers)
35
+
36
+ const cleanup = on(node, 'keyup', handleKeydown)
37
+
38
+ return () => cleanup()
39
+ })
40
+ }
package/src/navigator.js CHANGED
@@ -1,182 +1,272 @@
1
- import { handleAction } from './utils'
2
- import { noop, isNested, hasChildren, isExpanded } from '@rokkit/core'
3
- import {
4
- moveNext,
5
- movePrevious,
6
- pathFromIndices,
7
- indicesFromPath,
8
- getCurrentNode
9
- } from './hierarchy'
10
- import { mapKeyboardEventsToActions } from './lib'
11
1
  /**
12
- * Keyboard navigation for Lists and NestedLists. The data is either nested or not and is not
13
- * expected to switch from nested to simple list or vice-versa.
2
+ * Navigator
14
3
  *
15
- * @param {HTMLElement} element - Root element for the actionn
16
- * @param {import('./types').NavigatorOptions} options - Configuration options for the action
17
- * @returns
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
+ * })
18
25
  */
19
- export function navigator(element, options) {
20
- const { fields, enabled = true, vertical = true, idPrefix = 'id-' } = options
21
- let items = [],
22
- path = null,
23
- currentNode = null
24
-
25
- if (!enabled) return { destroy: noop }
26
-
27
- const update = (input) => {
28
- const previousNode = currentNode
29
- items = input.items
30
- path = pathFromIndices(input.indices ?? [], items, fields)
31
- currentNode = getCurrentNode(path)
32
-
33
- if (previousNode !== currentNode && currentNode) {
34
- const indices = indicesFromPath(path)
35
- const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
36
- if (current) {
37
- current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
38
- }
39
- }
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
+ if (shiftKey) return 'range'
42
+ if (ctrlKey) return 'extend'
43
+ if (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
40
63
  }
64
+ return null
65
+ }
41
66
 
42
- const next = () => {
43
- const previousNode = currentNode
44
- path = moveNext(path, items, fields)
45
- currentNode = getCurrentNode(path)
46
- if (previousNode !== currentNode) moveTo(element, path, currentNode, idPrefix)
67
+ // ─── Navigator ────────────────────────────────────────────────────────────────
68
+
69
+ export class Navigator {
70
+ #root
71
+ #wrapper
72
+ #keymap
73
+ #containScroll
74
+
75
+ // Typeahead state
76
+ #buffer = ''
77
+ #bufferTimer = null
78
+
79
+ /**
80
+ * @param {HTMLElement} root
81
+ * @param {import('@rokkit/states').Wrapper} wrapper
82
+ * @param {{ orientation?: string, dir?: string, collapsible?: boolean, containScroll?: boolean }} [options]
83
+ */
84
+ constructor(root, wrapper, options = {}) {
85
+ this.#root = root
86
+ this.#wrapper = wrapper
87
+ this.#keymap = buildKeymap(options)
88
+ this.#containScroll = options.containScroll ?? false
89
+
90
+ root.addEventListener('keydown', this.#onKeydown)
91
+ root.addEventListener('click', this.#onClick)
92
+ root.addEventListener('focusin', this.#onFocusin)
93
+ root.addEventListener('focusout', this.#onFocusout)
94
+ if (this.#containScroll) {
95
+ root.addEventListener('wheel', this.#onWheel, { passive: false })
96
+ }
47
97
  }
48
98
 
49
- const previous = () => {
50
- const previousNode = currentNode
51
- path = movePrevious(path)
52
- if (path.length > 0) {
53
- currentNode = getCurrentNode(path)
54
- if (previousNode !== currentNode) moveTo(element, path, currentNode, idPrefix)
99
+ destroy() {
100
+ this.#root.removeEventListener('keydown', this.#onKeydown)
101
+ this.#root.removeEventListener('click', this.#onClick)
102
+ this.#root.removeEventListener('focusin', this.#onFocusin)
103
+ this.#root.removeEventListener('focusout', this.#onFocusout)
104
+ if (this.#containScroll) {
105
+ this.#root.removeEventListener('wheel', this.#onWheel)
55
106
  }
107
+ this.#clearTypeahead()
56
108
  }
57
- const select = () => {
58
- if (currentNode) emit('select', element, indicesFromPath(path), currentNode)
109
+
110
+ // ─── Keydown ────────────────────────────────────────────────────────────
111
+
112
+ #onKeydown = (/** @type {KeyboardEvent} */ event) => {
113
+ // Typeahead: single printable character (no modifiers except shift for caps)
114
+ if (this.#tryTypeahead(event)) return
115
+
116
+ const action = resolveAction(event, this.#keymap)
117
+ if (!action) return
118
+
119
+ // Links handle Enter/Space natively — browser fires a synthetic click
120
+ if (action === 'select' && event.target.closest('a[href]')) return
121
+
122
+ event.preventDefault()
123
+ event.stopPropagation()
124
+
125
+ // Resolve current path from the focused element so all actions get context
126
+ const path = getPath(document.activeElement, this.#root)
127
+ this.#dispatch(action, path)
128
+
129
+ // Scroll focused item into view after keyboard navigation
130
+ this.#syncFocus()
59
131
  }
60
- const collapse = () => {
61
- if (currentNode) {
62
- const expanded = isExpanded(currentNode, path[path.length - 1].fields)
63
- if (expanded) {
64
- toggle()
65
- } else if (path.length > 0) {
66
- path = path.slice(0, -1)
67
- currentNode = getCurrentNode(path)
68
- select()
69
- }
132
+
133
+ // ─── Click ──────────────────────────────────────────────────────────────
134
+
135
+ #onClick = (/** @type {MouseEvent} */ event) => {
136
+ const path = getPath(event.target, this.#root)
137
+ if (path === null) return
138
+
139
+ const action = getClickAction(event)
140
+
141
+ // Links: let browser navigate naturally, still update state
142
+ if (!event.target.closest('a[href]')) {
143
+ event.preventDefault()
70
144
  }
145
+
146
+ this.#dispatch(action, path)
147
+ // No scrollIntoView — user clicked where they wanted
71
148
  }
72
- const expand = () => {
73
- if (currentNode && hasChildren(currentNode, path[path.length - 1].fields)) {
74
- toggle()
149
+
150
+ // ─── Focusin ────────────────────────────────────────────────────────────
151
+
152
+ #onFocusin = (/** @type {FocusEvent} */ event) => {
153
+ const path = getPath(event.target, this.#root)
154
+
155
+ if (path !== null) {
156
+ // Focused a specific item (click, programmatic focus, or tab with roving tabindex)
157
+ this.#wrapper.moveTo(path)
158
+ return
159
+ }
160
+
161
+ // Focused the container itself (user tabbed in, no roving tabindex item yet)
162
+ // Redirect focus to the currently focused item, or first item if none
163
+ const targetKey = this.#wrapper.focusedKey
164
+ const selector = targetKey ? `[data-path="${targetKey}"]` : '[data-path]:not([disabled])'
165
+ const el = /** @type {HTMLElement|null} */ (this.#root.querySelector(selector))
166
+ if (el) {
167
+ el.focus()
168
+ // focusin will re-fire with the item as target, handled above
75
169
  }
76
170
  }
77
- function toggle() {
78
- const expanded = isExpanded(currentNode, path[path.length - 1].fields)
79
- const event = expanded ? 'collapse' : 'expand'
80
- currentNode[path[path.length - 1].fields.isOpen] = !expanded
81
- emit(event, element, indicesFromPath(path), currentNode)
82
- }
83
- const handlers = { next, previous, select, collapse, expand }
84
171
 
85
- update(options)
172
+ // ─── Wheel ──────────────────────────────────────────────────────────────
86
173
 
87
- const nested = isNested(items, fields)
88
- const actions = mapKeyboardEventsToActions(handlers, {
89
- horizontal: !vertical,
90
- nested
91
- })
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
+ }
92
179
 
93
- const handleKeyDown = (event) => handleAction(actions, event)
180
+ // ─── Focusout ───────────────────────────────────────────────────────────
94
181
 
95
- const handleClick = (event) => {
96
- event.stopPropagation()
97
- const target = findParentWithDataPath(event.target, element)
98
- const indices = !target
99
- ? []
100
- : target.dataset.path
101
- .split(',')
102
- .filter((item) => item !== '')
103
- .map((item) => Number(item))
104
-
105
- if (indices.length > 0 && event.target.tagName !== 'DETAIL') {
106
- path = pathFromIndices(indices, items, fields)
107
- currentNode = getCurrentNode(path)
108
- if (hasChildren(currentNode, path[path.length - 1].fields)) {
109
- currentNode[path[path.length - 1].fields.isOpen] =
110
- !currentNode[path[path.length - 1].fields.isOpen]
111
- const eventName = currentNode[path[path.length - 1].fields.isOpen] ? 'expand' : 'collapse'
112
- emit(eventName, element, indices, currentNode)
113
- } else if (currentNode !== null) emit('select', element, indices, currentNode)
114
- emit('move', element, indices, currentNode)
115
- // emit('select', element, indices, currentNode)
182
+ #onFocusout = (/** @type {FocusEvent} */ event) => {
183
+ // relatedTarget is the element receiving focus next
184
+ // If it's null or outside this root, focus left the list
185
+ const next = /** @type {Node|null} */ (event.relatedTarget)
186
+ if (!next || !this.#root.contains(next)) {
187
+ // Focus left the list — wrapper can react (e.g. close a dropdown)
188
+ this.#wrapper.blur?.()
116
189
  }
117
190
  }
118
191
 
119
- element.addEventListener('keydown', handleKeyDown)
120
- element.addEventListener('click', handleClick)
192
+ // ─── Dispatch ───────────────────────────────────────────────────────────
121
193
 
122
- return {
123
- update,
124
- destroy() {
125
- element.removeEventListener('keydown', handleKeyDown)
126
- element.removeEventListener('click', handleClick)
127
- }
194
+ /**
195
+ * Call wrapper[action](path) for every action.
196
+ * Movement methods (next/prev/first/last/expand/collapse) ignore the path.
197
+ * Selection methods (select/extend/range/toggle) use it.
198
+ * If path is null for a selection action the wrapper falls back to focusedKey.
199
+ *
200
+ * @param {string} action
201
+ * @param {string|null} path
202
+ */
203
+ #dispatch(action, path) {
204
+ this.#wrapper[action]?.(path)
128
205
  }
129
- }
130
206
 
131
- /**
132
- * Move to the element with the given path
133
- *
134
- * @param {HTMLElement} element
135
- * @param {*} path
136
- * @param {*} currentNode
137
- * @param {*} idPrefix
138
- */
139
- export function moveTo(element, path, currentNode, idPrefix) {
140
- const indices = indicesFromPath(path)
141
- const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
142
- if (current) current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
207
+ // ─── Focus + scroll ──────────────────────────────────────────────────────
143
208
 
144
- emit('move', element, indices, currentNode)
145
- }
209
+ #syncFocus() {
210
+ const key = this.#wrapper.focusedKey
211
+ if (!key) return
212
+ const el = /** @type {HTMLElement|null} */ (this.#root.querySelector(`[data-path="${key}"]`))
213
+ if (!el) return
214
+ if (el !== document.activeElement) el.focus()
215
+ el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
216
+ }
146
217
 
147
- /**
148
- * Find the parent element with data-path attribute
149
- *
150
- * @param {HTMLElement} element
151
- * @param {HTMLElement} root
152
- * @returns {HTMLElement}
153
- */
154
- export function findParentWithDataPath(element, root) {
155
- if (element.hasAttribute('data-path')) return element
156
- let parent = element.parentNode
218
+ // ─── Typeahead ───────────────────────────────────────────────────────────
157
219
 
158
- while (parent && parent !== root && !parent.hasAttribute('data-path')) {
159
- parent = parent.parentNode
220
+ #isPrintableKey(key, ctrlKey, metaKey, altKey) {
221
+ if (ctrlKey) return false
222
+ if (metaKey) return false
223
+ if (altKey) return false
224
+ if (key.length !== 1) return false
225
+ return key !== ' '
160
226
  }
161
227
 
162
- return parent !== root ? parent : null
163
- }
228
+ #appendBuffer(key) {
229
+ const startAfter = this.#buffer.length === 0 ? this.#wrapper.focusedKey : null
230
+ this.#buffer += key
231
+ if (this.#bufferTimer) {
232
+ clearTimeout(this.#bufferTimer)
233
+ this.#bufferTimer = null
234
+ }
235
+ this.#bufferTimer = setTimeout(() => this.#clearTypeahead(), TYPEAHEAD_RESET_MS)
236
+ return startAfter
237
+ }
164
238
 
165
- /**
166
- * Emit a custom event on the element with the path and node as detail
167
- *
168
- * @param {string} event
169
- * @param {HTMLElement} element
170
- * @param {Array<integer>} indices
171
- * @param {*} node
172
- */
173
- function emit(event, element, indices, node) {
174
- element.dispatchEvent(
175
- new CustomEvent(event, {
176
- detail: {
177
- path: indices,
178
- node
179
- }
180
- })
181
- )
239
+ /**
240
+ * Handle printable character keys for typeahead search.
241
+ * Returns true if the event was consumed.
242
+ *
243
+ * @param {KeyboardEvent} event
244
+ * @returns {boolean}
245
+ */
246
+ #tryTypeahead(event) {
247
+ const { key, ctrlKey, metaKey, altKey } = event
248
+
249
+ if (!this.#isPrintableKey(key, ctrlKey, metaKey, altKey)) return false
250
+
251
+ const startAfter = this.#appendBuffer(key)
252
+
253
+ const matchKey = this.#wrapper.findByText(this.#buffer, startAfter)
254
+ if (matchKey !== null) {
255
+ event.preventDefault()
256
+ event.stopPropagation()
257
+ this.#wrapper.moveTo(matchKey)
258
+ this.#syncFocus()
259
+ return true
260
+ }
261
+
262
+ return false
263
+ }
264
+
265
+ #clearTypeahead() {
266
+ this.#buffer = ''
267
+ if (this.#bufferTimer) {
268
+ clearTimeout(this.#bufferTimer)
269
+ this.#bufferTimer = null
270
+ }
271
+ }
182
272
  }