@rokkit/actions 1.0.0-next.98 → 1.0.1
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
|
@@ -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
|
-
*
|
|
13
|
-
* expected to switch from nested to simple list or vice-versa.
|
|
2
|
+
* Navigator
|
|
14
3
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
172
|
+
// ─── Wheel ──────────────────────────────────────────────────────────────
|
|
86
173
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
180
|
+
// ─── Focusout ───────────────────────────────────────────────────────────
|
|
94
181
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
120
|
-
element.addEventListener('click', handleClick)
|
|
192
|
+
// ─── Dispatch ───────────────────────────────────────────────────────────
|
|
121
193
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
}
|