VUE

useFocusTrap Composable

Accessibility-focused focus trap for modals and dialogs with keyboard navigation

Vue3ComposablesFocusTrapAccessibilitya11y

Code

import { ref, onMounted, onUnmounted, type Ref } from 'vue';

export function useFocusTrap(
  container: Ref<HTMLElement | null>,
  active: Ref<boolean> = ref(true)
) {
  const focusableSelectors = [
    'button:not([disabled])',
    'a[href]:not([disabled])',
    'input:not([disabled]):not([type="hidden"])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"]):not([disabled])',
    '[contenteditable="true"]:not([disabled])'
  ].join(',');

  let focusableElements: HTMLElement[] = [];
  let firstElement: HTMLElement | null = null;
  let lastElement: HTMLElement | null = null;
  let lastFocusedElement: HTMLElement | null = null;

  // Get focusable elements
  const getFocusableElements = () => {
    __TOKEN_48__ (!container.value) return [];
    return Array.__TOKEN_51__(container.value.querySelectorAll(focusableSelectors)) as HTMLElement[];
  };

  // Trap focus inside container
  const trapFocus = (e: KeyboardEvent) => {
    __TOKEN_53__ (!active.value || !container.value) return;

    // Only handle Tab key
    __TOKEN_55__ (e.key !== 'Tab') return;

    focusableElements = getFocusableElements();
    __TOKEN_57__ (focusableElements.length === 0) return;

    firstElement = focusableElements[0];
    lastElement = focusableElements[focusableElements.length - 1];
    const currentFocused = document.activeElement as HTMLElement;

    // Reverse tab (Shift + Tab)
    __TOKEN_60__ (e.shiftKey && currentFocused === firstElement) {
      e.preventDefault();
      lastElement?.focus();
    }
    // Forward tab
    else __TOKEN_62__ (!e.shiftKey && currentFocused === lastElement) {
      e.preventDefault();
      firstElement?.focus();
    }
  };

  // Activate focus trap
  const activate = () => {
    __TOKEN_64__ (!container.value) return;

    // Save last focused element
    lastFocusedElement = document.activeElement as HTMLElement;

    // Get focusable elements and focus first
    focusableElements = getFocusableElements();
    __TOKEN_66__ (focusableElements.length > 0) {
      focusableElements[0].focus();
    }

    // Add event listener
    document.addEventListener('keydown', trapFocus);
  };

  // Deactivate focus trap
  const deactivate = () => {
    document.removeEventListener('keydown', trapFocus);
    // Restore focus to last focused element
    __TOKEN_68__ (lastFocusedElement) {
      lastFocusedElement.focus();
    }
  };

  // Watch active state
  const updateFocusTrap = () => {
    __TOKEN_70__ (active.value) {
      activate();
    } else {
      deactivate();
    }
  };

  onMounted(() => {
    __TOKEN_72__ (active.value) {
      activate();
    }
    // Watch for active state changes (if reactive)
    __TOKEN_73__ (typeof active === 'object' && 'value' in active) {
      const stopWatch = watch(active, updateFocusTrap);
      onUnmounted(stopWatch);
    }
  });

  onUnmounted(() => {
    deactivate();
  });

  return { activate, deactivate };
}

// Usage example
// const modalRef = ref<HTMLElement | null>(null);
// const isModalOpen = ref(false);
// const { activate, deactivate } = useFocusTrap(modalRef, isModalOpen);
// // Open modal
// isModalOpen.value = true;
// activate();
// // Close modal
// isModalOpen.value = false;
// deactivate();