Restore previous focus when modal dialogs close.

This commit is contained in:
Leo Vasanko
2025-12-10 15:40:05 +00:00
parent ce783cbf57
commit 4208f78f4f
4 changed files with 174 additions and 6 deletions
+72 -2
View File
@@ -36,6 +36,7 @@ const renameIdValue = ref('')
const editingPermDisplay = ref(null)
const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
const dialogPreviousFocus = ref(null) // Track element that had focus before dialog opened
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
// Template refs for navigation
@@ -404,8 +405,77 @@ async function toggleOrgPermission(org, permId, checked) {
}
}
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
function openDialog(type, data) {
const focused = document.activeElement
dialogPreviousFocus.value = focused
// For delete operations, store sibling info to help restore focus after deletion
if (type === 'confirm' && focused) {
const row = focused.closest('tr')
if (row) {
const tbody = row.closest('tbody')
if (tbody) {
const rows = Array.from(tbody.querySelectorAll('tr'))
const idx = rows.indexOf(row)
// Store context to find next/prev row after deletion
dialog.value.focusContext = {
tbody,
index: idx,
total: rows.length,
selector: 'button:not([disabled]), a'
}
}
}
}
dialog.value = { ...dialog.value, type, data, busy: false, error: '' }
}
function closeDialog() {
const prev = dialogPreviousFocus.value
const context = dialog.value.focusContext
dialog.value = { type: null, data: null, busy: false, error: '' }
// Restore focus after dialog closes
restoreFocusAfterDialog(prev, context)
dialogPreviousFocus.value = null
}
/**
* Restore focus to the previously focused element, or find a sibling if deleted.
*/
function restoreFocusAfterDialog(prev, context) {
if (!prev) return
// Check if the original element still exists in DOM and is focusable
if (document.body.contains(prev) && !prev.disabled) {
prev.focus()
return
}
// Element was deleted - try to find a sibling using stored context
if (context?.tbody && context.selector) {
const rows = Array.from(context.tbody.querySelectorAll('tr'))
if (rows.length > 0) {
// Try the same index (next row moved up) or the last row
const targetIdx = Math.min(context.index, rows.length - 1)
const targetRow = rows[targetIdx]
const focusable = targetRow?.querySelector(context.selector)
if (focusable) {
focusable.focus()
return
}
}
}
// Fallback: try to find any focusable element in the admin panels
const container = document.querySelector('.admin-panels')
if (!container) return
const focusable = container.querySelector('button:not([disabled]), a, input:not([disabled]), [tabindex="0"]')
if (focusable) {
focusable.focus()
}
}
// Keyboard navigation handlers
function handleBreadcrumbKeydown(event) {
+22 -3
View File
@@ -5,7 +5,7 @@
<p class="view-lede">{{ subheading }}</p>
</header>
<section class="section-block">
<section class="section-block" ref="userInfoSection">
<div class="section-body">
<UserBasicInfo
v-if="user"
@@ -25,7 +25,7 @@
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@@ -58,10 +58,11 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
defineProps({
initializing: {
@@ -73,6 +74,10 @@ defineProps({
const authStore = useAuthStore()
const currentHost = window.location.host
// Template refs for navigation
const userInfoSection = ref(null)
const buttonRow = ref(null)
const user = computed(() => authStore.userInfo?.user || null)
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
@@ -105,6 +110,20 @@ const goToAuthSite = () => {
const logout = async () => {
await authStore.logout()
}
// Keyboard navigation for button row
const handleButtonRowKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
}
// Up does nothing (no elements above to navigate to)
// Down does nothing (no elements below to navigate to)
}
</script>
<style scoped>
+71 -1
View File
@@ -7,11 +7,73 @@
</template>
<script setup>
import { onMounted, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
focusFallback: { type: [HTMLElement, Object], default: null },
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
/**
* Try to restore focus to the original element, or find a suitable fallback.
* Called on unmount to restore focus when modal closes.
*/
const restoreFocus = () => {
const prev = previouslyFocusedElement.value
if (!prev) return
// Check if the original element still exists in DOM and is focusable
if (document.body.contains(prev) && !prev.disabled) {
prev.focus()
return
}
// Original element is gone (deleted) - try to find a sibling
if (props.focusSiblingSelector && props.focusIndex >= 0) {
// Find container that has items matching the selector
const containers = [
props.focusFallback?.$el || props.focusFallback,
prev.closest('[data-nav-group]'),
prev.parentElement?.closest('section'),
document.querySelector('.view-root')
].filter(Boolean)
for (const container of containers) {
if (!container) continue
const siblings = container.querySelectorAll(props.focusSiblingSelector)
if (siblings.length > 0) {
// Try to focus the next item, or the previous if we were at the end
const targetIndex = Math.min(props.focusIndex, siblings.length - 1)
const target = siblings[targetIndex]
if (target && !target.disabled) {
target.focus()
return
}
}
}
}
// Fall back to the provided fallback element
const fallback = props.focusFallback?.$el || props.focusFallback
if (fallback && document.body.contains(fallback)) {
const focusable = fallback.querySelector?.('button:not([disabled]), a, [tabindex="0"]') || fallback
if (focusable?.focus) {
focusable.focus()
return
}
}
}
const handleOverlayKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
@@ -45,6 +107,9 @@ const handleOverlayKeydown = (event) => {
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -61,6 +126,11 @@ onMounted(() => {
}
})
})
onUnmounted(() => {
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
@@ -49,6 +49,8 @@ const emit = defineEmits(['close', 'copied'])
const linkUrl = ref(null)
const expiresAt = ref(null)
const actionsRow = ref(null)
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
async function generateLink() {
try {
@@ -102,6 +104,8 @@ const handleActionsKeydown = (event) => {
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
generateLink()
@@ -110,6 +114,11 @@ onMounted(() => {
onUnmounted(() => {
// Release backdrop when modal closes
releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
prev.focus()
}
})
</script>