Files
paskia/frontend/src/components/Modal.vue
T
LeoVasanko 68dccc1378 OAuth2 OpenID Connect provider support, API and DB refactoring (#3)
Allows Paskia to authenticate the user to a client site.
- User friendly client registration flow on the admin app
- Redirect-based authentication flow (per spec)
- Backchannel logout both ways to keep sessions synchronized
- Groups integrated with Paskia's permission system
- Adds email, preferred username and telephone fields on user profile
- All new user basic info layout to show the new information, better looks
- API and DB structures redesigned
- Various unrelated fixes to theming and layout
2026-02-18 02:40:27 +00:00

199 lines
5.9 KiB
Vue

<template>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
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: '' }
})
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
// 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 handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
// Check if we're in a modal-actions row
const target = event.target
const actionsRow = target.closest('.modal-actions')
if (actionsRow && (direction === 'left' || direction === 'right')) {
event.preventDefault()
navigateButtonRow(actionsRow, target, direction, { itemSelector: 'button' })
} else if (direction === 'up' && actionsRow) {
// From actions, try to go back to last input or focusable element in form
event.preventDefault()
const form = actionsRow.closest('form') || actionsRow.closest('.modal-form')
const inputs = form?.querySelectorAll('input, textarea, select, button:not(.modal-actions button)')
if (inputs && inputs.length > 0) {
inputs[inputs.length - 1].focus()
}
} else if (direction === 'down' && !actionsRow) {
// From an input, try to go to modal-actions
const form = target.closest('form') || target.closest('.modal-form')
if (form) {
event.preventDefault()
const actions = form.querySelector('.modal-actions')
if (actions) {
focusPreferred(actions, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
}
}
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
// 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
const primaryBtn = dialog.value.querySelector('.modal-actions .btn-primary')
if (primaryBtn) {
primaryBtn.setAttribute('data-nav-primary', '')
}
// Focus the most appropriate element
focusDialogDefault(dialog.value)
}
})
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: calc(var(--space-lg) - var(--space-xs));
max-width: 500px;
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
}
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-md);
margin-bottom: var(--space-xs);
}
</style>