Skip to content

Commit 9102386

Browse files
authored
fix(FocusScope): don't move focus if DOM mutation occurred before any nodes had focus (#2546)
* fix(FocusScope): don't move focus if DOM mutation occurred before any nodes had focus Fixes #2506. DOM mutation may be triggered by something unrelated to focus, e.g. class changed on an element, so first check if we even have a previously focused element. Also ensure that captured DOM mutations even have removed nodes. Add test to ensure focus is restored on container on node removal. * test(FocusScope): add test case for Dialog with Select initial focus interaction * chore(FocusScope): remove unnecessary waitFor and use toHaveFocus assertion
1 parent 7134c51 commit 9102386

2 files changed

Lines changed: 78 additions & 10 deletions

File tree

packages/core/src/FocusScope/FocusScope.test.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { RenderResult } from '@testing-library/vue'
22
import userEvent from '@testing-library/user-event'
3-
import { render, waitFor } from '@testing-library/vue'
3+
import { render } from '@testing-library/vue'
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { defineComponent } from 'vue'
5+
import { defineComponent, nextTick } from 'vue'
66
import { FocusScope } from '.'
7+
import { DialogContent, DialogRoot, DialogTitle, DialogTrigger } from '../Dialog'
8+
import { SelectContent, SelectItem, SelectRoot, SelectTrigger, SelectValue } from '../Select'
79

