|
|
@@ -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>
|