Skip to content

Commit 31c19dc

Browse files
committed
fix(select): prevent menu from closing when clicking on an option that is no longer in the DOM and adjust focus behavior for multi-select
See #417 and #422
1 parent 82d8630 commit 31c19dc

3 files changed

Lines changed: 61 additions & 4 deletions

File tree

β€Žsrc/Menu.vueβ€Ž

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,14 @@ const handleNavigation = (e: KeyboardEvent) => {
142142
};
143143
144144
const handleClickOutside = (event: MouseEvent) => {
145-
const target = event.target as Node;
145+
const target = event.target as HTMLElement;
146146
const isInsideContainer = sharedData.containerRef.value && sharedData.containerRef.value.contains(target);
147147
const isInsideMenu = menuRef.value && menuRef.value.contains(target);
148+
// This function is called right after clicking on an option. DOM node of the
149+
// option is already removed from the DOM, which falsify the check.
150+
const isOption = target?.getAttribute?.("role") === "option";
148151
149-
if (!isInsideContainer && !isInsideMenu) {
152+
if (!isInsideContainer && !isInsideMenu && !isOption) {
150153
sharedData.closeMenu();
151154
}
152155
};

β€Žsrc/Select.spec.tsβ€Ž

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,23 @@ describe("multi-select options", () => {
280280
expect(wrapper.findAll(".menu-option").length).toBe(options.length);
281281
expect(wrapper.findAll(".multi-value").length).toBe(0);
282282
});
283+
284+
it("should focus second-to-last option when selecting the last option (multi-select)", async () => {
285+
const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, closeOnSelect: false } });
286+
287+
await openMenu(wrapper);
288+
289+
const lastOption = wrapper.findAll("div[role='option']")[options.length - 1];
290+
291+
if (!lastOption) {
292+
throw new Error("Last option not found.");
293+
}
294+
295+
await lastOption.trigger("click");
296+
await wrapper.vm.$nextTick();
297+
298+
expect(wrapper.get(".focused[role='option']").text()).toBe(options[1]?.label);
299+
});
283300
});
284301

285302
describe("option-deselected event", () => {
@@ -828,6 +845,35 @@ describe("menu closing behavior", () => {
828845
await wrapper.get("input").trigger("mousedown");
829846
expect(wrapper.findAll("div[role='option']").length).toBe(2);
830847
});
848+
849+
it("should not close menu when click target is an option node no longer in DOM (isMulti=true + closeOnSelect=false)", async () => {
850+
const wrapper = mount(VueSelect, {
851+
props: { modelValue: [], isMulti: true, options, closeOnSelect: false, hideSelectedOptions: true },
852+
attachTo: document.body,
853+
});
854+
855+
await openMenu(wrapper);
856+
expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
857+
858+
const lastOption = wrapper.findAll("div[role='option']")[options.length - 1];
859+
if (!lastOption?.element) {
860+
throw new Error("Last option element not found.");
861+
}
862+
863+
lastOption.trigger("click");
864+
await wrapper.vm.$nextTick();
865+
866+
const optionOutsideSelect = document.createElement("div");
867+
optionOutsideSelect.setAttribute("role", "option");
868+
document.body.appendChild(optionOutsideSelect);
869+
optionOutsideSelect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
870+
await wrapper.vm.$nextTick();
871+
872+
expect(wrapper.find(".menu").exists()).toBe(true);
873+
874+
document.body.removeChild(optionOutsideSelect);
875+
wrapper.unmount();
876+
});
831877
});
832878

833879
describe("hideSelectedOptions prop", () => {

β€Žsrc/Select.vueβ€Ž

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,22 @@ const setOption = (option: GenericOption) => {
196196
197197
if (props.isMulti) {
198198
if (Array.isArray(selected.value)) {
199-
const isAlreadyPresent = selected.value.find((v) => v === option.value);
199+
const isAlreadySelected = !!selected.value.find((v) => v === option.value);
200+
const indexInMenu = availableOptions.value.findIndex((v) => v.value === option.value);
201+
const isLastIndexInMenu = indexInMenu >= availableOptions.value.length - 1;
200202
201-
if (!isAlreadyPresent) {
203+
if (!isAlreadySelected) {
202204
selected.value = [...selected.value, option.value];
203205
}
204206
else {
205207
selected.value = selected.value.filter((v) => v !== option.value);
206208
}
209+
210+
// When selecting the last option, reset focused option to the second last option.
211+
// Avoids an issue where no focused option is available when clicking on the last option.
212+
if (isLastIndexInMenu) {
213+
focusedOption.value = availableOptions.value.length - 2;
214+
}
207215
}
208216
else {
209217
selected.value = [option.value];

0 commit comments

Comments
Β (0)