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 @@ + + + + + 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 @@ + + + + + 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 @@