Skip to content

Commit 21f29a3

Browse files
authored
feat: add isMenuOpen prop to programatically control menu state (#182)
* feat: add isMenuOpen prop to programatically control menu state Fixes #161 Add an option to programmatically control the menu open state. * Add a new prop `isMenuOpen` to `src/Select.vue` to control the menu open state. * Update the `openMenu` and `closeMenu` methods to use the `isMenuOpen` prop. * Add a watcher to sync the `menuOpen` state with the `isMenuOpen` prop. * Update the documentation in `docs/props.md` to include the new `isMenuOpen` prop. * Add tests in `src/Select.spec.ts` to verify the functionality of the `isMenuOpen` prop. --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/TotomInc/vue3-select-component/issues/161?shareId=XXXX-XXXX-XXXX-XXXX). * fix: improve input keydown space detection * fix(select): watch props.isMenuOpen should be immediate * feat(select): emit menuOpened and menuClosed, ensure closeMenu/openMenu are called internally * feat(docs): add menu-closed and menu-opened events * feat(docs): add controlled menu demo
1 parent 44d3486 commit 21f29a3

File tree

6 files changed

+150
-31
lines changed

6 files changed

+150
-31
lines changed

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default defineConfig({
5353
{ text: "Disabled options", link: "/demo/disabled-options" },
5454
{ text: "With menu-header", link: "/demo/with-menu-header" },
5555
{ text: "With complex menu filter", link: "/demo/with-complex-menu-filter.md" },
56+
{ text: "Controlled Menu", link: "/demo/controlled-menu" },
5657
],
5758
},
5859
],

docs/demo/controlled-menu.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
title: 'Controlled Menu'
3+
---
4+
5+
# Controlled Menu
6+
7+
Control the menu open state programmatically with the `isMenuOpen` prop.
8+
9+
<script setup>
10+
import { ref } from "vue";
11+
12+
import VueSelect from "../../src";
13+
14+
const selected = ref("");
15+
const isMenuOpen = ref(false);
16+
</script>
17+
18+
<button type="button" @click="isMenuOpen = !isMenuOpen">
19+
Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
20+
</button>
21+
22+
<VueSelect
23+
v-model="selected"
24+
:options="[
25+
{ label: 'Option #1', value: 'option_1' },
26+
{ label: 'Option #2', value: 'option_2' },
27+
{ label: 'Option #3', value: 'option_3' },
28+
]"
29+
:is-menu-open="isMenuOpen"
30+
@menu-opened="isMenuOpen = true"
31+
@menu-closed="isMenuOpen = false"
32+
/>
33+
34+
## Demo source-code
35+
36+
```vue
37+
<script setup lang="ts">
38+
import { ref } from "vue";
39+
import VueSelect from "vue3-select-component";
40+
41+
const selected = ref("");
42+
const isMenuOpen = ref(false);
43+
</script>
44+
45+
<template>
46+
<button type="button" @click="isMenuOpen = !isMenuOpen">
47+
Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
48+
</button>
49+
50+
<VueSelect
51+
v-model="selected"
52+
:options="[
53+
{ label: 'Option #1', value: 'option_1' },
54+
{ label: 'Option #2', value: 'option_2' },
55+
{ label: 'Option #3', value: 'option_3' },
56+
]"
57+
:is-menu-open="isMenuOpen"
58+
@menu-opened="isMenuOpen = true"
59+
@menu-closed="isMenuOpen = false"
60+
/>
61+
</template>
62+
```

