Skip to content

Commit 50a150b

Browse files
memorandumtkKaelWD
andauthored
fix(VDialog): fix focus trap when tabbing forward (#22101)
Co-authored-by: Kael <[email protected]> fixes #21945
1 parent 8853f4d commit 50a150b

File tree

2 files changed

+73
-12
lines changed

2 files changed

+73
-12
lines changed

packages/vuetify/src/components/VDialog/VDialog.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@ export const VDialog = genericComponent<OverlaySlots>()({
5151
const { scopeId } = useScopeId()
5252

5353
const overlay = ref<VOverlay>()
54-
function onFocusin (e: FocusEvent) {
54+
async function onFocusin (e: FocusEvent) {
5555
const before = e.relatedTarget as HTMLElement | null
5656
const after = e.target as HTMLElement | null
5757

58+
await nextTick()
59+
5860
if (
61+
isActive.value &&
5962
before !== after &&
6063
overlay.value?.contentEl &&
6164
// We're the topmost dialog
@@ -66,29 +69,43 @@ export const VDialog = genericComponent<OverlaySlots>()({
6669
!overlay.value.contentEl.contains(after)
6770
) {
6871
const focusable = focusableChildren(overlay.value.contentEl)
72+
focusable[0]?.focus()
73+
}
74+
}
6975

70-
if (!focusable.length) return
76+
function onKeydown (e: KeyboardEvent) {
77+
if (e.key !== 'Tab' || !overlay.value?.contentEl) return
7178

72-
const firstElement = focusable[0]
73-
const lastElement = focusable[focusable.length - 1]
79+
const focusable = focusableChildren(overlay.value.contentEl)
80+
if (!focusable.length) return
7481

75-
if (before === firstElement) {
76-
lastElement.focus()
77-
} else {
78-
firstElement.focus()
79-
}
82+
const firstElement = focusable[0]
83+
const lastElement = focusable[focusable.length - 1]
84+
const active = document.activeElement as HTMLElement | null
85+
86+
if (e.shiftKey && active === firstElement) {
87+
e.preventDefault()
88+
lastElement.focus()
89+
} else if (!e.shiftKey && active === lastElement) {
90+
e.preventDefault()
91+
firstElement.focus()
8092
}
8193
}
8294

8395
onBeforeUnmount(() => {
8496
document.removeEventListener('focusin', onFocusin)
97+
document.removeEventListener('keydown', onKeydown)
8598
})
8699

87100
if (IN_BROWSER) {
88101
watch(() => isActive.value && props.retainFocus, val => {
89-
val
90-
? document.addEventListener('focusin', onFocusin)
91-
: document.removeEventListener('focusin', onFocusin)
102+
if (val) {
103+
document.addEventListener('focusin', onFocusin, { once: true })
104+
document.addEventListener('keydown', onKeydown)
105+
} else {
106+
document.removeEventListener('focusin', onFocusin)
107+
document.removeEventListener('keydown', onKeydown)
108+
}
92109
}, { immediate: true })
93110
}
94111

packages/vuetify/src/components/VDialog/__test__/VDialog.spec.browser.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,48 @@ describe('VDialog', () => {
4444
await userEvent.click(element)
4545
await expect.poll(() => onAfterLeave).toHaveBeenCalledTimes(1)
4646
})
47+
48+
it('should focus on the last element when shift + tab key is pressed on the first element', async () => {
49+
const model = ref(true)
50+
render(() => (
51+
<div>
52+
<VDialog v-model={ model.value } persistent>
53+
<div>
54+
<button data-testid="first">First</button>
55+
<button data-testid="last">Last</button>
56+
</div>
57+
</VDialog>
58+
</div>
59+
))
60+
const first = screen.getByCSS('button[data-testid="first"]')
61+
const last = screen.getByCSS('button[data-testid="last"]')
62+
63+
first.focus()
64+
await expect.poll(() => document.activeElement).toBe(first)
65+
66+
await userEvent.tab({ shift: true })
67+
await expect.poll(() => document.activeElement).toBe(last)
68+
})
69+
70+
it('should focus on the first element when Tab key is pressed on the last element', async () => {
71+
const model = ref(true)
72+
render(() => (
73+
<div>
74+
<VDialog v-model={ model.value }>
75+
<div>
76+
<button data-testid="first">First</button>
77+
<button data-testid="last">Last</button>
78+
</div>
79+
</VDialog>
80+
</div>
81+
))
82+
const first = screen.getByCSS('button[data-testid="first"]')
83+
const last = screen.getByCSS('button[data-testid="last"]')
84+
85+
last.focus()
86+
await expect.poll(() => document.activeElement).toBe(last)
87+
88+
await userEvent.tab()
89+
await expect.poll(() => document.activeElement).toBe(first)
90+
})
4791
})

0 commit comments

Comments
 (0)