Skip to content
Open
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
46 changes: 43 additions & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
<template>
<main>
Vue-Tailwind
</main>
<div>
<Autocomplete
v-model="selectedOptions"
:label="label"
:options="options"
:multiple="multiple"
:loading="loading"
:chips="chips"
:clearable="clearable"
:placeholder="placeholder"
:hint="hint"
@select="handleSelect"
>
<template #clear="{ onClick }">
<div @click="onClick">
cl
</div>
</template>
</Autocomplete>
</div>
</template>

<script setup lang="ts">
import Autocomplete from './components/DxhSelect.vue'
import { ref } from 'vue'

const selectedOptions = ref([])
const label = 'Select Options'
const options = [
{ id: 1, label: 'Option 1' },
{ id: 2, label: 'Option 2' },
{ id: 3, label: 'Option 3' }
]
const multiple = true
const loading = false
const chips = true
const clearable = true
const placeholder = 'Select an option'
const hint = 'This is a hint.'

const handleSelect = (value) => {
console.log('Selected Options:', value)
}
</script>
58 changes: 58 additions & 0 deletions src/assets/icons/LoadingSpinner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g class="spinner_Wezc">
<circle cx="12" cy="2.5" r="1.5" opacity=".14" />
<circle cx="16.75" cy="3.77" r="1.5" opacity=".29" />
<circle cx="20.23" cy="7.25" r="1.5" opacity=".43" />
<circle cx="21.50" cy="12.00" r="1.5" opacity=".57" />
<circle cx="20.23" cy="16.75" r="1.5" opacity=".71" />
<circle cx="16.75" cy="20.23" r="1.5" opacity=".86" />
<circle cx="12" cy="21.5" r="1.5" />
</g>
</svg>
</template>

<style scoped>
.spinner_Wezc {
transform-origin: center;
animation: spinner_Oiah 0.75s step-end infinite;
}
@keyframes spinner_Oiah {
8.3% {
transform: rotate(30deg);
}
16.6% {
transform: rotate(60deg);
}
25% {
transform: rotate(90deg);
}
33.3% {
transform: rotate(120deg);
}
41.6% {
transform: rotate(150deg);
}
50% {
transform: rotate(180deg);
}
58.3% {
transform: rotate(210deg);
}
66.6% {
transform: rotate(240deg);
}
75% {
transform: rotate(270deg);
}
83.3% {
transform: rotate(300deg);
}
91.6% {
transform: rotate(330deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
207 changes: 207 additions & 0 deletions src/components/DxhSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<template>
<div class="autocomplete">
<label v-if="label" class="block" data-test="label">{{ label }}</label>
<div ref="dropdown" class="relative inline-block w-full">
<div
@click="toggleDropdown"
class="cursor-pointer border p-2 flex items-center justify-between bg-white"
data-test="dropdown-toggle"
>
<div
v-if="clearable && selectedOptions[0]"
class="absolute right-9 top-0 bottom-0 cursor-pointer flex items-center h-full"
data-test="clear-icon"
>
<slot name="clear" :onClick="clearInput">
<svg
class="inline"
xmlns="http://www.w3.org/2000/svg"
height="14px"
viewBox="0 0 512 512"
@click="clearInput"
>
<path
d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c-9.4 9.4-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0z"
/>
</svg>
</slot>
</div>
<div class="flex items-center space-x-2">
<div v-if="!selectedOptions.length" class="text-gray-500" data-test="placeholder">
{{ placeholder }}
</div>
<div
class="flex space-x-2"
:selected-options="selectedOptions"
:remove-option="removeOption"
>
<div
v-for="option in selectedOptions"
:key="option.id"
:class="chips ? 'bg-gray-200 p-1' : ''"
>
{{ option.label }}
<span v-if="chips" @click.stop="removeOption(option)" class="ml-1 cursor-pointer"
>&times;</span
>
</div>
</div>
</div>
<slot name="dropdown" :isOpen="isDropdownOpen">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
:class="{ 'rotate-180': isDropdownOpen }"
class="w-4 h-4 transition-transform transform"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</slot>
</div>

<transition name="fade">
<div
v-show="isDropdownOpen"
class="absolute w-full bg-white border border-gray-300 shadow-md overflow-hidden min-w-[300px]"
>
<slot name="loading" v-if="loading">
<div class="flex w-full justify-center items-center h-12" data-test="loading">
<Spinner />
</div>
</slot>
<div v-else>
<div
v-for="option in options"
:key="option.id"
@click="toggleOption(option)"
class="p-2 hover:bg-gray-100 cursor-pointer"
:data-test="'dropdown-option-' + option.id"
>
<input
v-if="multiple"
type="checkbox"
:checked="isSelected(option)"
@click.stop="toggleOption(option)"
/>
{{ option.label }}
</div>
</div>
</div>
</transition>
</div>
<p v-if="hint" class="text-sm text-gray-500 mt-1" data-test="hint">{{ hint }}</p>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, defineProps, defineEmits } from 'vue'
import Spinner from '../assets/icons/LoadingSpinner.vue'

interface Option {
id: string | number
label: string
}

const props = defineProps<{
modelValue: any
label?: string
options: any
multiple?: boolean
loading?: boolean
chips?: boolean
clearable?: boolean
placeholder?: string
hint?: string
}>()

const emit = defineEmits(['update:modelValue', 'select'])

const selectedOptions: any = ref([])
const isDropdownOpen = ref(false)
const dropdown = ref(null)

const isClickOutside = (event: MouseEvent) => {
const dropdownElement: any = dropdown.value
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
isDropdownOpen.value = false
}
}

const clearInput = () => {
emit('update:modelValue', (selectedOptions.value = []))
}

const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value
}

