Skip to content

Commit

Permalink
fix: minor fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
Rohan Bansal committed Jan 20, 2025
1 parent baf4f2a commit 93bd0e9
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 72 deletions.
124 changes: 67 additions & 57 deletions aform/src/components/form/ADropdown.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
<template>
<div class="autocomplete" :class="{ isOpen: isOpen }" v-on-click-outside="onClickOutside">
<div class="autocomplete" :class="{ isOpen: dropdown.open }" v-on-click-outside="onClickOutside">
<div class="input-wrapper">
<input
type="text"
@input="onChange"
@focus="onFocus"
v-model="search"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
@keydown.enter="onEnter"
@keydown.esc="onClickOutside" />

<ul id="autocomplete-results" v-show="isOpen" class="autocomplete-results">
<li class="loading autocomplete-result" v-if="isLoading">Loading results...</li>
type="text"
@input="filter"
@focus="openDropdown"
@keydown.down="selectNextResult"
@keydown.up="selectPrevResult"
@keydown.enter="setCurrentResult"
@keydown.esc="onClickOutside"
@keydown.tab="onClickOutside" />

<ul id="autocomplete-results" v-show="dropdown.open" class="autocomplete-results">
<li class="loading autocomplete-result" v-if="dropdown.loading">Loading results...</li>
<li
v-else
v-for="(result, i) in results"
:key="i"
v-for="(result, i) in dropdown.results"
:key="result"
@click.stop="setResult(result)"
class="autocomplete-result"
:class="{ 'is-active': i === activeItemIndex }">
:class="{ 'is-active': i === dropdown.activeItemIndex }">
{{ result }}
</li>
</ul>
Expand All @@ -29,89 +30,98 @@
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { vOnClickOutside } from '@vueuse/components'
import { reactive } from 'vue'
const { label, items, isAsync, filterFunction } = defineProps<{
label: string
items?: string[]
isAsync?: boolean
filterFunction?: (search: string) => Promise<string[]>
filterFunction?: (search: string) => string[] | Promise<string[]>
}>()
const results = ref(items)
const search = defineModel<string>()
const isLoading = ref(false)
const activeItemIndex = ref<number | null>(null)
const isOpen = ref(false)
const onClickOutside = () => {
closeResults()
activeItemIndex.value = null
}
const dropdown = reactive({
activeItemIndex: null as number | null,
open: false,
loading: false,
results: items,
})
const onClickOutside = () => closeDropdown()
const onChange = async () => {
isOpen.value = true
const filter = async () => {
dropdown.open = true
if (filterFunction) {
if (isAsync) isLoading.value = true
if (isAsync) dropdown.loading = true
try {
const filteredResults = await filterFunction(search.value || '')
results.value = filteredResults
dropdown.results = filteredResults
} catch {
results.value = []
dropdown.results = []
} finally {
if (isAsync) isLoading.value = false
if (isAsync) dropdown.loading = false
}
} else {
filterResults()
}
}
const onFocus = () => {
isOpen.value = true
if (isAsync) {
results.value = []
activeItemIndex.value = null
} else {
results.value = items
activeItemIndex.value = items?.indexOf(search.value || '') || null
}
}
const setResult = (result: string) => {
search.value = result
closeResults(result)
closeDropdown(result)
}
const openDropdown = () => {
dropdown.activeItemIndex = isAsync ? null : search.value ? items?.indexOf(search.value) || null : null
dropdown.open = true
// TODO: this should probably call the async function if it's async
dropdown.results = isAsync ? [] : items
}
const closeResults = (result?: string) => {
isOpen.value = false
const closeDropdown = (result?: string) => {
dropdown.activeItemIndex = null
dropdown.open = false
if (!items?.includes(result || search.value || '')) {
search.value = ''
}
}
const filterResults = () => {
if (!search.value) {
results.value = items
dropdown.results = items
} else {
results.value = items?.filter(item => item.toLowerCase().includes((search.value ?? '').toLowerCase()))
dropdown.results = items?.filter(item => item.toLowerCase().includes((search.value ?? '').toLowerCase()))
}
}
const onArrowDown = () => {
const resultsLength = results.value?.length || 0
activeItemIndex.value = ((activeItemIndex.value ?? 0) + 1) % resultsLength
const selectNextResult = () => {
const resultsLength = dropdown.results?.length || 0
if (dropdown.activeItemIndex != null) {
const currentIndex = isNaN(dropdown.activeItemIndex) ? 0 : dropdown.activeItemIndex
dropdown.activeItemIndex = (currentIndex + 1) % resultsLength
} else {
dropdown.activeItemIndex = 0
}
}
const onArrowUp = () => {
const resultsLength = results.value?.length || 0
activeItemIndex.value = ((activeItemIndex.value ?? 0) - 1 + resultsLength) % resultsLength
const selectPrevResult = () => {
const resultsLength = dropdown.results?.length || 0
if (dropdown.activeItemIndex != null) {
const currentIndex = isNaN(dropdown.activeItemIndex) ? 0 : dropdown.activeItemIndex
dropdown.activeItemIndex = (currentIndex - 1 + resultsLength) % resultsLength
} else {
dropdown.activeItemIndex = resultsLength - 1
}
}
const onEnter = () => {
if (results.value) {
search.value = results.value[activeItemIndex.value || 0]
closeResults(results.value[activeItemIndex.value || 0])
const setCurrentResult = () => {
if (dropdown.results) {
const currentIndex = dropdown.activeItemIndex || 0
const result = dropdown.results[currentIndex]
setResult(result)
}
activeItemIndex.value = 0
dropdown.activeItemIndex = 0
}
</script>

Expand Down
5 changes: 3 additions & 2 deletions aform/tests/dropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'

import ADropdown from '../src/components/form/ADropdown.vue'

Expand Down Expand Up @@ -56,6 +56,7 @@ describe('dropdown input component', () => {

// arrow down to select the second item
await input.trigger('keydown', { key: 'ArrowDown' })
await input.trigger('keydown', { key: 'ArrowDown' })
await input.trigger('keydown', { key: 'Enter' })

updateEvents = wrapper.emitted('update:modelValue')
Expand Down Expand Up @@ -99,7 +100,7 @@ describe('dropdown input component', () => {
})

it('emits filter change event when dropdown item is selected using mouse in async', async () => {
const mockFilterFunction = vi.fn(async search => {
const mockFilterFunction = vi.fn(search => {
if (search === 'a') {
return ['Apple', 'Orange', 'Pear']
}
Expand Down
29 changes: 16 additions & 13 deletions examples/aform/dropdown.story.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
<template>
<Story>
<div class="dropdown-form">
<!-- normal dropdown story -->
<ADropdown
data-theme="purple"
:items="dropdown_data.items"
v-model="dropdown_data.value"
:label="dropdown_data.label" />

<!-- dropdown with API request simulation -->
<ADropdown
:items="async_dropdown_data.items"
v-model="async_dropdown_data.value"
:label="async_dropdown_data.label"
:isAsync="true"
:filterFunction="asyncFilterItems" />

<!-- dropdown with custom filtering logic -->
<ADropdown
:items="custom_filter_dropdown_data.items"
v-model="custom_filter_dropdown_data.value"
Expand All @@ -22,10 +27,10 @@
</Story>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'
<script setup lang="ts">
import { reactive } from 'vue'
const dropdown_data = ref({
const dropdown_data = reactive({
items: ['Apple', 'Orange', 'Pear', 'Kiwi', 'Grape'],
value: 'Orange',
label: 'Fruit',
Expand All @@ -45,24 +50,25 @@ const custom_filter_dropdown_data = reactive({
label: 'Food',
})
function delay(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 750))
}
async function asyncFilterItems(search: string): Promise<string[]> {
await delay()
async function asyncFilterItems(search: string) {
// introduce a delay to simulate an async request
await delay(750)
const filtered = async_dropdown_data.allItems.filter(item => item.toLowerCase().includes(search.toLowerCase()))
async_dropdown_data.items = filtered
return filtered
}
async function filterItems(search: string): Promise<string[]> {
function filterItems(search: string) {
const filtered = custom_filter_dropdown_data.allItems.filter(item =>
item.toLowerCase().startsWith(search.toLowerCase())
)
custom_filter_dropdown_data.items = filtered
return filtered
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
</script>

<style>
Expand All @@ -77,6 +83,3 @@ async function filterItems(search: string): Promise<string[]> {
padding-right: 1ch;
}
</style>

<!-- enter documentation here -->
<docs lang="md"></docs>

0 comments on commit 93bd0e9

Please sign in to comment.