From bc3b7e30639a1770441496e85ea47119dec3fe6f Mon Sep 17 00:00:00 2001 From: J-Sek Date: Tue, 23 Sep 2025 01:13:17 +0200 Subject: [PATCH 1/4] feat(VNagivationDrawer, VOverlay): add `focus-trap` prop closes #16140 --- .../api-generator/src/locale/en/generic.json | 1 + packages/docs/src/data/new-in.json | 4 + .../src/components/VDialog/VDialog.tsx | 15 +--- .../vuetify/src/components/VMenu/VMenu.tsx | 2 +- .../VNavigationDrawer/VNavigationDrawer.tsx | 4 + .../src/components/VOverlay/VOverlay.tsx | 4 + .../src/components/VTooltip/VTooltip.tsx | 1 + packages/vuetify/src/composables/focusTrap.ts | 77 +++++++++++++++++++ 8 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 packages/vuetify/src/composables/focusTrap.ts diff --git a/packages/api-generator/src/locale/en/generic.json b/packages/api-generator/src/locale/en/generic.json index 7112aaac98a..d5157b56622 100644 --- a/packages/api-generator/src/locale/en/generic.json +++ b/packages/api-generator/src/locale/en/generic.json @@ -20,6 +20,7 @@ "falseValue": "Sets value for falsy state.", "firstDayOfWeek": "Sets the first day of the week, starting with 0 for Sunday. (Note: not guaranteed to work when using custom date adapters.)", "firstDayOfYear": "Sets the day that determines the first week of the year, starting with 0 for Sunday. For ISO 8601 this should be 4. (Note: not guaranteed to work when using custom date adapters.)", + "focusTrap": "Keeps focus within the content element when using **Tab** and **Shift**+**Tab**.", "fullWidth": "Sets the component width to 100%.", "height": "Sets the height for the component.", "hideNoData": "Hides the menu when there are no options to show. Useful for preventing the menu from opening before results are fetched asynchronously. Also has the effect of opening the menu when the `items` array changes if not already open.", diff --git a/packages/docs/src/data/new-in.json b/packages/docs/src/data/new-in.json index 556df2eeac9..494a62b11ad 100644 --- a/packages/docs/src/data/new-in.json +++ b/packages/docs/src/data/new-in.json @@ -114,6 +114,7 @@ }, "VDialog": { "props": { + "focusTrap": "3.11.0", "stickToTarget": "3.10.0" } }, @@ -167,12 +168,14 @@ }, "VMenu": { "props": { + "focusTrap": "3.11.0", "stickToTarget": "3.10.0", "submenu": "3.7.0" } }, "VNavigationDrawer": { "props": { + "focusTrap": "3.11.0", "persistent": "3.6.0" } }, @@ -183,6 +186,7 @@ }, "VOverlay": { "props": { + "focusTrap": "3.11.0", "stickToTarget": "3.10.0" } }, diff --git a/packages/vuetify/src/components/VDialog/VDialog.tsx b/packages/vuetify/src/components/VDialog/VDialog.tsx index 129f50c12ae..3b64de9634b 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -32,6 +32,7 @@ export const makeVDialogProps = propsFactory({ scrollStrategy: 'block' as const, transition: { component: VDialogTransition }, zIndex: 2400, + focusTrap: true, }), }, 'VDialog') @@ -66,17 +67,7 @@ export const VDialog = genericComponent()({ !overlay.value.contentEl.contains(after) ) { const focusable = focusableChildren(overlay.value.contentEl) - - if (!focusable.length) return - - const firstElement = focusable[0] - const lastElement = focusable[focusable.length - 1] - - if (before === firstElement) { - lastElement.focus() - } else { - firstElement.focus() - } + focusable[0]?.focus() } } @@ -87,7 +78,7 @@ export const VDialog = genericComponent()({ if (IN_BROWSER) { watch(() => isActive.value && props.retainFocus, val => { val - ? document.addEventListener('focusin', onFocusin) + ? document.addEventListener('focusin', onFocusin, { once: true }) : document.removeEventListener('focusin', onFocusin) }, { immediate: true }) } diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index 7150113d79d..bef12039f22 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -165,7 +165,7 @@ export const VMenu = genericComponent()({ e.shiftKey ? 'prev' : 'next', (el: HTMLElement) => el.tabIndex >= 0 ) - if (!nextElement) { + if (!nextElement && !props.focusTrap) { isActive.value = false overlay.value?.activatorEl?.focus() } diff --git a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx index 1fa40ff9a04..69bf97d4a44 100644 --- a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx +++ b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx @@ -16,6 +16,7 @@ import { provideDefaults } from '@/composables/defaults' import { makeDelayProps, useDelay } from '@/composables/delay' import { makeDisplayProps, useDisplay } from '@/composables/display' import { makeElevationProps, useElevation } from '@/composables/elevation' +import { makeFocusTrapProps, useFocusTrap } from '@/composables/focusTrap' import { makeLayoutItemProps, useLayoutItem } from '@/composables/layout' import { useProxiedModel } from '@/composables/proxiedModel' import { makeRoundedProps, useRounded } from '@/composables/rounded' @@ -91,6 +92,7 @@ export const makeVNavigationDrawerProps = propsFactory({ ...makeElevationProps(), ...makeLayoutItemProps(), ...makeRoundedProps(), + ...makeFocusTrapProps(), ...makeTagProps({ tag: 'nav' }), ...makeThemeProps(), }, 'VNavigationDrawer') @@ -121,6 +123,8 @@ export const VNavigationDrawer = genericComponent()({ const rootEl = ref() const isHovering = shallowRef(false) + useFocusTrap(props, { isActive, contentEl: rootEl }) + const { runOpenDelay, runCloseDelay } = useDelay(props, value => { isHovering.value = value }) diff --git a/packages/vuetify/src/components/VOverlay/VOverlay.tsx b/packages/vuetify/src/components/VOverlay/VOverlay.tsx index 61e3de2eea6..e45362ce808 100644 --- a/packages/vuetify/src/components/VOverlay/VOverlay.tsx +++ b/packages/vuetify/src/components/VOverlay/VOverlay.tsx @@ -8,6 +8,7 @@ import { makeActivatorProps, useActivator } from './useActivator' import { useBackgroundColor } from '@/composables/color' import { makeComponentProps } from '@/composables/component' import { makeDimensionProps, useDimension } from '@/composables/dimensions' +import { makeFocusTrapProps, useFocusTrap } from '@/composables/focusTrap' import { useHydration } from '@/composables/hydration' import { makeLazyProps, useLazy } from '@/composables/lazy' import { useRtl } from '@/composables/locale' @@ -108,6 +109,7 @@ export const makeVOverlayProps = propsFactory({ ...makeLazyProps(), ...makeLocationStrategyProps(), ...makeScrollStrategyProps(), + ...makeFocusTrapProps(), ...makeThemeProps(), ...makeTransitionProps(), }, 'VOverlay') @@ -203,6 +205,8 @@ export const VOverlay = genericComponent()({ ) } + useFocusTrap(props, { isActive, contentEl }) + IN_BROWSER && watch(isActive, val => { if (val) { window.addEventListener('keydown', onKeydown) diff --git a/packages/vuetify/src/components/VTooltip/VTooltip.tsx b/packages/vuetify/src/components/VTooltip/VTooltip.tsx index 29aae2b94ec..252c161976f 100644 --- a/packages/vuetify/src/components/VTooltip/VTooltip.tsx +++ b/packages/vuetify/src/components/VTooltip/VTooltip.tsx @@ -38,6 +38,7 @@ export const makeVTooltipProps = propsFactory({ transition: null, }), [ 'absolute', + 'focusTrap', 'persistent', ]), }, 'VTooltip') diff --git a/packages/vuetify/src/composables/focusTrap.ts b/packages/vuetify/src/composables/focusTrap.ts new file mode 100644 index 00000000000..2490fc35029 --- /dev/null +++ b/packages/vuetify/src/composables/focusTrap.ts @@ -0,0 +1,77 @@ +// Utilities +import { watch } from 'vue' +import { focusableChildren, IN_BROWSER, propsFactory } from '@/util' + +// Types +import type { Ref } from 'vue' + +// Types +export interface FocusTrapProps { + focusTrap: boolean +} + +// Composables +export const makeFocusTrapProps = propsFactory({ + focusTrap: Boolean, +}, 'focusTrap') + +const registry = new Map + contentEl: Ref +}>() + +function onKeydown (e: KeyboardEvent) { + const activeElement = document.activeElement as HTMLElement | null + if (e.key !== 'Tab' || !activeElement) return + + const parentTraps = Array.from(registry.values()) + .filter(({ isActive, contentEl }) => isActive.value && contentEl.value?.contains(activeElement)) + .map(x => x.contentEl.value) + + let closestTrap + let currentParent = activeElement.parentElement + while (currentParent) { + if (parentTraps.includes(currentParent)) { + closestTrap = currentParent + break + } + currentParent = currentParent.parentElement + } + + if (!closestTrap) return + + const focusable = focusableChildren(closestTrap) + .filter(x => !x.classList.contains('v-list')) + + if (!focusable.length) return + + const focusedIndex = focusable.indexOf(activeElement) + let newIndex = focusedIndex + (e.shiftKey ? -1 : 1) + if (newIndex === -1) { + newIndex = focusable.length - 1 + } else if (newIndex === focusable.length) { + newIndex = 0 + } + + e.preventDefault() + focusable[newIndex].focus() +} + +export function useFocusTrap ( + props: FocusTrapProps, + { isActive, contentEl }: { + isActive: Ref + contentEl: Ref + } +) { + const trapId = Symbol('trap') + watch(() => props.focusTrap, val => { + if (val) { + registry.set(trapId, { isActive, contentEl }) + } else { + registry.delete(trapId) + } + }, { immediate: true }) + + IN_BROWSER && document.addEventListener('keydown', onKeydown) +} From aa676bcd99a7b69c6c1b6883ea91df6e114653d9 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Tue, 23 Sep 2025 10:19:16 +0200 Subject: [PATCH 2/4] chore: consolidate with `retain-focus` --- packages/api-generator/src/locale/en/VDialog.json | 1 - packages/api-generator/src/locale/en/generic.json | 2 +- packages/docs/src/data/new-in.json | 7 +++---- packages/vuetify/src/components/VDialog/VDialog.tsx | 6 +----- packages/vuetify/src/components/VMenu/VMenu.tsx | 2 +- packages/vuetify/src/components/VTooltip/VTooltip.tsx | 2 +- packages/vuetify/src/composables/focusTrap.ts | 6 +++--- 7 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/api-generator/src/locale/en/VDialog.json b/packages/api-generator/src/locale/en/VDialog.json index d9e1762c28c..164d6b2278a 100644 --- a/packages/api-generator/src/locale/en/VDialog.json +++ b/packages/api-generator/src/locale/en/VDialog.json @@ -9,7 +9,6 @@ "noClickAnimation": "Disables the bounce effect when clicking outside of a `v-dialog`'s content when using the **persistent** prop.", "openOnHover": "Designates whether component should activate when its activator is hovered.", "persistent": "Clicking outside of the element or pressing **esc** key will not deactivate it.", - "retainFocus": "Tab focus will return to the first child of the dialog by default. Disable this when using external tools that require focus such as TinyMCE or vue-clipboard.", "scrollable": "When set to true, expects a `v-card` and a `v-card-text` component with a designated height. For more information, check out the [scrollable example](/components/dialogs#scrollable)." }, "events": { diff --git a/packages/api-generator/src/locale/en/generic.json b/packages/api-generator/src/locale/en/generic.json index d5157b56622..9d692332c4a 100644 --- a/packages/api-generator/src/locale/en/generic.json +++ b/packages/api-generator/src/locale/en/generic.json @@ -20,7 +20,7 @@ "falseValue": "Sets value for falsy state.", "firstDayOfWeek": "Sets the first day of the week, starting with 0 for Sunday. (Note: not guaranteed to work when using custom date adapters.)", "firstDayOfYear": "Sets the day that determines the first week of the year, starting with 0 for Sunday. For ISO 8601 this should be 4. (Note: not guaranteed to work when using custom date adapters.)", - "focusTrap": "Keeps focus within the content element when using **Tab** and **Shift**+**Tab**.", + "retainFocus": "Captures and keeps focus within the content element when using **Tab** and **Shift**+**Tab**. Recommended to be `false` when using external tools that require focus such as TinyMCE or vue-clipboard.", "fullWidth": "Sets the component width to 100%.", "height": "Sets the height for the component.", "hideNoData": "Hides the menu when there are no options to show. Useful for preventing the menu from opening before results are fetched asynchronously. Also has the effect of opening the menu when the `items` array changes if not already open.", diff --git a/packages/docs/src/data/new-in.json b/packages/docs/src/data/new-in.json index 494a62b11ad..7915965113d 100644 --- a/packages/docs/src/data/new-in.json +++ b/packages/docs/src/data/new-in.json @@ -114,7 +114,6 @@ }, "VDialog": { "props": { - "focusTrap": "3.11.0", "stickToTarget": "3.10.0" } }, @@ -168,14 +167,14 @@ }, "VMenu": { "props": { - "focusTrap": "3.11.0", + "retainFocus": "3.11.0", "stickToTarget": "3.10.0", "submenu": "3.7.0" } }, "VNavigationDrawer": { "props": { - "focusTrap": "3.11.0", + "retainFocus": "3.11.0", "persistent": "3.6.0" } }, @@ -186,7 +185,7 @@ }, "VOverlay": { "props": { - "focusTrap": "3.11.0", + "retainFocus": "3.11.0", "stickToTarget": "3.10.0" } }, diff --git a/packages/vuetify/src/components/VDialog/VDialog.tsx b/packages/vuetify/src/components/VDialog/VDialog.tsx index 3b64de9634b..d4b4d060916 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -21,10 +21,6 @@ import type { OverlaySlots } from '@/components/VOverlay/VOverlay' export const makeVDialogProps = propsFactory({ fullscreen: Boolean, - retainFocus: { - type: Boolean, - default: true, - }, scrollable: Boolean, ...makeVOverlayProps({ @@ -32,7 +28,7 @@ export const makeVDialogProps = propsFactory({ scrollStrategy: 'block' as const, transition: { component: VDialogTransition }, zIndex: 2400, - focusTrap: true, + retainFocus: true, }), }, 'VDialog') diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index bef12039f22..2401651b12d 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -165,7 +165,7 @@ export const VMenu = genericComponent()({ e.shiftKey ? 'prev' : 'next', (el: HTMLElement) => el.tabIndex >= 0 ) - if (!nextElement && !props.focusTrap) { + if (!nextElement && !props.retainFocus) { isActive.value = false overlay.value?.activatorEl?.focus() } diff --git a/packages/vuetify/src/components/VTooltip/VTooltip.tsx b/packages/vuetify/src/components/VTooltip/VTooltip.tsx index 252c161976f..c9778501d14 100644 --- a/packages/vuetify/src/components/VTooltip/VTooltip.tsx +++ b/packages/vuetify/src/components/VTooltip/VTooltip.tsx @@ -38,7 +38,7 @@ export const makeVTooltipProps = propsFactory({ transition: null, }), [ 'absolute', - 'focusTrap', + 'retainFocus', 'persistent', ]), }, 'VTooltip') diff --git a/packages/vuetify/src/composables/focusTrap.ts b/packages/vuetify/src/composables/focusTrap.ts index 2490fc35029..a2fa855dc88 100644 --- a/packages/vuetify/src/composables/focusTrap.ts +++ b/packages/vuetify/src/composables/focusTrap.ts @@ -7,12 +7,12 @@ import type { Ref } from 'vue' // Types export interface FocusTrapProps { - focusTrap: boolean + retainFocus: boolean } // Composables export const makeFocusTrapProps = propsFactory({ - focusTrap: Boolean, + retainFocus: Boolean, }, 'focusTrap') const registry = new Map props.focusTrap, val => { + watch(() => props.retainFocus, val => { if (val) { registry.set(trapId, { isActive, contentEl }) } else { From baf72b3bbffdf29cd031e8ea323c99fb62dd1672 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Tue, 23 Sep 2025 10:41:14 +0200 Subject: [PATCH 3/4] chore: move logic that captures initial focus --- .../src/components/VDialog/VDialog.tsx | 34 +------------ .../vuetify/src/components/VMenu/VMenu.tsx | 44 ++--------------- .../VNavigationDrawer/VNavigationDrawer.tsx | 6 +-- .../src/components/VOverlay/VOverlay.tsx | 2 +- packages/vuetify/src/composables/focusTrap.ts | 49 +++++++++++++++++-- 5 files changed, 54 insertions(+), 81 deletions(-) diff --git a/packages/vuetify/src/components/VDialog/VDialog.tsx b/packages/vuetify/src/components/VDialog/VDialog.tsx index d4b4d060916..1c33cb496ce 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -13,8 +13,8 @@ import { useProxiedModel } from '@/composables/proxiedModel' import { useScopeId } from '@/composables/scopeId' // Utilities -import { mergeProps, nextTick, onBeforeUnmount, ref, watch } from 'vue' -import { focusableChildren, genericComponent, IN_BROWSER, propsFactory, useRender } from '@/util' +import { mergeProps, nextTick, ref, watch } from 'vue' +import { genericComponent, propsFactory, useRender } from '@/util' // Types import type { OverlaySlots } from '@/components/VOverlay/VOverlay' @@ -48,36 +48,6 @@ export const VDialog = genericComponent()({ const { scopeId } = useScopeId() const overlay = ref() - function onFocusin (e: FocusEvent) { - const before = e.relatedTarget as HTMLElement | null - const after = e.target as HTMLElement | null - - if ( - before !== after && - overlay.value?.contentEl && - // We're the topmost dialog - overlay.value?.globalTop && - // It isn't the document or the dialog body - ![document, overlay.value.contentEl].includes(after!) && - // It isn't inside the dialog body - !overlay.value.contentEl.contains(after) - ) { - const focusable = focusableChildren(overlay.value.contentEl) - focusable[0]?.focus() - } - } - - onBeforeUnmount(() => { - document.removeEventListener('focusin', onFocusin) - }) - - if (IN_BROWSER) { - watch(() => isActive.value && props.retainFocus, val => { - val - ? document.addEventListener('focusin', onFocusin, { once: true }) - : document.removeEventListener('focusin', onFocusin) - }, { immediate: true }) - } function onAfterEnter () { emit('afterEnter') diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index 2401651b12d..3c68e7838f7 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -18,7 +18,6 @@ import { computed, inject, mergeProps, - nextTick, onBeforeUnmount, onDeactivated, provide, @@ -33,7 +32,6 @@ import { focusChild, genericComponent, getNextElement, - IN_BROWSER, isClickInsideElement, omit, propsFactory, @@ -48,7 +46,6 @@ export const makeVMenuProps = propsFactory({ // disableKeys: Boolean, id: String, submenu: Boolean, - disableInitialFocus: Boolean, ...omit(makeVOverlayProps({ closeDelay: 250, @@ -103,46 +100,13 @@ export const VMenu = genericComponent()({ }, }) - onBeforeUnmount(() => { - parent?.unregister() - document.removeEventListener('focusin', onFocusIn) - }) + onBeforeUnmount(() => parent?.unregister()) onDeactivated(() => isActive.value = false) - 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 menu - overlay.value?.globalTop && - // It isn't the document or the menu body - ![document, overlay.value.contentEl].includes(after!) && - // It isn't inside the menu body - !overlay.value.contentEl.contains(after) - ) { - const focusable = focusableChildren(overlay.value.contentEl) - focusable[0]?.focus() - } - } - watch(isActive, val => { - if (val) { - parent?.register() - if (IN_BROWSER && !props.disableInitialFocus) { - document.addEventListener('focusin', onFocusIn, { once: true }) - } - } else { - parent?.unregister() - if (IN_BROWSER) { - document.removeEventListener('focusin', onFocusIn) - } - } + val + ? parent?.register() + : parent?.unregister() }, { immediate: true }) function onClickOutside (e: MouseEvent) { diff --git a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx index 69bf97d4a44..60ee1b36dbb 100644 --- a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx +++ b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx @@ -92,7 +92,7 @@ export const makeVNavigationDrawerProps = propsFactory({ ...makeElevationProps(), ...makeLayoutItemProps(), ...makeRoundedProps(), - ...makeFocusTrapProps(), + ...makeFocusTrapProps({ disableInitialFocus: true }), ...makeTagProps({ tag: 'nav' }), ...makeThemeProps(), }, 'VNavigationDrawer') @@ -123,8 +123,6 @@ export const VNavigationDrawer = genericComponent()({ const rootEl = ref() const isHovering = shallowRef(false) - useFocusTrap(props, { isActive, contentEl: rootEl }) - const { runOpenDelay, runCloseDelay } = useDelay(props, value => { isHovering.value = value }) @@ -145,6 +143,8 @@ export const VNavigationDrawer = genericComponent()({ location.value !== 'bottom' ) + useFocusTrap(props, { isActive, globalTop: isTemporary, contentEl: rootEl }) + useToggleScope(() => props.expandOnHover && props.rail != null, () => { watch(isHovering, val => emit('update:rail', !val)) }) diff --git a/packages/vuetify/src/components/VOverlay/VOverlay.tsx b/packages/vuetify/src/components/VOverlay/VOverlay.tsx index e45362ce808..db3a4f76bc1 100644 --- a/packages/vuetify/src/components/VOverlay/VOverlay.tsx +++ b/packages/vuetify/src/components/VOverlay/VOverlay.tsx @@ -205,7 +205,7 @@ export const VOverlay = genericComponent()({ ) } - useFocusTrap(props, { isActive, contentEl }) + useFocusTrap(props, { isActive, globalTop, contentEl }) IN_BROWSER && watch(isActive, val => { if (val) { diff --git a/packages/vuetify/src/composables/focusTrap.ts b/packages/vuetify/src/composables/focusTrap.ts index a2fa855dc88..d9ef5991b98 100644 --- a/packages/vuetify/src/composables/focusTrap.ts +++ b/packages/vuetify/src/composables/focusTrap.ts @@ -1,18 +1,20 @@ // Utilities -import { watch } from 'vue' +import { nextTick, onBeforeUnmount, toRef, toValue, watch } from 'vue' import { focusableChildren, IN_BROWSER, propsFactory } from '@/util' // Types -import type { Ref } from 'vue' +import type { MaybeRefOrGetter, Ref } from 'vue' // Types export interface FocusTrapProps { retainFocus: boolean + disableInitialFocus: boolean } // Composables export const makeFocusTrapProps = propsFactory({ retainFocus: Boolean, + disableInitialFocus: Boolean, }, 'focusTrap') const registry = new Map - contentEl: Ref + { isActive, globalTop, contentEl }: { + isActive: Readonly> + globalTop: Readonly> + contentEl: Readonly> } ) { const trapId = Symbol('trap') @@ -73,5 +76,41 @@ export function useFocusTrap ( } }, { immediate: true }) + async function captureFocus (e: FocusEvent) { + const before = e.relatedTarget as HTMLElement | null + const after = e.target as HTMLElement | null + + await nextTick() + + if ( + isActive.value && + before !== after && + contentEl.value && + // We're the topmost menu + toValue(globalTop) && + // It isn't the document or the container body + ![document, contentEl.value].includes(after!) && + // It isn't inside the container body + !contentEl.value.contains(after) + ) { + const focusable = focusableChildren(contentEl.value) + focusable[0]?.focus() + } + } + + const shouldCapture = toRef(() => isActive.value && !props.disableInitialFocus) + + IN_BROWSER && watch(shouldCapture, val => { + if (val) { + document.addEventListener('focusin', captureFocus, { once: true }) + } else { + document.removeEventListener('focusin', captureFocus) + } + }, { immediate: true }) + + onBeforeUnmount(() => { + document.removeEventListener('focusin', captureFocus) + }) + IN_BROWSER && document.addEventListener('keydown', onKeydown) } From 865aa14bba1c2221e36ebf539c64dec0dc50f9cd Mon Sep 17 00:00:00 2001 From: J-Sek Date: Tue, 23 Sep 2025 10:51:47 +0200 Subject: [PATCH 4/4] chore: `capture-focus` replacing `disable-initial-focus` --- packages/docs/src/data/new-in.json | 5 +++++ packages/vuetify/src/components/VDialog/VDialog.tsx | 7 ++++--- packages/vuetify/src/components/VMenu/VMenu.tsx | 1 + .../VNavigationDrawer/VNavigationDrawer.tsx | 4 ++-- packages/vuetify/src/components/VOverlay/VOverlay.tsx | 3 ++- .../vuetify/src/components/VSnackbar/VSnackbar.tsx | 11 ++++++++++- packages/vuetify/src/components/VTooltip/VTooltip.tsx | 2 ++ packages/vuetify/src/composables/focusTrap.ts | 10 ++++++---- 8 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/docs/src/data/new-in.json b/packages/docs/src/data/new-in.json index 7915965113d..a39efa5efae 100644 --- a/packages/docs/src/data/new-in.json +++ b/packages/docs/src/data/new-in.json @@ -114,6 +114,8 @@ }, "VDialog": { "props": { + "captureFocus": "3.11.0", + "retainFocus": "3.11.0", "stickToTarget": "3.10.0" } }, @@ -167,6 +169,7 @@ }, "VMenu": { "props": { + "captureFocus": "3.11.0", "retainFocus": "3.11.0", "stickToTarget": "3.10.0", "submenu": "3.7.0" @@ -174,6 +177,7 @@ }, "VNavigationDrawer": { "props": { + "captureFocus": "3.11.0", "retainFocus": "3.11.0", "persistent": "3.6.0" } @@ -185,6 +189,7 @@ }, "VOverlay": { "props": { + "captureFocus": "3.11.0", "retainFocus": "3.11.0", "stickToTarget": "3.10.0" } diff --git a/packages/vuetify/src/components/VDialog/VDialog.tsx b/packages/vuetify/src/components/VDialog/VDialog.tsx index 1c33cb496ce..b4d715eb3d5 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -14,7 +14,7 @@ import { useScopeId } from '@/composables/scopeId' // Utilities import { mergeProps, nextTick, ref, watch } from 'vue' -import { genericComponent, propsFactory, useRender } from '@/util' +import { genericComponent, omit, propsFactory, useRender } from '@/util' // Types import type { OverlaySlots } from '@/components/VOverlay/VOverlay' @@ -23,13 +23,14 @@ export const makeVDialogProps = propsFactory({ fullscreen: Boolean, scrollable: Boolean, - ...makeVOverlayProps({ + ...omit(makeVOverlayProps({ + captureFocus: true, origin: 'center center' as const, scrollStrategy: 'block' as const, transition: { component: VDialogTransition }, zIndex: 2400, retainFocus: true, - }), + }), ['disableInitialFocus']), }, 'VDialog') export const VDialog = genericComponent()({ diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index 3c68e7838f7..c368919953b 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -48,6 +48,7 @@ export const makeVMenuProps = propsFactory({ submenu: Boolean, ...omit(makeVOverlayProps({ + captureFocus: true, closeDelay: 250, closeOnContentClick: true, locationStrategy: 'connected' as const, diff --git a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx index 60ee1b36dbb..70ad4243d7b 100644 --- a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx +++ b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx @@ -29,7 +29,7 @@ import { useToggleScope } from '@/composables/toggleScope' // Utilities import { computed, nextTick, readonly, ref, shallowRef, toRef, Transition, watch } from 'vue' -import { genericComponent, propsFactory, toPhysical, useRender } from '@/util' +import { genericComponent, omit, propsFactory, toPhysical, useRender } from '@/util' // Types import type { PropType } from 'vue' @@ -92,7 +92,7 @@ export const makeVNavigationDrawerProps = propsFactory({ ...makeElevationProps(), ...makeLayoutItemProps(), ...makeRoundedProps(), - ...makeFocusTrapProps({ disableInitialFocus: true }), + ...omit(makeFocusTrapProps({ captureFocus: false }), ['disableInitialFocus']), ...makeTagProps({ tag: 'nav' }), ...makeThemeProps(), }, 'VNavigationDrawer') diff --git a/packages/vuetify/src/components/VOverlay/VOverlay.tsx b/packages/vuetify/src/components/VOverlay/VOverlay.tsx index db3a4f76bc1..9c270e90358 100644 --- a/packages/vuetify/src/components/VOverlay/VOverlay.tsx +++ b/packages/vuetify/src/components/VOverlay/VOverlay.tsx @@ -41,6 +41,7 @@ import { getCurrentInstance, getScrollParent, IN_BROWSER, + omit, propsFactory, standardEasing, useRender, @@ -124,7 +125,7 @@ export const VOverlay = genericComponent()({ props: { _disableGlobalStack: Boolean, - ...makeVOverlayProps(), + ...omit(makeVOverlayProps(), ['disableInitialFocus']), }, emits: { diff --git a/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx b/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx index be1a58954dd..9192d34b183 100644 --- a/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx +++ b/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx @@ -87,7 +87,16 @@ export const makeVSnackbarProps = propsFactory({ ...makeThemeProps(), ...omit(makeVOverlayProps({ transition: 'v-snackbar-transition', - }), ['persistent', 'noClickAnimation', 'scrim', 'scrollStrategy', 'stickToTarget']), + }), [ + 'persistent', + 'noClickAnimation', + 'retainFocus', + 'captureFocus', + 'disableInitialFocus', + 'scrim', + 'scrollStrategy', + 'stickToTarget', + ]), }, 'VSnackbar') export const VSnackbar = genericComponent()({ diff --git a/packages/vuetify/src/components/VTooltip/VTooltip.tsx b/packages/vuetify/src/components/VTooltip/VTooltip.tsx index c9778501d14..5bb1e527472 100644 --- a/packages/vuetify/src/components/VTooltip/VTooltip.tsx +++ b/packages/vuetify/src/components/VTooltip/VTooltip.tsx @@ -39,6 +39,8 @@ export const makeVTooltipProps = propsFactory({ }), [ 'absolute', 'retainFocus', + 'captureFocus', + 'disableInitialFocus', 'persistent', ]), }, 'VTooltip') diff --git a/packages/vuetify/src/composables/focusTrap.ts b/packages/vuetify/src/composables/focusTrap.ts index d9ef5991b98..7b220f0f09c 100644 --- a/packages/vuetify/src/composables/focusTrap.ts +++ b/packages/vuetify/src/composables/focusTrap.ts @@ -3,18 +3,20 @@ import { nextTick, onBeforeUnmount, toRef, toValue, watch } from 'vue' import { focusableChildren, IN_BROWSER, propsFactory } from '@/util' // Types -import type { MaybeRefOrGetter, Ref } from 'vue' +import type { Ref } from 'vue' // Types export interface FocusTrapProps { retainFocus: boolean - disableInitialFocus: boolean + captureFocus: boolean + disableInitialFocus?: boolean // deprecated } // Composables export const makeFocusTrapProps = propsFactory({ retainFocus: Boolean, - disableInitialFocus: Boolean, + captureFocus: Boolean, + disableInitialFocus: Boolean, // deprecated }, 'focusTrap') const registry = new Map isActive.value && !props.disableInitialFocus) + const shouldCapture = toRef(() => isActive.value && props.captureFocus && !props.disableInitialFocus) IN_BROWSER && watch(shouldCapture, val => { if (val) {