瀏覽代碼

fix: trap focus in task hint modal

jimmylee 2 周之前
父節點
當前提交
a251333a38
共有 1 個文件被更改,包括 103 次插入1 次删除
  1. 103 1
      src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue

+ 103 - 1
src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue

@@ -1,10 +1,13 @@
 <template>
   <div v-if="visible" class="modal-mask" @click.self="emit('close')">
     <div
+      ref="dialogRef"
       class="modal hint-modal scale-in"
       role="dialog"
       aria-modal="true"
       aria-labelledby="task-hint-modal-title"
+      tabindex="-1"
+      @keydown="handleKeydown"
     >
       <div class="modal-head">
         <h3 id="task-hint-modal-title" class="modal-title">
@@ -97,9 +100,10 @@
 </template>
 
 <script lang="ts" setup>
+import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
 import type { TaskHint } from '@/types/englishSpeaking'
 
-defineProps<{
+const props = defineProps<{
   visible: boolean
   loading: boolean
   error?: string | null
@@ -111,6 +115,104 @@ const emit = defineEmits<{
   close: []
   retry: []
 }>()
+
+const dialogRef = ref<HTMLElement | null>(null)
+const previouslyFocusedElement = ref<HTMLElement | null>(null)
+
+const focusableSelector = [
+  'a[href]',
+  'button:not([disabled])',
+  'textarea:not([disabled])',
+  'input:not([disabled])',
+  'select:not([disabled])',
+  '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+const getFocusableElements = () => {
+  const dialog = dialogRef.value
+  if (!dialog) return []
+
+  return Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter((el) => {
+    if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false
+    return el.offsetParent !== null || el === document.activeElement
+  })
+}
+
+const restorePreviousFocus = () => {
+  const element = previouslyFocusedElement.value
+  previouslyFocusedElement.value = null
+
+  if (element && document.contains(element)) {
+    element.focus()
+  }
+}
+
+const focusDialog = async () => {
+  previouslyFocusedElement.value = document.activeElement instanceof HTMLElement
+    ? document.activeElement
+    : null
+
+  await nextTick()
+  dialogRef.value?.focus()
+}
+
+const containTabFocus = (event: KeyboardEvent) => {
+  const dialog = dialogRef.value
+  if (!dialog) return
+
+  const focusableElements = getFocusableElements()
+  if (!focusableElements.length) {
+    event.preventDefault()
+    dialog.focus()
+    return
+  }
+
+  const firstElement = focusableElements[0]
+  const lastElement = focusableElements[focusableElements.length - 1]
+  const activeElement = document.activeElement
+
+  if (event.shiftKey) {
+    if (activeElement === firstElement || !dialog.contains(activeElement)) {
+      event.preventDefault()
+      lastElement.focus()
+    }
+    return
+  }
+
+  if (activeElement === lastElement) {
+    event.preventDefault()
+    firstElement.focus()
+  }
+}
+
+const handleKeydown = (event: KeyboardEvent) => {
+  if (event.key === 'Escape') {
+    event.preventDefault()
+    emit('close')
+    return
+  }
+
+  if (event.key === 'Tab') {
+    containTabFocus(event)
+  }
+}
+
+watch(
+  () => props.visible,
+  (visible) => {
+    if (visible) {
+      void focusDialog()
+      return
+    }
+
+    restorePreviousFocus()
+  },
+  { flush: 'post', immediate: true },
+)
+
+onBeforeUnmount(() => {
+  restorePreviousFocus()
+})
 </script>
 
 <style lang="scss" scoped>