diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 7857b6f..9ef119e 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
{ text: "Home", link: "/" },
{ text: "Docs", link: "/getting-started" },
{ text: "Demo", link: "/demo/single-select" },
+ { text: "Playground", link: "https://play.vuejs.org/#eNqNU01v2zAM/SuCLtmA2MaQ7ZK5Qbehh/WwDU2xyzwMqs04TmVJ0IedIfF/HyU7qZOmxS6GRT3yPT5SO/pJqbhxQOc0NbmulCUGrFOEM1FeZdSajC4yUdVKakt2RMOKdGSlZU0yinkZ/Xi8/elgCRxyO7qfRSaEolwiSICwISMTuRTGkoZxB+TKl02N1ZUoyZ4Ix/nijf++RWia9LpQBR4s1IozC3giJH1i9EdCmqiWBXDUHQpntA/PpbIV8mH8Vx8h2ApnD8DnZHLLGrYMFJNpLwiDGzMh3fQ5+P6vgmdg+wL4zpkxTPvjReCyrVZjpAnnI/T3oRFsPYe15AVo7GVonRGlZalZXXv7/NwcK4fek+AaOqUWPRoK0ptx4NrthiHs9zgxgSPKKOm6NFGYmiYjv+mU9oOOaqbijZECl2bnq2fDBe4KFuylDtsxx5+1tcrMkyQvBKbhfKpGxwJsIlSdXCMs0U7YqoaokPW1ZzM2KSr8jOIxmDp60LI1oLFKRgdvAk+CwQZ0pEGgNaD/l/cs7YT77O5V/ktbPlbQtm3shHosYwQkFxNOuCvk3CJjIPI8XSY69N8afDarqjxz3xepOOjv/ZqfTIFxLtvbELPawVF1vob88UJ8Y7a99h8aggWjTi3TJQyt3Sy/wRb/j5f48hwfJv7C5R0YyZ3X2MM+O1Gg7BEuqP0algmX+d7cbC0Ic2jKCw1uBHxw/ssrrT/JncXvRy7+wbn6mmjgLP4Qv5vR7h/FnLCQ" },
{ text: "Changelog", link: "https://github.com/TotomInc/vue3-select-component/releases" },
],
@@ -33,12 +34,12 @@ export default defineConfig({
text: "Documentation",
items: [
{ text: "Getting Started", link: "/getting-started" },
- { text: "Dropdown Options", link: "/dropdown-options" },
+ { text: "Options", link: "/options" },
{ text: "Props", link: "/props" },
{ text: "Slots", link: "/slots" },
{ text: "Events", link: "/events" },
{ text: "Styling", link: "/styling" },
- { text: "TypeScript", link: "/typescript" },
+ { text: "TypeScript Guide", link: "/typescript" },
],
},
{
@@ -49,12 +50,10 @@ export default defineConfig({
{ text: "Multiple Select Taggable", link: "/demo/multiple-select-taggable" },
{ text: "Custom option slot", link: "/demo/custom-option-slot" },
{ text: "Custom tag slot", link: "/demo/custom-tag-slot" },
- { text: "Custom value/label properties", link: "/demo/custom-option-label-value" },
- { text: "Pre-selected values", link: "/demo/pre-selected-values" },
- { text: "Disabled options", link: "/demo/disabled-options" },
- { text: "With menu-header", link: "/demo/with-menu-header" },
- { text: "With complex menu filter", link: "/demo/with-complex-menu-filter.md" },
- { text: "Controlled Menu", link: "/demo/controlled-menu" },
+ { text: "Custom value mapping", link: "/demo/custom-value-mapping" },
+ { text: "Dropdown menu header", link: "/demo/dropdown-menu-header" },
+ { text: "Custom displayed options", link: "/demo/custom-displayed-options" },
+ { text: "Controlled menu", link: "/demo/controlled-menu" },
],
},
],
diff --git a/docs/demo/controlled-menu.md b/docs/demo/controlled-menu.md
index 0ab701c..97052a2 100644
--- a/docs/demo/controlled-menu.md
+++ b/docs/demo/controlled-menu.md
@@ -1,8 +1,8 @@
---
-title: 'Controlled Menu'
+title: 'Controlled menu'
---
-# Controlled Menu
+# Controlled menu
Control the menu open state programmatically with the `isMenuOpen` prop.
@@ -19,17 +19,19 @@ const isMenuOpen = ref(false);
Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
-
+
+
+
## Demo source-code
diff --git a/docs/demo/with-complex-menu-filter.md b/docs/demo/custom-displayed-options.md
similarity index 84%
rename from docs/demo/with-complex-menu-filter.md
rename to docs/demo/custom-displayed-options.md
index 87670d0..4307779 100644
--- a/docs/demo/with-complex-menu-filter.md
+++ b/docs/demo/custom-displayed-options.md
@@ -1,8 +1,8 @@
---
-title: 'With complex menu filter'
+title: 'Custom displayed options'
---
-# With complex menu filter
+# Custom displayed options
The following example demonstrate how you can create a complex filter inside the options menu, using:
@@ -41,19 +41,21 @@ function switchFilter() {
};
-
-
-
-
-
+
+
+
+
+
+
+
```
+You can also use the `:deep` selector to apply the CSS variables to the component's children if you prefer to no add a custom class:
+
+```vue
+
+
+
+
+
+```
+
## Scoped styling inside SFC
You can apply any custom styling using [the `:deep` selector](https://vuejs.org/api/sfc-css-features.html#deep-selectors) inside a `
diff --git a/src/MenuOption.vue b/src/MenuOption.vue
index d05e187..2217bae 100644
--- a/src/MenuOption.vue
+++ b/src/MenuOption.vue
@@ -44,9 +44,10 @@ watch(
+
+
diff --git a/src/MultiValue.vue b/src/MultiValue.vue
new file mode 100644
index 0000000..a9bb994
--- /dev/null
+++ b/src/MultiValue.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
diff --git a/src/Placeholder.vue b/src/Placeholder.vue
new file mode 100644
index 0000000..13daf01
--- /dev/null
+++ b/src/Placeholder.vue
@@ -0,0 +1,18 @@
+
+
+
+
+ {{ text }}
+
+
+
+
diff --git a/src/Select.spec.ts b/src/Select.spec.ts
index bfddf75..1e9652b 100644
--- a/src/Select.spec.ts
+++ b/src/Select.spec.ts
@@ -33,17 +33,11 @@ async function inputSearch(wrapper: ReturnType, search: string) {
await wrapper.get("input").setValue(search);
}
-it("should render the component", () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- expect(wrapper.exists()).toBe(true);
-});
-
describe("input + menu interactions behavior", () => {
- it("should display the placeholder in the input when no option is selected", () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
+ it("should display the placeholder when no option is selected", () => {
+ const wrapper = mount(VueSelect, { props: { modelValue: null, options, placeholder: "Select an option" } });
- expect(wrapper.find("input").attributes("placeholder"));
+ expect(wrapper.find(".placeholder").text()).toBe("Select an option");
});
it("should not open the menu when focusing the input", async () => {
@@ -54,30 +48,6 @@ describe("input + menu interactions behavior", () => {
expect(wrapper.findAll("div[role='option']").length).toBe(0);
});
- it("should open the menu when triggering mousedown on the input", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
- });
-
- it("should open the menu when focusing the input and pressing space", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper, "focus-space");
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
- });
-
- it("should open the menu when clicking on the dropdown button", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await wrapper.get(".dropdown-icon").trigger("click");
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
- });
-
it("should not open the menu when is-disabled and an option is selected", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: options[0].value, options, isDisabled: true } });
@@ -86,54 +56,6 @@ describe("input + menu interactions behavior", () => {
expect(wrapper.findAll("div[role='option']").length).toBe(0);
});
- it("should close the menu after focusing and pressing tab", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
-
- await wrapper.get("input").trigger("keydown", { key: "Tab" });
-
- expect(wrapper.findAll("div[role='option']").length).toBe(0);
- });
-
- it("should close the menu when clicking outside the menu", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
-
- await dispatchEvent(wrapper, new MouseEvent("mousedown"));
-
- expect(wrapper.findAll("div[role='option']").length).toBe(0);
- });
-
- it("should close the menu when hitting escape", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await wrapper.get("input").trigger("mousedown");
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
-
- await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Escape" }));
-
- expect(wrapper.findAll("div[role='option']").length).toBe(0);
- });
-
- it("should close the menu when clicking on the dropdown button", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
-
- await wrapper.get(".dropdown-icon").trigger("click");
-
- expect(wrapper.findAll("div[role='option']").length).toBe(0);
- });
-
it("should open the menu when isMenuOpen prop is set to true", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: null, options, isMenuOpen: true } });
@@ -151,29 +73,6 @@ describe("input + menu interactions behavior", () => {
});
});
-describe("menu on-open focus option", async () => {
- it("should focus the first option when opening the menu", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
- });
-
- it("should focus the first available available option when a disabled option is at the first index", async () => {
- const options = [
- { label: "Spain", value: "ES", disabled: true },
- { label: "France", value: "FR" },
- ];
-
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.get(".focused[role='option']").text()).toBe("France");
- });
-});
-
describe("menu keyboard navigation", () => {
it("should navigate through the options with the arrow keys", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
@@ -315,14 +214,6 @@ describe("single-select option", () => {
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
});
-
- it("should autofocus the first option when opening the menu, by default", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
- });
});
describe("multi-select options", () => {
@@ -355,20 +246,12 @@ describe("multi-select options", () => {
expect(wrapper.findAll(".menu-option").length).toBe(options.length - 1);
- await wrapper.get(".multi-value").trigger("click");
+ await wrapper.get(".multi-value-remove").trigger("click");
await openMenu(wrapper);
expect(wrapper.findAll(".menu-option").length).toBe(options.length);
expect(wrapper.findAll(".multi-value").length).toBe(0);
});
-
- it("should autofocus the first option when opening the menu, by default", async () => {
- const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options } });
-
- await openMenu(wrapper);
-
- expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
- });
});
describe("clear button", () => {
@@ -469,12 +352,6 @@ describe("component props", () => {
expect(wrapper.get(".single-value").text()).toBe("Admin");
});
- it("should display the placeholder in the input when no option is selected", () => {
- const wrapper = mount(VueSelect, { props: { modelValue: null, options, placeholder: "Pick an option" } });
-
- expect(wrapper.find("input").attributes("placeholder")).toBe("Pick an option");
- });
-
it("should disable the input when passing the isDisabled prop", () => {
const wrapper = mount(VueSelect, { props: { modelValue: null, options, isDisabled: true } });
@@ -537,3 +414,68 @@ describe("taggable prop", () => {
expect(wrapper.find(".custom-taggable-no-options").text()).toBe("Create option: New Option");
});
});
+
+describe("menu autofocus behavior", () => {
+ it("should autofocus first option when opening menu", async () => {
+ const testCases = [
+ { name: "single-select", props: { modelValue: null, options } },
+ { name: "multi-select", props: { modelValue: [], isMulti: true, options } },
+ ];
+
+ for (const testCase of testCases) {
+ // @ts-expect-error -- ignore type error
+ const wrapper = mount(VueSelect, { props: testCase.props });
+ await openMenu(wrapper);
+ expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
+ }
+ });
+
+ it("should focus first available option when first option is disabled", async () => {
+ const options = [
+ { label: "Spain", value: "ES", disabled: true },
+ { label: "France", value: "FR" },
+ ];
+
+ const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
+ await openMenu(wrapper);
+ expect(wrapper.get(".focused[role='option']").text()).toBe("France");
+ });
+});
+
+describe("menu opening behavior", () => {
+ it("should open menu with different triggers", async () => {
+ const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
+
+ const triggers = [
+ { name: "mousedown on input", action: async () => await openMenu(wrapper, "mousedown") },
+ { name: "space after focus", action: async () => await openMenu(wrapper, "focus-space") },
+ { name: "dropdown button click", action: async () => await wrapper.get(".dropdown-icon").trigger("click") },
+ ];
+
+ for (const trigger of triggers) {
+ await trigger.action();
+ expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
+ await wrapper.get(".dropdown-icon").trigger("click");
+ }
+ });
+});
+
+describe("menu closing behavior", () => {
+ it("should close menu with different triggers", async () => {
+ const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
+
+ const closeTriggers = [
+ { name: "tab key", action: async () => await wrapper.get("input").trigger("keydown", { key: "Tab" }) },
+ { name: "outside click", action: async () => await dispatchEvent(wrapper, new MouseEvent("mousedown")) },
+ { name: "escape key", action: async () => await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Escape" })) },
+ { name: "dropdown button", action: async () => await wrapper.get(".dropdown-icon").trigger("click") },
+ ];
+
+ for (const trigger of closeTriggers) {
+ await openMenu(wrapper);
+ expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
+ await trigger.action();
+ expect(wrapper.findAll("div[role='option']").length).toBe(0);
+ }
+ });
+});
diff --git a/src/Select.vue b/src/Select.vue
index de9abc6..9637036 100644
--- a/src/Select.vue
+++ b/src/Select.vue
@@ -1,104 +1,17 @@