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
4 changes: 1 addition & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
<template>
<main>
Vue-Tailwind
</main>
<main>Vue-Tailwind</main>
</template>
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>
147 changes: 147 additions & 0 deletions src/components/DxhAutocomplete.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<template>
<div class="autocomplete">
<label v-if="label" class="block" data-test="autocomplete-label">{{ label }}</label>
<div ref="dropdown">
<div class="relative">
<input
v-model="inputValue"
@input="handleInputChange"
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter="handleEnter"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:autofocus="autofocus"
class="w-full p-2 border border-gray-300"
data-test="autocomplete-input"
/>

<div
v-if="clearable && inputValue"
class="absolute right-2 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>

<div
v-if="isDropdownOpen"
class="p-2 border border-gray-300 shadow-md bg-white"
data-test="dropdown"
>
<slot name="loading" v-if="loading">
<div class="flex w-full justify-center items-center">
<Spinner />
</div>
</slot>
<div v-if="options.length === 0 && !loading" class="text-center">{{ notFoundContent }}</div>
<div v-if="options.length > 0 && !loading">
<div v-for="option in options" :key="getOptionKey(option)" class="cursor-pointer">
<button class="block" @click="selectOption(option)" data-test="dropdown-option">
{{ getOptionLabel(option) }}
</button>
</div>
</div>
</div>
</div>
<p v-if="hint" class="text-sm text-gray-500 mt-1">{{ 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
}

interface Props {
label?: string
loading?: boolean
value?: string
options: Option[]
placeholder?: string
notFoundContent?: string
clearable?: boolean
hint?: string
disabled?: boolean
readonly?: boolean
autofocus?: boolean
}

const props = withDefaults(defineProps<Props>(), {
notFoundContent: 'No data found'
})

const emit = defineEmits(['change', 'focus', 'blur', 'enter', 'selected'])

const dropdown = ref(null)
const isDropdownOpen = ref(false)
const inputValue = ref<string>(props.value || '')

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

const handleInputChange = () => {
emit('change', inputValue.value)
isDropdownOpen.value = true
}

const handleFocus = () => {
emit('focus')
}

const handleBlur = () => {
emit('blur')
}

const handleEnter = () => {
emit('enter')
}

const clearInput = () => {
inputValue.value = ''
}

const selectOption = (option: Option) => {
inputValue.value = option.label
emit('selected', option)
isDropdownOpen.value = false
}

const getOptionKey = (option: Option) => {
return typeof option === 'object' ? option.id : option
}

const getOptionLabel = (option: Option) => {
return typeof option === 'object' ? option.label : option
}

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

onUnmounted(() => {
document.removeEventListener('click', isClickOutside)
})
</script>
36 changes: 36 additions & 0 deletions src/components/__tests__/DxhAutocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DxhAutocomplete from '../DxhAutocomplete.vue'

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

beforeEach(() => {
wrapper = mount(DxhAutocomplete, {
props: {
label: 'Your Label',
options: [
{ id: 1, label: 'Option 1' },
{ id: 2, label: 'Option 2' },
{ id: 3, label: 'Option 3' }
]
}
})
})

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

it('renders with correct initial state', () => {
expect(wrapper.find('[data-test="autocomplete-label"]').exists()).toBe(true)
expect(wrapper.find('[data-test="autocomplete-label"]').text()).toBe('Your Label')
})

it('opens dropdown, selects an option, and closes dropdown', async () => {
await wrapper.find('[data-test="autocomplete-input"]').trigger('focus')
expect(wrapper.vm.isDropdownOpen).toBe(false)
expect(wrapper.vm.isDropdownOpen).toBe(false)
expect(wrapper.emitted('selected')).toBeFalsy()
})
})
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 DxhAutocomplete from './components/DxhAutocomplete.vue'

export default {DButton, DInput}
export default { DButton, DInput, DxhAutocomplete }