Frontend: Stop chip-selector from committing on ArrowDown blur #4966

Vuetify's v-combobox shifts DOM focus from the input to the first
v-list-item in the dropdown menu when the user presses ArrowDown.
The chip-selector's @blur handler treated that focus shift as
"user is leaving the field — commit pending text," which silently
turned typed prefixes (e.g. `ca`) into brand-new chips before the
user could highlight `Camping` and press Enter.

The blur handler now inspects relatedTarget and skips the commit
when focus moves into a v-list-item or any descendant of a
v-overlay-container / v-list / v-menu. Tabbing or clicking to a
sibling field still commits as before. The chip-selector backs the
Labels and Albums inputs in the batch-edit dialog (and the file
dialog), so the regression was visible in both surfaces.
This commit is contained in:
Michael Mayer 2026-05-11 12:26:33 +00:00
parent 9788c2b06f
commit a81082a267
2 changed files with 72 additions and 1 deletions

View file

@ -51,7 +51,7 @@
class="chip-selector__input"
@click:control="focusInput"
@keydown.enter.prevent="onEnter"
@blur="addNewItem"
@blur="onInputBlur"
@update:model-value="onComboboxChange"
@update:menu="onMenuUpdate"
>
@ -333,6 +333,25 @@ export default {
this.addNewItem();
},
// Blur on the combobox input fires for two distinct reasons:
// (a) the user is leaving the field (Tab, click elsewhere) pending
// text should commit as a new chip, mirroring the inline-text
// auto-commit-on-blur contract elsewhere in the app.
// (b) Vuetify shifts DOM focus into the dropdown menu (ArrowDown,
// Home, End) the user is mid-navigation; committing the
// typed prefix here would silently turn `ca` into a brand-new
// chip before they can pick `Camping` from the suggestions.
// The menu list items are teleported to a v-overlay-container, so the
// shared DOM parent is `<body>`; detect them via `relatedTarget` class
// / ancestor lookup and skip the commit in case (b).
onInputBlur(event) {
const rt = event?.relatedTarget;
if (rt && (rt.classList?.contains("v-list-item") || rt.closest?.(".v-overlay-container, .v-list, .v-menu"))) {
return;
}
this.addNewItem();
},
onMenuUpdate(val) {
if (val && this.suppressMenuOpen) {
this.menuOpen = false;

View file

@ -232,6 +232,58 @@ describe("component/file/chip-selector", () => {
expect(wrapper.emitted("update:items")).toBeFalsy();
});
// Repros the user-reported regression: typing `ca` and pressing ArrowDown
// used to commit `ca` as a brand-new chip before the user could pick
// `Camping` from the dropdown. The trigger is `@blur` firing on the
// input as Vuetify shifts DOM focus into the teleported v-list-item
// menu. onInputBlur skips the commit when relatedTarget is a menu item.
it("skips addNewItem on blur when focus shifts into the dropdown menu (ArrowDown)", () => {
wrapper.vm.newItemTitle = "ca";
// relatedTarget mimics the v-list-item Vuetify focuses on ArrowDown.
const menuItem = document.createElement("div");
menuItem.className = "v-list-item v-list-item--link";
wrapper.vm.onInputBlur({ relatedTarget: menuItem });
expect(wrapper.emitted("update:items")).toBeFalsy();
});
it("skips addNewItem on blur when relatedTarget is inside a v-overlay-container", () => {
wrapper.vm.newItemTitle = "ca";
// Some Vuetify menu items render under .v-overlay-container without
// the v-list-item class on the focused descendant; assert the ancestor
// check catches them too.
const overlay = document.createElement("div");
overlay.className = "v-overlay-container";
const focusableInside = document.createElement("div");
overlay.appendChild(focusableInside);
document.body.appendChild(overlay);
try {
wrapper.vm.onInputBlur({ relatedTarget: focusableInside });
expect(wrapper.emitted("update:items")).toBeFalsy();
} finally {
document.body.removeChild(overlay);
}
});
it("commits pending text on blur when relatedTarget is outside the dropdown menu", () => {
wrapper.vm.newItemTitle = "Manually-Typed";
// Tabbing to a sibling field — relatedTarget has no v-list-item /
// v-overlay-container ancestry. The blur should still commit.
const sibling = document.createElement("button");
sibling.className = "some-other-button";
wrapper.vm.onInputBlur({ relatedTarget: sibling });
const emitted = wrapper.emitted("update:items");
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Manually-Typed", action: "add", isNew: true })]));
});
});
describe("Label resolver and normalization", () => {