Focus Management¶
PhotoPrism uses a shared view helper to maintain predictable focus across pages and dialogs.
This helper tracks the currently active component, applies focus when views change, and traps focus inside open dialogs, ensuring that tabbing never leaks into the page behind an overlay. The following guidelines explain how to work with the helper when building UI functionality.
Tabindex Cheat Sheet¶
| Value | When to use it | Effect |
|---|---|---|
0 |
Interactive controls in the natural tab order | Element participates in sequential keyboard focus |
-1 |
Programmatic focus targets (dialog wrappers, sentinels) | Element can receive focus via script but is skipped while tabbing |
| positive | Avoid | Custom tab order becomes hard to maintain; the view helper no longer knows the “first” element |
Tips
- Root page containers (
<div class="p-page ...">) should usetabindex="-1"so the view helper can focus them when a route becomes active, then immediately move focus to the first interactive control. - Leave buttons, inputs, and links at the default
tabindex="0"(or no attribute) so the browser controls the natural order.
Dialog Implementation Checklist¶
Vuetify dialogs are teleported to the overlay container, so consistent refs and lifecycle hooks are essential.
- Add refs and focus hooks
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card ref="content" tabindex="-1">
<!-- dialog body -->
</v-card>
</v-dialog>
export default {
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
},
};
ref="dialog"lets the view helper grab the teleported overlay viaref.contentEl.-
The
$view.enter/leavecalls are mandatory so the helper knows when to trap or release focus. -
Keep the first focusable control at
tabindex="0"
<v-card-actions class="action-buttons">
<v-btn variant="flat" color="button" class="action-cancel" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn variant="flat" color="highlight" class="action-confirm" @click.stop="confirm">
{{ $gettext(`Delete`) }}
</v-btn>
</v-card-actions>
The view helper resolves the first tabbable element (Cancel in this case) as the fallback when tabbing inside the dialog.
- Avoid per-dialog traps unless necessary
Only add local @focusout handlers if a dialog needs custom behaviour. If you do, always call ev.preventDefault() when you redirect focus so you do not fight the global handler.
Example: Confirmation Dialog¶
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-file-delete-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card ref="content" tabindex="-1">
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon icon="mdi-delete-outline" size="54" color="primary"></v-icon>
<p class="text-subtitle-1">{{ $gettext(`Are you sure you want to permanently delete this file?`) }}</p>
</v-card-title>
<v-card-actions class="action-buttons mt-1">
<v-btn variant="flat" color="button" class="action-cancel" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn color="highlight" variant="flat" class="action-confirm" @click.stop="confirm">
{{ $gettext(`Delete`) }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "PFileDeleteDialog",
props: {
visible: Boolean,
},
emits: ["close", "confirm"],
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},
confirm() {
this.$emit("confirm");
},
},
};
</script>
This pattern ensures:
- The dialog registers with the view helper as soon as it appears (
afterEnter). - Focus defaults to the
Cancelbutton (first tabbable control). - Tabbing continues to cycle between
CancelandDeleteuntil the dialog closes.
Troubleshooting Checklist¶
Focus escapes the dialog when tabbing
- Verify the dialog calls
$view.enter(this)/$view.leave(this). - Confirm the dialog template has
ref="dialog"; if you teleport manually, exposecontentEl. - Ensure there is at least one control with
tabindex="0"inside the card. Pure static content cannot trap focus.
Focus lands on the overlay instead of a button
- Check for stray
tabindex="-1"on child elements. Only the outer container should use-1. - Use the browser console with
tracelogging enabled (this.$config.get("trace")) to see which elements receivedocument.focusin/out.
Custom focusout handler keeps fighting the trap
- Make sure the handler checks
this.$view.isActive(this)and callsev.preventDefault()when redirecting focus. - Consider removing the custom handler if the global trap already matches the desired behaviour.
Nested dialogs (dialog inside dialog)
- Each dialog must have
ref="dialog"so the helper can distinguish them. - The helper chooses the currently active component (
this.$view.getCurrent()) as the trap owner, so opening a second dialog automatically pauses the first one’s trap.