diff --git a/packages/vuetify/src/components/VDialog/VDialog.tsx b/packages/vuetify/src/components/VDialog/VDialog.tsx index 129f50c12ae..d972b5bef79 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -51,11 +51,14 @@ export const VDialog = genericComponent()({ const { scopeId } = useScopeId() const overlay = ref() - function onFocusin (e: FocusEvent) { + async function onFocusin (e: FocusEvent) { const before = e.relatedTarget as HTMLElement | null const after = e.target as HTMLElement | null + await nextTick() + if ( + isActive.value && before !== after && overlay.value?.contentEl && // We're the topmost dialog @@ -66,29 +69,43 @@ export const VDialog = genericComponent()({ !overlay.value.contentEl.contains(after) ) { const focusable = focusableChildren(overlay.value.contentEl) + focusable[0]?.focus() + } + } - if (!focusable.length) return + function onKeydown (e: KeyboardEvent) { + if (e.key !== 'Tab' || !overlay.value?.contentEl) return - const firstElement = focusable[0] - const lastElement = focusable[focusable.length - 1] + const focusable = focusableChildren(overlay.value.contentEl) + if (!focusable.length) return - if (before === firstElement) { - lastElement.focus() - } else { - firstElement.focus() - } + const firstElement = focusable[0] + const lastElement = focusable[focusable.length - 1] + const active = document.activeElement as HTMLElement | null + + if (e.shiftKey && active === firstElement) { + e.preventDefault() + lastElement.focus() + } else if (!e.shiftKey && active === lastElement) { + e.preventDefault() + firstElement.focus() } } onBeforeUnmount(() => { document.removeEventListener('focusin', onFocusin) + document.removeEventListener('keydown', onKeydown) }) if (IN_BROWSER) { watch(() => isActive.value && props.retainFocus, val => { - val - ? document.addEventListener('focusin', onFocusin) - : document.removeEventListener('focusin', onFocusin) + if (val) { + document.addEventListener('focusin', onFocusin, { once: true }) + document.addEventListener('keydown', onKeydown) + } else { + document.removeEventListener('focusin', onFocusin) + document.removeEventListener('keydown', onKeydown) + } }, { immediate: true }) } diff --git a/packages/vuetify/src/components/VDialog/__test__/VDialog.spec.browser.tsx b/packages/vuetify/src/components/VDialog/__test__/VDialog.spec.browser.tsx index a197de45b15..f3d8df2e636 100644 --- a/packages/vuetify/src/components/VDialog/__test__/VDialog.spec.browser.tsx +++ b/packages/vuetify/src/components/VDialog/__test__/VDialog.spec.browser.tsx @@ -44,4 +44,48 @@ describe('VDialog', () => { await userEvent.click(element) await expect.poll(() => onAfterLeave).toHaveBeenCalledTimes(1) }) + + it('should focus on the last element when shift + tab key is pressed on the first element', async () => { + const model = ref(true) + render(() => ( +
+ +
+ + +
+
+
+ )) + const first = screen.getByCSS('button[data-testid="first"]') + const last = screen.getByCSS('button[data-testid="last"]') + + first.focus() + await expect.poll(() => document.activeElement).toBe(first) + + await userEvent.tab({ shift: true }) + await expect.poll(() => document.activeElement).toBe(last) + }) + + it('should focus on the first element when Tab key is pressed on the last element', async () => { + const model = ref(true) + render(() => ( +
+ +
+ + +
+
+
+ )) + const first = screen.getByCSS('button[data-testid="first"]') + const last = screen.getByCSS('button[data-testid="last"]') + + last.focus() + await expect.poll(() => document.activeElement).toBe(last) + + await userEvent.tab() + await expect.poll(() => document.activeElement).toBe(first) + }) })