const toggleOption = (option: any) => {
if (isSelected(option)) {
removeOption(option)
} else {
addOption(option)
if (!props.multiple) {
isDropdownOpen.value = false
}
}
}

const addOption = (option: Option) => {
if (props.multiple) {
selectedOptions.value = [...selectedOptions.value, option]
emit('select', selectedOptions.value)
} else {
selectedOptions.value = [option]
isDropdownOpen.value = false
emit('select', selectedOptions.value)
}

emit('update:modelValue', getSelectedValues())
}

const removeOption = (option: any) => {
selectedOptions.value = selectedOptions.value.filter((o: { id: number }) => o.id !== option.id)
emit('update:modelValue', getSelectedValues())
}

const isSelected = (option: any): boolean => {
return selectedOptions.value.some((o: { id: string | number }) => o.id === option.id)
}

const getSelectedValues = (): any => {
return props.multiple
? selectedOptions.value.map((o: { id: any }) => o.id)
: selectedOptions.value[0]?.id || null
}

onMounted(() => {
document.addEventListener('click', isClickOutside)
})

onUnmounted(() => {
document.removeEventListener('click', isClickOutside)
})
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}

.fade-enter,
.fade-leave-to {
opacity: 0;
}

.rotate-180 {
transform: rotate(180deg);
}
</style>
51 changes: 51 additions & 0 deletions src/components/__tests__/DxhSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DxhSelect from '../DxhSelect.vue'

describe('DxhSelect.vue', () => {
let wrapper:any

beforeEach(() => {
wrapper = mount(DxhSelect, {
props: {
modelValue: [],
label: 'Your Label',
options: [
{ id: 1, label: 'Option 1' },
{ id: 2, label: 'Option 2' },
{ id: 3, label: 'Option 3' }
],
multiple: true,
chips: true,
clearable: true,
placeholder: 'Select an option',
hint: 'Your Hint'
}
})
})

afterEach(() => {
wrapper.unmount()
})

it('renders with correct initial state', () => {
expect(wrapper.find('label').exists()).toBe(true)
expect(wrapper.find('label').text()).toBe('Your Label')
expect(wrapper.find('.text-gray-500').text()).toBe('Select an option')
expect(wrapper.find('p').text()).toBe('Your Hint')
expect(wrapper.find('[data-test="clear-icon"]').exists()).toBe(false)
expect(wrapper.vm.isDropdownOpen).toBe(false)
})

it('opens dropdown, selects an option, and closes dropdown', async () => {
await wrapper.find('[data-test="dropdown-toggle"]').trigger('click')
expect(wrapper.vm.isDropdownOpen).toBe(true)

await wrapper.find('[data-test="dropdown-option-1"]').trigger('click')
expect(wrapper.vm.selectedOptions).toEqual([{ id: 1, label: 'Option 1' }])
expect(wrapper.vm.isDropdownOpen).toBe(true)

expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0][0]).toEqual([1])
})
})
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import DButton from "./components/DButton.vue"
import DInput from "./components/DInput.vue"
import DButton from './components/DButton.vue'
import DInput from './components/DInput.vue'
import DSelect from './components/DSelect.vue'

export default {DButton, DInput}
export default { DButton, DInput, DSelect }