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 7112aaac98a..9d692332c4a 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.)", + "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 556df2eeac9..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,12 +169,16 @@ }, "VMenu": { "props": { + "captureFocus": "3.11.0", + "retainFocus": "3.11.0", "stickToTarget": "3.10.0", "submenu": "3.7.0" } }, "VNavigationDrawer": { "props": { + "captureFocus": "3.11.0", + "retainFocus": "3.11.0", "persistent": "3.6.0" } }, @@ -183,6 +189,8 @@ }, "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 129f50c12ae..b4d715eb3d5 100644 --- a/packages/vuetify/src/components/VDialog/VDialog.tsx +++ b/packages/vuetify/src/components/VDialog/VDialog.tsx @@ -13,26 +13,24 @@ 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, omit, propsFactory, useRender } from '@/util' // Types import type { OverlaySlots } from '@/components/VOverlay/VOverlay' export const makeVDialogProps = propsFactory({ fullscreen: Boolean, - retainFocus: { - type: Boolean, - default: true, - }, 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()({ @@ -51,46 +49,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) - - if (!focusable.length) return - - const firstElement = focusable[0] - const lastElement = focusable[focusable.length - 1] - - if (before === firstElement) { - lastElement.focus() - } else { - firstElement.focus() - } - } - } - - onBeforeUnmount(() => { - document.removeEventListener('focusin', onFocusin) - }) - - if (IN_BROWSER) { - watch(() => isActive.value && props.retainFocus, val => { - val - ? document.addEventListener('focusin', onFocusin) - : 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 7150113d79d..c368919953b 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,9 +46,9 @@ export const makeVMenuProps = propsFactory({ // disableKeys: Boolean, id: String, submenu: Boolean, - disableInitialFocus: Boolean, ...omit(makeVOverlayProps({ + captureFocus: true, closeDelay: 250, closeOnContentClick: true, locationStrategy: 'connected' as const, @@ -103,46 +101,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) { @@ -165,7 +130,7 @@ export const VMenu = genericComponent()({ e.shiftKey ? 'prev' : 'next', (el: HTMLElement) => el.tabIndex >= 0 ) - if (!nextElement) { + if (!nextElement && !props.retainFocus) { 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..70ad4243d7b 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' @@ -28,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' @@ -91,6 +92,7 @@ export const makeVNavigationDrawerProps = propsFactory({ ...makeElevationProps(), ...makeLayoutItemProps(), ...makeRoundedProps(), + ...omit(makeFocusTrapProps({ captureFocus: false }), ['disableInitialFocus']), ...makeTagProps({ tag: 'nav' }), ...makeThemeProps(), }, 'VNavigationDrawer') @@ -141,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 61e3de2eea6..9c270e90358 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' @@ -40,6 +41,7 @@ import { getCurrentInstance, getScrollParent, IN_BROWSER, + omit, propsFactory, standardEasing, useRender, @@ -108,6 +110,7 @@ export const makeVOverlayProps = propsFactory({ ...makeLazyProps(), ...makeLocationStrategyProps(), ...makeScrollStrategyProps(), + ...makeFocusTrapProps(), ...makeThemeProps(), ...makeTransitionProps(), }, 'VOverlay') @@ -122,7 +125,7 @@ export const VOverlay = genericComponent()({ props: { _disableGlobalStack: Boolean, - ...makeVOverlayProps(), + ...omit(makeVOverlayProps(), ['disableInitialFocus']), }, emits: { @@ -203,6 +206,8 @@ export const VOverlay = genericComponent()({ ) } + useFocusTrap(props, { isActive, globalTop, contentEl }) + IN_BROWSER && watch(isActive, val => { if (val) { window.addEventListener('keydown', onKeydown) 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 29aae2b94ec..5bb1e527472 100644 --- a/packages/vuetify/src/components/VTooltip/VTooltip.tsx +++ b/packages/vuetify/src/components/VTooltip/VTooltip.tsx @@ -38,6 +38,9 @@ export const makeVTooltipProps = propsFactory({ transition: null, }), [ 'absolute', + 'retainFocus', + 'captureFocus', + 'disableInitialFocus', 'persistent', ]), }, 'VTooltip') diff --git a/packages/vuetify/src/composables/focusTrap.ts b/packages/vuetify/src/composables/focusTrap.ts new file mode 100644 index 00000000000..7b220f0f09c --- /dev/null +++ b/packages/vuetify/src/composables/focusTrap.ts @@ -0,0 +1,118 @@ +// Utilities +import { nextTick, onBeforeUnmount, toRef, toValue, watch } from 'vue' +import { focusableChildren, IN_BROWSER, propsFactory } from '@/util' + +// Types +import type { Ref } from 'vue' + +// Types +export interface FocusTrapProps { + retainFocus: boolean + captureFocus: boolean + disableInitialFocus?: boolean // deprecated +} + +// Composables +export const makeFocusTrapProps = propsFactory({ + retainFocus: Boolean, + captureFocus: Boolean, + disableInitialFocus: Boolean, // deprecated +}, '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, globalTop, contentEl }: { + isActive: Readonly> + globalTop: Readonly> + contentEl: Readonly> + } +) { + const trapId = Symbol('trap') + watch(() => props.retainFocus, val => { + if (val) { + registry.set(trapId, { isActive, contentEl }) + } else { + registry.delete(trapId) + } + }, { 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.captureFocus && !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) +}