UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

255 lines (251 loc) 10.6 kB
'use strict'; var vue = require('vue'); var utils = require('./utils.js'); const CFocusTrap = vue.defineComponent({ name: 'CFocusTrap', props: { /** * Controls whether the focus trap is active or inactive. * When `true`, focus will be trapped within the child element. * When `false`, normal focus behavior is restored. */ active: { type: Boolean, default: true, }, /** * Additional container elements to include in the focus trap. * Useful for floating elements like tooltips or popovers that are * rendered outside the main container but should be part of the trap. */ additionalContainer: { type: Object, default: undefined, }, /** * Controls whether to focus the first selectable element or the container itself. * When `true`, focuses the first tabbable element within the container. * When `false`, focuses the container element directly. * * This is useful for containers that should receive focus themselves, * such as scrollable regions or custom interactive components. */ focusFirstElement: { type: Boolean, default: false, }, /** * Automatically restores focus to the previously focused element when the trap is deactivated. * This is crucial for accessibility as it maintains the user's place in the document * when returning from modal dialogs or overlay components. * * Recommended to be `true` for modal dialogs and popover components. */ restoreFocus: { type: Boolean, default: true, }, }, emits: { /** * Emitted when the focus trap becomes active. * Useful for triggering additional accessibility announcements or analytics. */ activate: () => true, /** * Emitted when the focus trap is deactivated. * Can be used for cleanup, analytics, or triggering state changes. */ deactivate: () => true, }, setup(props, { emit, slots, expose }) { const containerRef = vue.ref(null); const prevFocusedRef = vue.ref(null); const isActiveRef = vue.ref(false); const lastTabNavDirectionRef = vue.ref('forward'); const tabEventSourceRef = vue.ref(null); let handleKeyDown = null; let handleFocusIn = null; const activateTrap = () => { const container = containerRef.value; const additionalContainer = props.additionalContainer?.value || null; if (!container) { return; } prevFocusedRef.value = document.activeElement; // Activating... isActiveRef.value = true; // Set initial focus if (props.focusFirstElement) { const elements = utils.focusableChildren(container); if (elements.length > 0) { elements[0].focus({ preventScroll: true }); } else { // Fallback to container if no focusable elements container.focus({ preventScroll: true }); } } else { container.focus({ preventScroll: true }); } emit('activate'); // Create event handlers handleFocusIn = (event) => { // Only handle focus events from tab navigation if (containerRef.value !== tabEventSourceRef.value) { return; } const target = event.target; // Allow focus within container if (target === document || target === container || container.contains(target)) { return; } // Allow focus within additional elements if (additionalContainer && (target === additionalContainer || additionalContainer.contains(target))) { return; } // Focus escaped, bring it back const elements = utils.focusableChildren(container); if (elements.length === 0) { container.focus({ preventScroll: true }); } else if (lastTabNavDirectionRef.value === 'backward') { elements.at(-1)?.focus({ preventScroll: true }); } else { elements[0].focus({ preventScroll: true }); } }; handleKeyDown = (event) => { if (event.key !== 'Tab') { return; } tabEventSourceRef.value = container; lastTabNavDirectionRef.value = event.shiftKey ? 'backward' : 'forward'; if (!additionalContainer) { return; } const containerElements = utils.focusableChildren(container); const additionalElements = utils.focusableChildren(additionalContainer); if (containerElements.length === 0 && additionalElements.length === 0) { // No focusable elements, prevent tab event.preventDefault(); return; } const activeElement = document.activeElement; const isInContainer = containerElements.includes(activeElement); const isInAdditional = additionalElements.includes(activeElement); // Handle tab navigation between container and additional elements if (isInContainer) { const index = containerElements.indexOf(activeElement); if (!event.shiftKey && index === containerElements.length - 1 && additionalElements.length > 0) { // Tab forward from last container element to first additional element event.preventDefault(); additionalElements[0].focus({ preventScroll: true }); } else if (event.shiftKey && index === 0 && additionalElements.length > 0) { // Tab backward from first container element to last additional element event.preventDefault(); additionalElements.at(-1)?.focus({ preventScroll: true }); } } else if (isInAdditional) { const index = additionalElements.indexOf(activeElement); if (!event.shiftKey && index === additionalElements.length - 1 && containerElements.length > 0) { // Tab forward from last additional element to first container element event.preventDefault(); containerElements[0].focus({ preventScroll: true }); } else if (event.shiftKey && index === 0 && containerElements.length > 0) { // Tab backward from first additional element to last container element event.preventDefault(); containerElements.at(-1)?.focus({ preventScroll: true }); } } }; // Add event listeners container.addEventListener('keydown', handleKeyDown, true); if (additionalContainer) { additionalContainer.addEventListener('keydown', handleKeyDown, true); } document.addEventListener('focusin', handleFocusIn, true); }; const deactivateTrap = () => { if (!isActiveRef.value) { return; } // Cleanup event listeners const container = containerRef.value; const additionalContainer = props.additionalContainer?.value || null; if (container && handleKeyDown) { container.removeEventListener('keydown', handleKeyDown, true); } if (additionalContainer && handleKeyDown) { additionalContainer.removeEventListener('keydown', handleKeyDown, true); } if (handleFocusIn) { document.removeEventListener('focusin', handleFocusIn, true); } // Restore focus if (props.restoreFocus && prevFocusedRef.value?.isConnected) { prevFocusedRef.value.focus({ preventScroll: true }); } emit('deactivate'); isActiveRef.value = false; prevFocusedRef.value = null; }; vue.watch(() => props.active, (newActive) => { if (newActive && containerRef.value) { activateTrap(); } else { deactivateTrap(); } }, { immediate: false }); vue.watch(() => props.additionalContainer?.value, () => { if (props.active && isActiveRef.value) { // Reactivate to update event listeners deactivateTrap(); activateTrap(); } }); vue.onMounted(() => { if (props.active && containerRef.value) { activateTrap(); } }); vue.onUnmounted(() => { deactivateTrap(); }); // Expose containerRef for parent components expose({ containerRef, }); return () => { const vnodes = slots.default?.(); const vnode = vnodes?.[0]; if (!vnode) return null; const originalRef = vnode.props?.ref; return vue.cloneVNode(vnode, { ref: (el) => { containerRef.value = el; if (typeof originalRef === 'function') { originalRef(el); } else if (originalRef && typeof originalRef === 'object' && 'value' in originalRef) { originalRef.value = el; } }, }); }; }, }); exports.CFocusTrap = CFocusTrap; //# sourceMappingURL=CFocusTrap.js.map