Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions src/components/BaseMultiselect.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,93 @@
<script setup>
import { ref, computed, watch } from "vue";
import { useDropdown } from "../composables/useDropdown.js";

const props = defineProps({
options: { type: Array, default: () => [] },
modelValue: { type: [String, Number, Object, Array, Boolean, null], default: null },
placeholder: { type: String, default: "Select..." }
});

const emit = defineEmits(["update:modelValue", "search"]);

const { isOpen, toggleDropdown, closeDropdown } = useDropdown();

const searchTerm = ref("");

const filteredOptions = computed(() => {
const term = String(searchTerm.value).toLowerCase();
return props.options.filter(option => {
const text = typeof option === "object"
? Object.values(option).join(" ").toLowerCase()
: String(option).toLowerCase();
return text.includes(term);
});
});

watch(() => props.modelValue, () => { closeDropdown(); });
</script>

<template>
<div class="base-multiselect">
<p>Base Multiselect Component</p>
<div class="base-multiselect-container" v-click-outside="closeDropdown">
<div class="selected-display" @click="toggleDropdown">
<slot name="selected-items" :modelValue="modelValue">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)" class="placeholder">{{ placeholder }}</span>
</slot>
<input
type="text"
v-model="searchTerm"
@input="emit('search', searchTerm)"
:placeholder="placeholder"
class="search-input"
/>
</div>

<div v-if="isOpen" class="options-dropdown">
<slot name="options" :filteredOptions="filteredOptions" :modelValue="modelValue"></slot>
</div>
</div>

</template>

<script setup></script>

<style scoped>
.base-multiselect {
padding: 1rem;
.base-multiselect-container {
position: relative;
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
}

.selected-display {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
}

.selected-display .placeholder {
color: #888;
}

.search-input {
border: none;
outline: none;
flex-grow: 1;
padding: 0;
margin-left: 8px;
}

.options-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
background-color: #fff;
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
</style>
117 changes: 73 additions & 44 deletions src/components/SingleSelect.vue
Original file line number Diff line number Diff line change
@@ -1,69 +1,98 @@
<template>
<div class="single-select">
<select v-model="selected" @change="emitSelection">
<option disabled value="">Select...</option>
<option
v-for="option in options"
:key="getOptionValue(option)"
:value="getOptionValue(option)"
>
{{ getLabel(option) }}
</option>
</select>
</div>
<base-multiselect
:options="options"
:modelValue="modelValue"
:placeholder="placeholder"
@update:modelValue="emit('update:modelValue', $event)"
>
<template #selected-items="{ modelValue: currentModelValue }">
<span v-if="selectedLabel">{{ selectedLabel }}</span>
<span v-else class="placeholder">{{ placeholder }}</span>
</template>

<template #options="{ filteredOptions, modelValue: currentModelValue }">
<ul class="single-select-options">
<li
v-for="option in filteredOptions"
:key="getOptionValue(option)"
@click="selectOption(getOptionValue(option))"
:class="{ 'is-selected': getOptionValue(option) === getSelectedValue(currentModelValue) }"
>
{{ getLabel(option) }}
</li>
</ul>
</template>
</base-multiselect>
</template>

<script setup>
import { ref, watch } from "vue";
import { computed } from 'vue'
import BaseMultiselect from './BaseMultiselect.vue'

const props = defineProps({
options: { type: Array, default: () => [] },
modelValue: { type: [String, Number, Object], default: "" },
labelKey: { type: String, default: "label" },
valueKey: { type: String, default: "value" }
});
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number, Object],
default: ''
},
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
placeholder: {
type: String,
default: 'Select...'
}
})

const emit = defineEmits(["update:modelValue"]);
const emit = defineEmits(['update:modelValue'])

function getOptionValue(option) {
return typeof option === "object" ? option[props.valueKey] : option;
return typeof option === 'object' ? option[props.valueKey] : option
}

function getLabel(option) {
return typeof option === "object" ? option[props.labelKey] : option;
return typeof option === 'object' ? option[props.labelKey] : option
}

