Skip to content

Commit 5ce6381

Browse files
committed
fix(focus): correct traversal order
1 parent 7ab9a00 commit 5ce6381

File tree

13 files changed

+386
-88
lines changed

13 files changed

+386
-88
lines changed

.vscode/launch.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
3+
"version": "0.2.0",
4+
"configurations": [
5+
{
6+
"type": "pwa-node",
7+
"request": "launch",
8+
"name": "Debug Current Test File",
9+
"autoAttachChildProcesses": true,
10+
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
11+
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
12+
"args": ["run", "${relativeFile}"],
13+
"smartStep": true,
14+
"console": "integratedTerminal"
15+
}
16+
]
17+
}

packages/cli/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import '@vue/runtime-core'
55

66
declare module '@vue/runtime-core' {
77
export interface GlobalComponents {
8+
Box: typeof import('vue-termui')['TuiBox']
89
Div: typeof import('vue-termui')['TuiBox']
910
Link: typeof import('vue-termui')['TuiLink']
1011
Text: typeof import('vue-termui')['TuiText']

packages/cli/demo/bugs/Focusables.vue

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import {
3+
ref,
4+
reactive,
5+
MouseDataEvent,
6+
MouseEventType,
7+
useStdoutDimensions,
8+
useTitle,
9+
} from 'vue-termui'
10+
import Input from './Input.vue'
11+
12+
const [cols, rows] = useStdoutDimensions()
13+
14+
const disabled = ref(1)
15+
onKeyData(['d', 'D'], () => {
16+
disabled.value = (disabled.value + 1) % 4
17+
})
18+
</script>
19+
20+
<template>
21+
<Box
22+
:width="cols"
23+
:height="30"
24+
justifyContent="center"
25+
flexDirection="column"
26+
alignItems="center"
27+
borderStyle="round"
28+
title="Focusables"
29+
>
30+
<Text underline>Cannot be focused</Text>
31+
<Box title="Other stuff" borderStyle="round">
32+
<Text>I am just some text</Text>
33+
</Box>
34+
<Box title="Focused here" flexDirection="column" borderStyle="round">
35+
<Link
36+
v-for="i in 4"
37+
:href="`https://esm.dev/labs/${i - 1}`"
38+
:disabled="disabled !== i - 1"
39+
>Lab {{ i }}{{ disabled !== i - 1 ? ' (disabled)' : '' }}</Link
40+
>
41+
<Link href="https://example.com">Another one</Link>
42+
</Box>
43+
</Box>
44+
</template>

packages/core/src/app/createApp.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ export function createApp(
7171
log.clear()
7272

7373
const rootEl = new DOMElement('tui:root')
74-
// for debugging purposes
75-
rootEl.toString = () => `<Root>`
7674

7775
const focusManager = createFocusManager(rootEl)
7876

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,83 @@
1-
import { DOMElement } from '../renderer/dom'
21
import { createFocusManager } from './FocusManager'
2+
import { DOMElement, DOMNode, TextNode, CommentNode } from '../renderer/dom'
3+
import {
4+
ComponentInternalInstance,
5+
VNode,
6+
ref,
7+
ComputedRef,
8+
h,
9+
} from '@vue/runtime-core'
10+
import { Focusable } from './types'
11+
12+
/**
13+
* create internal instance
14+
* @param el
15+
* @returns
16+
*/
17+
function focusable(
18+
_el: DOMNode | string,
19+
options: Partial<Focusable> | false = {}
20+
): ComponentInternalInstance {
21+
const el = typeof _el === 'string' ? new TextNode(_el) : _el
22+
const vnode = { el } as VNode<DOMNode, DOMElement>
23+
// @ts-expect-error: we only need the el for focus
24+
const instance = { vnode } as ComponentInternalInstance
25+
26+
el.focusable =
27+
options === false
28+
? null
29+
: {
30+
active: ref(false) as ComputedRef<boolean>,
31+
disabled: ref(false),
32+
id: Symbol(),
33+
_i: instance,
34+
...options,
35+
}
36+
37+
return instance
38+
}
39+
40+
focusable(new TextNode('a'))
41+
focusable('a')
42+
43+
interface DOMTree extends Array<DOMTree | DOMNode | string> {}
44+
45+
function createFocusableTree(
46+
tree: DOMTree,
47+
parent: DOMElement = new DOMElement('tui:root')
48+
) {
49+
tree.forEach((node) => {
50+
if (Array.isArray(node)) {
51+
parent.insertNode(createFocusableTree(node, new DOMElement('tui:box')))
52+
} else {
53+
const ci = focusable(node)
54+
parent.insertNode(ci.vnode.el as DOMNode)
55+
}
56+
})
57+
58+
return parent
59+
}
360

461
describe('FocusManager', () => {
562
it('creates a focus manager', () => {
6-
const root = new DOMElement('tui:root')
63+
const root = createFocusableTree([])
64+
const fm = createFocusManager(root)
65+
66+
expect(fm.activeElement.value).toBeNull()
67+
expect(fm.focusNext()).toBeFalsy()
68+
expect(fm.activeElement.value).toBeNull()
69+
expect(fm.focusPrevious()).toBeFalsy()
70+
expect(fm.activeElement.value).toBeNull()
71+
})
72+
73+
it('loops through one single item with next', () => {
74+
const root = createFocusableTree(['1'])
775
const fm = createFocusManager(root)
876

977
expect(fm.activeElement.value).toBeNull()
10-
expect(fm.focusNext()).toBeUndefined()
78+
expect(fm.focusNext()).toBeFalsy()
1179
expect(fm.activeElement.value).toBeNull()
12-
expect(fm.focusPrevious()).toBeUndefined()
80+
expect(fm.focusPrevious()).toBeFalsy()
1381
expect(fm.activeElement.value).toBeNull()
1482
})
1583
})

packages/core/src/focus/FocusManager.ts

Lines changed: 52 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import {
66
shallowRef,
77
unref,
88
} from '@vue/runtime-core'
9-
import { nextSibling, previousSibling } from '../renderer/nodeOpts'
9+
import {
10+
getLastLeaf,
11+
previousDeepSibling,
12+
nextDeepSibling,
13+
} from '../renderer/nodeOpts'
1014
import { DOMElement, DOMNode, isDOMElement } from '../renderer/dom'
1115
import { checkCurrentInstance, getElementFromInstance, noop } from '../utils'
1216
import { Focusable, FocusId } from './types'
17+
import { indentHTML } from '../utils/indentHTML'
1318

1419
export interface FocusManager {
1520
activeElement: ShallowRef<Focusable | null | undefined>
@@ -26,7 +31,7 @@ export interface FocusManager {
2631
_changeFocusableId(newId: FocusId, oldId: FocusId): void
2732

2833
/**
29-
* Focus a Focusable or remove the current focus by passing `null`.
34+
* Focus a Focusable or remove the current focus by passing `null`. Returns the currently Focusable or null otherwise.
3035
*
3136
* @param id - id of the focusable
3237
*/
@@ -92,62 +97,63 @@ export function createFocusManager(
9297
}
9398
}
9499

95-
const focusNext: FocusManager['focusNext'] = () => {
96-
// the rootNode cannot be focused, so it's safe to start from there
97-
const startNode: DOMNode =
98-
getElementFromInstance(activeElement.value?._i) || rootNode
99-
let node: DOMNode | null = startNode
100-
// startNode === rootNode ? rootNode.childNodes[0] : nextSibling(startNode)
100+
const focusPrevious: FocusManager['focusPrevious'] = () => {
101+
const lastNode = getLastLeaf(rootNode)
102+
const activeNode = getElementFromInstance(activeElement.value?._i)
103+
let startNode = activeNode
104+
let cursor: DOMNode | null | undefined = activeNode
101105

102106
do {
103-
node = nextDeepSibling(node, true)
104-
// we reached the end of the tree and we didn't start at the root node
105-
// so let's try going from the root
106-
if (!node && startNode !== rootNode && cyclic) {
107-
node = nextDeepSibling(rootNode, true)!
107+
// get the previous deep node or the very last node
108+
cursor = (cursor && previousDeepSibling(cursor)) || lastNode
109+
110+
// cursor cannot be empty but startNode can
111+
if (cursor === startNode) {
112+
break
108113
}
114+
// it is safe to set the starter node to the lastNode now
115+
// so we can detect full loops
116+
startNode = startNode || lastNode
109117
} while (
110-
node &&
111-
// we did a full loop
112-
node !== startNode &&
118+
cursor &&
113119
// we skip any non focusable
114-
(!node.focusable ||
120+
(!cursor.focusable ||
115121
// or disabled element
116-
node.focusable.disabled.value)
122+
cursor.focusable.disabled.value)
117123
)
118124

119-
if (node && node.focusable) {
120-
return focus(unref(node.focusable.id))
125+
if (cursor.focusable) {
126+
return focus(unref(cursor.focusable.id))
121127
}
122128
}
123129

124-
const focusPrevious: FocusManager['focusPrevious'] = () => {
125-
// the rootNode cannot be focused, so it's safe to start from there
126-
const lastNode = getLastNode(rootNode)
127-
const startNode: DOMNode =
128-
getElementFromInstance(activeElement.value?._i) || lastNode
129-
let node: DOMNode | null = startNode
130-
// startNode === rootNode ? rootNode.childNodes[0] : nextSibling(startNode)
130+
const focusNext: FocusManager['focusNext'] = () => {
131+
const firstNode = rootNode
132+
const activeNode = getElementFromInstance(activeElement.value?._i)
133+
let startNode = activeNode
134+
let cursor: DOMNode | null | undefined = activeNode
131135

132136
do {
133-
node = nextDeepSibling(node, false)
134-
// we reached the rootNode of the tree and we didn't start at the root node
135-
// so let's try going from the root
136-
if (!node && startNode !== lastNode && cyclic) {
137-
node = nextDeepSibling(lastNode, false)!
137+
// get the next deep node or the very first node (the root)
138+
cursor = (cursor && nextDeepSibling(cursor)) || firstNode
139+
140+
// cursor cannot be empty but startNode can
141+
if (cursor === startNode) {
142+
break
138143
}
144+
// it is safe to set the starter node to the lastNode now
145+
// so we can detect full loops
146+
startNode = startNode || firstNode
139147
} while (
140-
node &&
141-
// we did a full loop
142-
node !== lastNode &&
148+
cursor &&
143149
// we skip any non focusable
144-
(!node.focusable ||
150+
(!cursor.focusable ||
145151
// or disabled element
146-
node.focusable.disabled.value)
152+
cursor.focusable.disabled.value)
147153
)
148154

149-
if (node && node.focusable) {
150-
return focus(unref(node.focusable.id))
155+
if (cursor.focusable) {
156+
return focus(unref(cursor.focusable.id))
151157
}
152158
}
153159

@@ -159,6 +165,7 @@ export function createFocusManager(
159165
const _add: FocusManager['_add'] = (focusable) => {
160166
focusableMap.set(unref(focusable.id), focusable)
161167
const el = getElementFromInstance(focusable._i)
168+
// TODO: can el be an array? If so, should we error and allow only single element root?
162169
if (!el) {
163170
throw new Error('NO VNODE wat')
164171
}
@@ -168,6 +175,12 @@ export function createFocusManager(
168175
const id = unref(focusable.id)
169176
const existingFocusable = focusableMap.get(id)
170177
if (existingFocusable) {
178+
// remove the cyclic referenc
179+
const el = getElementFromInstance(focusable._i)
180+
if (el) {
181+
el.focusable = null
182+
}
183+
171184
focusableMap.delete(id)
172185
// if the focusable being removed is focused, remove focus
173186
if (activeElement.value === existingFocusable) {
@@ -199,41 +212,3 @@ export function createFocusManager(
199212
trapFocus,
200213
}
201214
}
202-
203-
/**
204-
* Gets the next deep sibling. A DFS tree search. Returns `null` if we reached the root.
205-
*
206-
* @param node - node to start at
207-
*/
208-
function nextDeepSibling(node: DOMNode, forward: boolean): DOMNode | null {
209-
// check if the node has children
210-
if (forward && isDOMElement(node) && node.childNodes.length > 0) {
211-
return node.childNodes[0]
212-
} else {
213-
// get the next sibling based on the parent
214-
let nextNode = (forward ? nextSibling : previousSibling)(node)
215-
if (nextNode) return nextNode
216-
// no next sibling, find the closest parent next sibling
217-
nextNode = node.parentNode
218-
while (nextNode) {
219-
const sibling = (forward ? nextSibling : previousSibling)(nextNode)
220-
if (sibling) {
221-
return sibling
222-
}
223-
// try again with the parent
224-
nextNode = nextNode.parentNode
225-
}
226-
227-
// we reached the root
228-
return null
229-
}
230-
}
231-
232-
function getLastNode(node: DOMElement): DOMNode {
233-
let cursor: DOMNode = node
234-
while (isDOMElement(cursor) && cursor.childNodes.length > 0) {
235-
cursor = cursor.childNodes[cursor.childNodes.length - 1]
236-
}
237-
238-
return cursor
239-
}

packages/core/src/focus/Focusable.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ export function useFocus({
7878
focus(unref(id))
7979
}
8080
})
81-
onUnmounted(() => _remove(focusable))
81+
onUnmounted(() => {
82+
_remove(focusable)
83+
// this is okay because the focusable is being destroyed
84+
// @ts-expect-error: avoid cyclic references
85+
focusable._i = null
86+
})
8287

8388
return focusable
8489
}

packages/core/src/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('Full App', () => {
5353

5454
stdin.emit('data', 'A')
5555
await nextTick()
56+
// TODO: we probably need a custom nextTick that triggers after a render
5657
await delay(40)
5758

5859
expect(stdout.write).toHaveBeenLastCalledWith(

0 commit comments

Comments
 (0)