docs/events.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,31 @@ Search value is cleared when the menu is closed. This will trigger an empty stri
6565
/>
6666
</template>
6767
```
68+
69+
## `@menu-opened`
70+
71+
Emitted when the menu is opened.
72+
73+
```vue
74+
<template>
75+
<VueSelect
76+
v-model="selectedValue"
77+
:options="options"
78+
@menu-opened="() => console.log('menu opened')"
79+
/>
80+
</template>
81+
```
82+
83+
## `@menu-closed`
84+
85+
Emitted when the menu is closed.
86+
87+
```vue
88+
<template>
89+
<VueSelect
90+
v-model="selectedValue"
91+
:options="options"
92+
@menu-closed="() => console.log('menu closed')"
93+
/>
94+
</template>
95+
```

docs/props.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ Resolves option data to a string to compare options and specify value attributes
226226

227227
This function can be used if you don't want to use the standard `option.value` as the value of the option.
228228

229-
::: warning
230-
If you are using TypeScript, TODO.
231-
:::
229+
## isMenuOpen
230+
231+
**Type**: `boolean`
232+
233+
**Default**: `undefined`
234+
235+
A prop to control the menu open state programmatically. When set to `true`, the menu will be open. When set to `false`, the menu will be closed.

src/Select.spec.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async function openMenu(wrapper: ReturnType<typeof mount>, method: "mousedown" |
1717
}
1818
else if (method === "focus-space") {
1919
await wrapper.get("input").trigger("focus");
20-
await wrapper.get("input").trigger("keydown", { key: "Space" });
20+
await wrapper.get("input").trigger("keydown", { code: "Space" });
2121
}
2222
else if (method === "single-value") {
2323
await wrapper.get(".single-value").trigger("click");
@@ -105,7 +105,7 @@ describe("input + menu interactions behavior", () => {
105105

106106
expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
107107

108-
await dispatchEvent(wrapper, new MouseEvent("click"));
108+
await dispatchEvent(wrapper, new MouseEvent("mousedown"));
109109

110110
expect(wrapper.findAll("div[role='option']").length).toBe(0);
111111
});
@@ -133,6 +133,22 @@ describe("input + menu interactions behavior", () => {
133133

134134
expect(wrapper.findAll("div[role='option']").length).toBe(0);
135135
});
136+
137+
it("should open the menu when isMenuOpen prop is set to true", async () => {
138+
const wrapper = mount(VueSelect, { props: { modelValue: null, options, isMenuOpen: true } });
139+
140+
expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
141+
});
142+
143+
it("should close the menu when isMenuOpen prop is set to false", async () => {
144+
const wrapper = mount(VueSelect, { props: { modelValue: null, options, isMenuOpen: true } });
145+
146+
expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
147+
148+
await wrapper.setProps({ isMenuOpen: false });
149+
150+
expect(wrapper.findAll("div[role='option']").length).toBe(0);
151+
});
136152
});
137153

138154
describe("menu on-open focus option", async () => {
@@ -401,7 +417,7 @@ describe("search emit", () => {
401417
const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
402418

403419
await inputSearch(wrapper, "United");
404-
await dispatchEvent(wrapper, new MouseEvent("click"));
420+
await dispatchEvent(wrapper, new MouseEvent("mousedown"));
405421

406422
expect(wrapper.emitted("search")).toStrictEqual([["United"], [""]]);
407423
});

src/Select.vue

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ const props = withDefaults(
4242
* when fetching the options asynchronously.
4343
*/
4444
isLoading?: boolean;
45+
/**
46+
* Control the menu open state programmatically.
47+
*/
48+
isMenuOpen?: boolean;
4549
/**
4650
* When set to true, focus the first option when the menu is opened.
4751
* When set to false, no option will be focused.
@@ -98,6 +102,7 @@ const props = withDefaults(
98102
isSearchable: true,
99103
isMulti: false,
100104
isLoading: false,
105+
isMenuOpen: undefined,
101106
shouldAutofocusOption: true,
102107
closeOnSelect: true,
103108
teleport: undefined,
@@ -112,6 +117,8 @@ const props = withDefaults(
112117
const emit = defineEmits<{
113118
(e: "optionSelected", option: GenericOption): void;
114119
(e: "optionDeselected", option: GenericOption | null): void;
120+
(e: "menuOpened"): void;
121+
(e: "menuClosed"): void;
115122
(e: "search", value: string): void;
116123
}>();
117124
@@ -185,11 +192,14 @@ const openMenu = (options?: { focusInput?: boolean }) => {
185192
if (options?.focusInput && input.value) {
186193
input.value.focus();
187194
}
195+
196+
emit("menuOpened");
188197
};
189198
190199
const closeMenu = () => {
191200
menuOpen.value = false;
192201
search.value = "";
202+
emit("menuClosed");
193203
};
194204
195205
const toggleMenu = () => {
@@ -218,7 +228,7 @@ const setOption = (option: GenericOption) => {
218228
search.value = "";
219229
220230
if (props.closeOnSelect) {
221-
menuOpen.value = false;
231+
closeMenu();
222232
}
223233
224234
if (input.value) {
@@ -243,8 +253,7 @@ const clear = () => {
243253
emit("optionDeselected", selectedOptions.value[0]);
244254
}
245255
246-
menuOpen.value = false;
247-
search.value = "";
256+
closeMenu();
248257
249258
if (input.value) {
250259
input.value.blur();
@@ -303,8 +312,7 @@ const handleNavigation = (e: KeyboardEvent) => {
303312
304313
if (e.key === "Escape") {
305314
e.preventDefault();
306-
menuOpen.value = false;
307-
search.value = "";
315+
closeMenu();
308316
}
309317
310318
const hasSelectedValue = props.isMulti ? (selected.value as OptionValue[]).length > 0 : !!selected.value;
@@ -323,33 +331,20 @@ const handleNavigation = (e: KeyboardEvent) => {
323331
}
324332
};
325333
326-
/**
327-
* When pressing space inside the input, open the menu only if the search is
328-
* empty. Otherwise, the user is typing and we should skip this action.
329-
*
330-
* @param e KeyboardEvent
331-
*/
332-
const handleInputSpace = (e: KeyboardEvent) => {
333-
if (!menuOpen.value && search.value.length === 0) {
334-
e.preventDefault();
335-
e.stopImmediatePropagation();
336-
openMenu();
337-
}
338-
};
339-
340334
const handleInputKeydown = (e: KeyboardEvent) => {
341335
if (e.key === "Tab") {
342336
closeMenu();
343337
}
344-
else if (e.key === "Space") {
345-
handleInputSpace(e);
338+
else if (e.code === "Space" && !menuOpen.value && search.value.length === 0) {
339+
e.preventDefault();
340+
e.stopImmediatePropagation();
341+
openMenu();
346342
}
347343
};
348344
349345
const handleClickOutside = (event: MouseEvent) => {
350346
if (container.value && !container.value.contains(event.target as Node)) {
351-
menuOpen.value = false;
352-
search.value = "";
347+
closeMenu();
353348
}
354349
};
355350
@@ -380,13 +375,26 @@ watch(
380375
},
381376
);
382377
378+
watch(
379+
() => props.isMenuOpen,
380+
(newValue) => {
381+
if (newValue) {
382+
openMenu({ focusInput: true });
383+
}
384+
else {
385+
closeMenu();
386+
}
387+
},
388+
{ immediate: true },
389+
);
390+
383391
onMounted(() => {
384-
document.addEventListener("click", handleClickOutside);
392+
document.addEventListener("mousedown", handleClickOutside);
385393
document.addEventListener("keydown", handleNavigation);
386394
});
387395
388396
onBeforeUnmount(() => {
389-
document.removeEventListener("click", handleClickOutside);
397+
document.removeEventListener("mousedown", handleClickOutside);
390398
document.removeEventListener("keydown", handleNavigation);
391399
});
392400
</script>

0 commit comments

Comments
 (0)