UX: Improve people name editing and confirm dialog focus management (#5307)

* Frontend: Improve people name editing and confirmation UX

Adds better menu control and confirmation dialog handling for editing people names in photo edit and new people pages. Ensures only one menu is open at a time, improves keyboard accessibility, and prevents conflicting confirmation dialogs. Also updates event handling and emits for dialog and people components.

* Frotend: clear model fields on cancel in people dialogs #5145

* Frontend: Enable menu opening on click in people edit and new pages #5145

* Frontend: Enhance confirmation dialog with improved keyboard accessibility #5145

* Frontend: Refactor name confirmation handling in people edit and new pages #5145

* Frontend: Update name setting logic in people edit and new pages #5145

* People: Adjust menuProps and add focus trap to PConfirmDialog #5307

Signed-off-by: Michael Mayer <michael@photoprism.app>

---------

Signed-off-by: Michael Mayer <michael@photoprism.app>
Co-authored-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Ömer Duran 2025-11-10 16:21:03 +01:00 committed by GitHub
parent 70821fb7d0
commit 2cc186074a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 102 additions and 26 deletions

View file

@ -1,21 +1,29 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
:close-delay="0"
:open-delay="0"
persistent
scrim
max-width="360"
class="p-dialog p-confirm-dialog"
@keydown.esc.exact="close"
@keyup.esc.exact="close"
@keyup.enter.exact="confirm"
@after-enter="afterEnter"
@after-leave="afterLeave"
@focusout="onFocusOut"
>
<v-card>
<v-card ref="content" tabindex="0">
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon :icon="icon" :size="iconSize" color="primary"></v-icon>
<div class="text-subtitle-1">{{ text ? text : $gettext(`Are you sure?`) }}</div>
</v-card-title>
<v-card-actions class="action-buttons">
<v-btn variant="flat" color="button" class="action-cancel action-close" @click.stop="close">
<v-btn variant="flat" tabindex="0" color="button" class="action-cancel action-close" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn color="highlight" variant="flat" class="action-confirm" @click.stop="confirm">
<v-btn color="highlight" tabindex="0" variant="flat" class="action-confirm" @click.stop="confirm">
{{ action ? action : $gettext(`Yes`) }}
</v-btn>
</v-card-actions>
@ -47,10 +55,36 @@ export default {
default: "",
},
},
emits: ["close", "confirm"],
data() {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
onFocusOut(ev) {
if (!this.$view.isActive(this)) {
return;
}
const el = this.$refs.content?.$el;
if (!ev || !ev.target || !(ev.target instanceof HTMLElement) || !(el instanceof HTMLElement)) {
return;
}
const next = ev.relatedTarget;
const leavingDialog = !next || !(next instanceof Node) || !el.contains(next);
if (leavingDialog) {
el.focus();
ev.preventDefault();
}
},
close() {
this.$emit("close");
},

View file

@ -79,19 +79,19 @@
item-title="Name"
item-value="Name"
:disabled="busy"
:menu-props="menuProps"
return-object
hide-no-data
:menu-props="menuProps"
hide-details
single-line
open-on-clear
append-icon=""
prepend-inner-icon="mdi-account-plus"
density="comfortable"
class="input-name pa-0 ma-0"
@blur="onSetName(m)"
class="input-name pa-0 ma-0 text-selectable"
@update:model-value="(person) => onSetPerson(m, person)"
@keyup.enter.native="onSetName(m)"
@blur="(ev) => onSetName(m, ev)"
@keyup.enter="(ev) => onSetName(m, ev)"
>
</v-combobox>
</v-card-actions>
@ -140,11 +140,18 @@ export default {
text: this.$gettext("Add person?"),
},
menuProps: {
closeOnClick: false,
openOnClick: true,
openOnFocus: true,
closeOnBack: true,
closeOnContentClick: true,
openOnClick: false,
persistent: false,
scrim: true,
openDelay: 0,
closeDelay: 0,
opacity: 0,
density: "compact",
maxHeight: 300,
scrollStrategy: "reposition",
},
textRule: (v) => {
if (!v || !v.length) {
@ -322,11 +329,16 @@ export default {
return true;
},
onSetName(model) {
onSetName(model, ev) {
if (this.busy || !model) {
return;
}
// If there's a pending confirmation for a different face, don't process new input
if (this.confirm.visible && this.confirm.model && this.confirm.model.UID !== model.UID) {
return;
}
const name = model?.Name;
if (!name) {
@ -343,14 +355,21 @@ export default {
if (found) {
model.Name = found.Name;
model.SubjUID = found.UID;
this.setName(model);
if (model.wasChanged()) {
this.setName(model);
}
return;
}
}
model.Name = name;
model.SubjUID = "";
this.confirm.visible = true;
if (ev && ev.key === "Enter" && !ev.isComposing && !ev.repeat) {
this.setName(model);
} else {
this.confirm.visible = true;
}
},
onConfirmSetName() {
if (!this.confirm?.model?.Name) {
@ -360,6 +379,10 @@ export default {
this.setName(this.confirm.model);
},
onCancelSetName() {
if (this.confirm && this.confirm.model) {
this.confirm.model.Name = "";
this.confirm.model.SubjUID = "";
}
this.confirm.visible = false;
},
setName(model) {

View file

@ -85,8 +85,8 @@
single-line
density="comfortable"
class="input-name pa-0 ma-0"
@blur="onSetName(m, false)"
@keyup.enter="onSetName(m, false)"
@blur="(ev) => onSetName(m, ev)"
@keyup.enter="(ev) => onSetName(m, ev)"
></v-text-field>
<v-combobox
v-else
@ -95,9 +95,9 @@
item-title="Name"
item-value="Name"
:readonly="readonly"
:menu-props="menuProps"
return-object
hide-no-data
:menu-props="menuProps"
hide-details
single-line
open-on-clear
@ -105,10 +105,10 @@
prepend-inner-icon="mdi-account-plus"
autocomplete="off"
density="comfortable"
class="input-name pa-0 ma-0"
@blur="onSetName(m, true)"
class="input-name pa-0 ma-0 text-selectable"
@update:model-value="(person) => onSetPerson(m, person)"
@keyup.enter.native="onSetName(m, false)"
@blur="(ev) => onSetName(m, ev)"
@keyup.enter="(ev) => onSetName(m, ev)"
>
</v-combobox>
</v-card-actions>
@ -155,6 +155,7 @@ export default {
},
active: Boolean,
},
emits: ["updateFaceCount"],
data() {
const query = this.$route.query;
const routeName = this.$route.name;
@ -193,11 +194,18 @@ export default {
text: this.$gettext("Add person?"),
},
menuProps: {
closeOnClick: false,
openOnClick: true,
openOnFocus: true,
closeOnBack: true,
closeOnContentClick: true,
openOnClick: false,
persistent: false,
scrim: true,
openDelay: 0,
closeDelay: 0,
opacity: 0,
density: "compact",
maxHeight: 300,
scrollStrategy: "reposition",
},
textRule: (v) => {
if (!v || !v.length) {
@ -643,11 +651,16 @@ export default {
return true;
},
onSetName(model, confirm) {
onSetName(model, ev) {
if (this.busy || !model) {
return;
}
// If there's a pending confirmation for a different face, don't process new input
if (this.confirm.visible && this.confirm.model && this.confirm.model.ID !== model.ID) {
return;
}
const name = model?.Name;
if (!name) {
@ -674,10 +687,12 @@ export default {
model.Name = name;
model.SubjUID = "";
if (confirm && model.wasChanged()) {
this.confirm.visible = true;
} else {
this.onConfirmRename();
if (model.Name) {
if (ev && ev.key === "Enter" && !ev.isComposing && !ev.repeat) {
this.setName(model, model.Name);
} else {
this.confirm.visible = true;
}
}
},
onConfirmRename() {
@ -693,6 +708,10 @@ export default {
}
},
onCancelRename() {
if (this.confirm && this.confirm.model) {
this.confirm.model.Name = "";
this.confirm.model.SubjUID = "";
}
this.confirm.visible = false;
},
setName(model, newName) {