function getSelectedValue(val) {
if (val && typeof val === "object") {
return val[props.valueKey];
if (val && typeof val === 'object') {
return val[props.valueKey]
}
return val ?? "";
return val ?? ''
}

const selected = ref(getSelectedValue(props.modelValue));

watch(
() => props.modelValue,
value => {
selected.value = getSelectedValue(value);
}
);
const selectedLabel = computed(() => {
const selectedValue = getSelectedValue(props.modelValue)
const found = props.options.find(option => String(getOptionValue(option)) === String(selectedValue))
return found ? getLabel(found) : ''
})

function emitSelection() {
const found = props.options.find(
option => String(getOptionValue(option)) === String(selected.value)
);
const toEmit = found
? typeof found === "object"
? found
: selected.value
: selected.value;
emit("update:modelValue", toEmit);
function selectOption(optionValue) {
const found = props.options.find(option => String(getOptionValue(option)) === String(optionValue))
const toEmit = found ? (typeof found === 'object' ? found : optionValue) : optionValue
emit('update:modelValue', toEmit)
}
</script>

<style scoped>
.single-select select {
padding: 0.5rem;
.single-select-options {
list-style: none;
margin: 0;
padding: 0;
}
.single-select-options li {
padding: 8px 12px;
cursor: pointer;
}
.single-select-options li.is-selected {
background-color: #f0f0f0;
}
</style>
13 changes: 13 additions & 0 deletions src/composables/useDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ref } from 'vue'

export function useDropdown() {
const isOpen = ref(false)

const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const closeDropdown = () => {
isOpen.value = false
}
return { isOpen, toggleDropdown, closeDropdown }
}
File renamed without changes.
12 changes: 6 additions & 6 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// directives
import clickOutside from "./composables/click-outside.js";
import clickOutside from './directives/click-outside.js'

import { createApp } from "vue";
import App from "./App.vue";
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App);
const app = createApp(App)

app.directive("click-outside", clickOutside);
app.directive('click-outside', clickOutside)

app.mount("#app");
app.mount('#app')
41 changes: 41 additions & 0 deletions tests/base-multiselect.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import BaseMultiselect from "../src/components/BaseMultiselect.vue";

const clickOutsideStub = {
beforeMount() {},
unmounted() {}
};

describe("BaseMultiselect wrapper behavior", () => {
it("renders placeholder when modelValue is empty", () => {
const wrapper = mount(BaseMultiselect, {
props: { options: ["A", "B"], modelValue: null, placeholder: "Select..." },
global: { directives: { "click-outside": clickOutsideStub } }
});
expect(wrapper.find(".selected-display").text()).toContain("Select...");
});

it("toggles dropdown visibility on click", async () => {
const wrapper = mount(BaseMultiselect, {
props: { options: ["A", "B"], modelValue: null },
global: { directives: { "click-outside": clickOutsideStub } }
});
expect(wrapper.find(".options-dropdown").exists()).toBe(false);
await wrapper.find(".selected-display").trigger("click");
expect(wrapper.find(".options-dropdown").exists()).toBe(true);
});

it("emits search on input", async () => {
const wrapper = mount(BaseMultiselect, {
props: { options: ["A", "B"], modelValue: null },
global: { directives: { "click-outside": clickOutsideStub } }
});
const input = wrapper.find(".search-input");
await input.setValue("B");
await input.trigger("input");
const events = wrapper.emitted("search");
expect(events).toBeTruthy();
expect(events[0][0]).toBe("B");
});
});
11 changes: 0 additions & 11 deletions tests/example.test.js

This file was deleted.

19 changes: 13 additions & 6 deletions tests/single-select-variant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Variant from "../src/components/variants/SingleSelectSimple.vue";

const clickOutsideStub = {
beforeMount() {},
unmounted() {}
};

describe("SingleSelect simple variant", () => {
it("renders two select elements with initial values", () => {
const wrapper = mount(Variant);
const selects = wrapper.findAll("select");
expect(selects.length).toBe(2);
expect(selects[0].element.value).toBe("Apple");
expect(selects[1].element.value).toBe("1");
it("renders two SingleSelects with initial labels", () => {
const wrapper = mount(Variant, {
global: { directives: { "click-outside": clickOutsideStub } }
});
const displays = wrapper.findAll(".selected-display");
expect(displays.length).toBe(2);
expect(displays[0].text()).toContain("Apple");
expect(displays[1].text()).toContain("One");
});
});
23 changes: 19 additions & 4 deletions tests/single-select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@ import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import SingleSelect from "../src/components/SingleSelect.vue";

const clickOutsideStub = {
beforeMount() {},
unmounted() {}
};

describe("SingleSelect Component", () => {
it("emits selected primitive value", async () => {
const wrapper = mount(SingleSelect, {
props: { options: ["a", "b", "c"] }
props: { options: ["a", "b", "c"], modelValue: "" },
global: { directives: { "click-outside": clickOutsideStub } }
});

await wrapper.find("select").setValue("b");
await wrapper.find(".selected-display").trigger("click");
const options = wrapper.findAll(".single-select-options li");
expect(options.length).toBe(3);
await options[1].trigger("click");

expect(wrapper.emitted()["update:modelValue"][0]).toEqual(["b"]);
});
Expand All @@ -18,9 +27,15 @@ describe("SingleSelect Component", () => {
{ label: "One", value: 1 },
{ label: "Two", value: 2 }
];
const wrapper = mount(SingleSelect, { props: { options } });
const wrapper = mount(SingleSelect, {
props: { options, modelValue: "" },
global: { directives: { "click-outside": clickOutsideStub } }
});

await wrapper.find("select").setValue("2");
await wrapper.find(".selected-display").trigger("click");
const items = wrapper.findAll(".single-select-options li");
expect(items.length).toBe(2);
await items[1].trigger("click");

expect(wrapper.emitted()["update:modelValue"][0]).toEqual([options[1]]);
});
Expand Down