810
const INNER_NAME_INPUT_LABEL = 'Name'
911
const INNER_EMAIL_INPUT_LABEL = 'Email'
@@ -24,6 +26,7 @@ const TestField = ({
2426
describe('focusScope', () => {
2527
describe('given a default FocusScope', () => {
2628
let rendered: RenderResult
29+
let focusContainer: HTMLElement
2730
let tabbableFirst: HTMLInputElement
2831
let tabbableSecond: HTMLInputElement
2932
let tabbableLast: HTMLButtonElement
@@ -33,7 +36,7 @@ describe('focusScope', () => {
3336
components: { TestField, FocusScope },
3437
template: `<div>
3538
<FocusScope asChild loop trapped>
36-
<form>
39+
<form data-testid="focus-scope">
3740
<TestField label=${INNER_NAME_INPUT_LABEL} />
3841
<TestField label=${INNER_EMAIL_INPUT_LABEL} />
3942
<button>${INNER_SUBMIT_LABEL}</button>
@@ -43,6 +46,7 @@ describe('focusScope', () => {
4346
<button>some outer button</button>
4447
</div>`,
4548
}))
49+
focusContainer = rendered.getByTestId('focus-scope') as HTMLElement
4650
tabbableFirst = rendered.getByLabelText(INNER_NAME_INPUT_LABEL) as HTMLInputElement
4751
tabbableSecond = rendered.getByLabelText(INNER_EMAIL_INPUT_LABEL) as HTMLInputElement
4852
tabbableLast = rendered.getByText(INNER_SUBMIT_LABEL) as HTMLButtonElement
@@ -57,13 +61,20 @@ describe('focusScope', () => {
5761
it('should focus the last element in the scope on shift+tab from the first element in scope', async () => {
5862
tabbableFirst.focus()
5963
await userEvent.tab({ shift: true })
60-
waitFor(() => expect(tabbableLast).toBe(document.activeElement))
64+
expect(tabbableLast).toHaveFocus()
6165
})
6266

6367
it('should focus the first element in scope on tab from the last element in scope', async () => {
6468
tabbableLast.focus()
6569
await userEvent.tab()
66-
expect(tabbableFirst).toBe(document.activeElement)
70+
expect(tabbableFirst).toHaveFocus()
71+
})
72+
73+
it('should focus container when focused element is removed from the DOM', async () => {
74+
tabbableFirst.focus()
75+
tabbableFirst.remove()
76+
await nextTick()
77+
expect(focusContainer).toHaveFocus()
6778
})
6879
})
6980

@@ -94,13 +105,13 @@ describe('focusScope', () => {
94105
it('should skip the element with a negative tabindex on tab', async () => {
95106
tabbableLast.focus()
96107
await userEvent.tab()
97-
expect(tabbableSecond).toBe(document.activeElement)
108+
expect(tabbableSecond).toHaveFocus()
98109
})
99110

100111
it('should skip the element with a negative tabindex on shift+tab', async () => {
101112
tabbableSecond.focus()
102113
await userEvent.tab({ shift: true })
103-
waitFor(() => expect(tabbableLast).toBe(document.activeElement))
114+
expect(tabbableLast).toHaveFocus()
104115
})
105116
})
106117

@@ -111,7 +122,9 @@ describe('focusScope', () => {
111122
beforeEach(() => {
112123
rendered = render(defineComponent({
113124
components: { TestField, FocusScope },
114-
props: { handleLastFocusableElementBlur },
125+
setup() {
126+
return { handleLastFocusableElementBlur }
127+
},
115128
template: `<div>
116129
<FocusScope asChild loop trapped>
117130
<form>
@@ -130,7 +143,48 @@ describe('focusScope', () => {
130143
tabbableFirst.focus()
131144
await userEvent.tab({ shift: true })
132145
await userEvent.tab()
133-
waitFor(() => expect(handleLastFocusableElementBlur).toHaveBeenCalledTimes(1))
146+
expect(handleLastFocusableElementBlur).toHaveBeenCalledTimes(1)
147+
})
148+
})
149+
150+
// https://github.com/unovue/reka-ui/issues/2550
151+
describe('given a FocusScope with SelectTrigger inside Dialog (#2550)', () => {
152+
const DialogWithSelect = defineComponent({
153+
components: { DialogRoot, DialogTrigger, DialogContent, DialogTitle, SelectRoot, SelectTrigger, SelectValue, SelectContent, SelectItem },
154+
template: `
155+
<DialogRoot>
156+
<DialogTrigger>Open</DialogTrigger>
157+
<DialogContent>
158+
<DialogTitle>Test Dialog</DialogTitle>
159+
<input data-testid="email-input" type="text" placeholder="you@example.com" />
160+
<SelectRoot>
161+
<SelectTrigger>
162+
<SelectValue placeholder="Select" />
163+
</SelectTrigger>
164+
<SelectContent>
165+
<SelectItem value="a">Option A</SelectItem>
166+
<SelectItem value="b">Option B</SelectItem>
167+
</SelectContent>
168+
</SelectRoot>
169+
</DialogContent>
170+
</DialogRoot>
171+
`,
172+
})
173+
174+
it('should auto-focus the first tabbable element when SelectTrigger is present', async () => {
175+
const rendered = render(DialogWithSelect)
176+
177+
const trigger = rendered.getByRole('button', { name: 'Open' })
178+
await userEvent.click(trigger)
179+
180+
const input1 = rendered.getByTestId('email-input')
181+
expect(input1).toHaveFocus()
182+
183+
// close and reopen, and ensure the input is focused again, not the SelectTrigger
184+
await userEvent.keyboard('{Escape}')
185+
await userEvent.click(trigger)
186+
const inputReopen = rendered.getByTestId('email-input')
187+
expect(inputReopen).toHaveFocus()
134188
})
135189
})
136190
})

packages/core/src/FocusScope/FocusScope.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,21 @@ watchEffect((cleanupFn) => {
117117
// if the element still exist inside the container,
118118
// if not then we focus to the container
119119
function handleMutations(mutations: MutationRecord[]) {
120-
const isLastFocusedElementExist = container.contains(lastFocusedElementRef.value)
120+
const lastFocusedElement = lastFocusedElementRef.value
121+
122+
// Mutation may be triggered by something unrelated to focus, e.g. class is set on an element
123+
// so we first check if we even have a previously focused element.
124+
if (lastFocusedElement === null) {
125+
return
126+
}
127+
128+
// Ensure mutations removed nodes from the DOM at all.
129+
const anyNodesRemoved = mutations.some(m => m.removedNodes.length > 0)
130+
if (!anyNodesRemoved) {
131+
return
132+
}
133+
134+
const isLastFocusedElementExist = container.contains(lastFocusedElement)
121135
if (!isLastFocusedElementExist)
122136
focus(container)
123137
}

0 commit comments

Comments
 (0)