Adopt <dialog> for our modals to tap into browser built-in functionality.

This commit is contained in:
2025-12-10 19:41:55 +00:00
parent cdb9691b59
commit 851b17f45c
2 changed files with 80 additions and 56 deletions
+37 -39
View File
@@ -1,9 +1,7 @@
<template> <template>
<div class="modal-overlay" @keydown.esc="$emit('close')" @keydown="handleOverlayKeydown" tabindex="-1"> <dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="modal" role="dialog" aria-modal="true"> <slot />
<slot /> </dialog>
</div>
</div>
</template> </template>
<script setup> <script setup>
@@ -21,6 +19,9 @@ const props = defineProps({
defineEmits(['close']) defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
// Store the element that had focus before modal opened // Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null) const previouslyFocusedElement = ref(null)
@@ -74,7 +75,7 @@ const restoreFocus = () => {
} }
} }
const handleOverlayKeydown = (event) => { const handleDialogKeydown = (event) => {
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -110,19 +111,21 @@ onMounted(() => {
// Save currently focused element before modal takes focus // Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement previouslyFocusedElement.value = document.activeElement
// Autofocus the most appropriate element: // Show the dialog as a modal
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
nextTick(() => { nextTick(() => {
const modal = document.querySelector('.modal') if (dialog.value) {
if (modal) { dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
// Mark primary button for keyboard navigation // Mark primary button for keyboard navigation
const primaryBtn = modal.querySelector('.modal-actions .btn-primary') const primaryBtn = dialog.value.querySelector('.modal-actions .btn-primary')
if (primaryBtn) { if (primaryBtn) {
primaryBtn.setAttribute('data-nav-primary', '') primaryBtn.setAttribute('data-nav-primary', '')
} }
// Focus the most appropriate element // Focus the most appropriate element
focusDialogDefault(modal) focusDialogDefault(dialog.value)
} }
}) })
}) })
@@ -134,22 +137,7 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.modal-overlay { dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -159,37 +147,47 @@ onUnmounted(() => {
width: min(500px, 90vw); width: min(500px, 90vw);
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
} }
.modal :deep(.modal-title), dialog::backdrop {
.modal :deep(h3) { background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
margin: 0 0 var(--space-md); margin: 0 0 var(--space-md);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
} }
.modal :deep(form) { dialog :deep(form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
.modal :deep(.modal-form) { dialog :deep(.modal-form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
.modal :deep(.modal-form label) { dialog :deep(.modal-form label) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); gap: var(--space-xs);
font-weight: 500; font-weight: 500;
} }
.modal :deep(.modal-form input), dialog :deep(.modal-form input),
.modal :deep(.modal-form textarea) { dialog :deep(.modal-form textarea) {
padding: var(--space-md); padding: var(--space-md);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -200,14 +198,14 @@ onUnmounted(() => {
min-height: 2.5rem; min-height: 2.5rem;
} }
.modal :deep(.modal-form input:focus), dialog :deep(.modal-form input:focus),
.modal :deep(.modal-form textarea:focus) { dialog :deep(.modal-form textarea:focus) {
outline: none; outline: none;
border-color: var(--color-accent); border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe; box-shadow: 0 0 0 2px #c7d2fe;
} }
.modal :deep(.modal-actions) { dialog :deep(.modal-actions) {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-sm); gap: var(--space-sm);
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="linkUrl" class="dialog-overlay no-backdrop" @keydown.esc.prevent="$emit('close')"> <dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <h2 id="regTitle" class="reg-title">
@@ -29,13 +29,13 @@
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
</div> </div>
</div> </div>
</div> </dialog>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue' import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from '@/utils/api' import { apiJson } from '@/utils/api'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
@@ -46,6 +46,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'copied']) const emit = defineEmits(['close', 'copied'])
const dialog = ref(null)
const linkUrl = ref(null) const linkUrl = ref(null)
const expiresAt = ref(null) const expiresAt = ref(null)
const actionsRow = ref(null) const actionsRow = ref(null)
@@ -58,11 +59,17 @@ async function generateLink() {
if (data.url) { if (data.url) {
linkUrl.value = data.url linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null expiresAt.value = data.expires ? new Date(data.expires) : null
// Focus primary button (or first button if no primary) after content renders
// Show the dialog as modal
await nextTick() await nextTick()
const actions = actionsRow.value if (dialog.value) {
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button') dialog.value.showModal()
target?.focus()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
} else { } else {
emit('close') emit('close')
} }
@@ -75,6 +82,18 @@ function onCopied() {
emit('copied') emit('copied')
} }
const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog>
// Handle other key navigation
const direction = getDirection(event)
if (!direction) return
if (direction === 'down' || direction === 'up') {
// Let the individual handlers manage navigation
return
}
}
const handleQRKeydown = (event) => { const handleQRKeydown = (event) => {
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -106,14 +125,10 @@ const handleActionsKeydown = (event) => {
onMounted(() => { onMounted(() => {
// Save currently focused element before modal takes focus // Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement previouslyFocusedElement.value = document.activeElement
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
generateLink() generateLink()
}) })
onUnmounted(() => { onUnmounted(() => {
// Release backdrop when modal closes
releaseGlobalBackdrop()
// Restore focus when modal closes // Restore focus when modal closes
const prev = previouslyFocusedElement.value const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) { if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -123,6 +138,23 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; } .icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; } .icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; } .reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
@@ -131,10 +163,4 @@ onUnmounted(() => {
.reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); } .reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; } .reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; } .expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
/* Use global backdrop, not local */
.dialog-overlay.no-backdrop {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
</style> </style>