Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[aform] add adropdown features #236

Merged
merged 10 commits into from
Jan 20, 2025
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
1 change: 1 addition & 0 deletions aform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@stonecrop/themes": "workspace:*",
"@stonecrop/utilities": "workspace:*",
"@vueuse/core": "^12.0.0",
"@vueuse/components": "^12.0.0",
"vue": "^3.5.11"
},
"devDependencies": {
Expand Down
163 changes: 85 additions & 78 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 }">
<div class="autocomplete" :class="{ isOpen: dropdown.open }" v-on-click-outside="onClickOutside">
<div class="input-wrapper">
<input
ref="mopInput"
type="text"
@input="onChange"
@focus="onChange"
v-model="search"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
@keydown.enter="onEnter" />

<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"
@click="setResult(result)"
v-for="(result, i) in dropdown.results"
:key="result"
@click.stop="setResult(result)"
class="autocomplete-result"
:class="{ 'is-active': i === arrowCounter }">
:class="{ 'is-active': i === dropdown.activeItemIndex }">
{{ result }}
</li>
</ul>
Expand All @@ -29,94 +30,99 @@
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref /* useTemplateRef */ } from 'vue'
import { vOnClickOutside } from '@vueuse/components'
import { reactive } from 'vue'

const { label, items, isAsync } = defineProps<{
const { label, items, isAsync, filterFunction } = defineProps<{
label: string
items?: string[]
isAsync?: boolean
filterFunction?: (search: string) => string[] | Promise<string[]>
}>()

const emit = defineEmits(['filterChanged'])

const results = ref(items)
const search = defineModel<string>()
const isLoading = ref(false)
const arrowCounter = ref(0)
const isOpen = ref(false)
// const mopInput = useTemplateRef<HTMLInputElement>('mopInput')

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

onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
const dropdown = reactive({
activeItemIndex: null as number | null,
open: false,
loading: false,
results: items,
})

const setResult = result => {
search.value = result
closeResults()
}

const filterResults = () => {
if (!search.value) {
results.value = items
} else {
results.value = items.filter(item => {
return item.toLowerCase().indexOf(search.value.toLowerCase()) > -1
})
}
}

const onChange = () => {
isOpen.value = true
if (isAsync) {
isLoading.value = true
emit('filterChanged', search.value)
const onClickOutside = () => closeDropdown()

const filter = async () => {
dropdown.open = true
if (filterFunction) {
if (isAsync) dropdown.loading = true
try {
const filteredResults = await filterFunction(search.value || '')
dropdown.results = filteredResults
} catch {
dropdown.results = []
} finally {
if (isAsync) dropdown.loading = false
}
} else {
filterResults()
}
}

const handleClickOutside = () => {
closeResults()
arrowCounter.value = 0
const setResult = (result: string) => {
search.value = result
closeDropdown(result)
}

const closeResults = () => {
isOpen.value = false
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
}

// TODO: (test) when would this occur? how should this be tested?
if (!items.includes(search.value)) {
const closeDropdown = (result?: string) => {
dropdown.activeItemIndex = null
dropdown.open = false
if (!items?.includes(result || search.value || '')) {
search.value = ''
}
}

const onArrowDown = () => {
if (arrowCounter.value < results.value.length) {
arrowCounter.value = arrowCounter.value + 1
const filterResults = () => {
if (!search.value) {
dropdown.results = items
} else {
dropdown.results = items?.filter(item => item.toLowerCase().includes((search.value ?? '').toLowerCase()))
}
}

const onArrowUp = () => {
if (arrowCounter.value > 0) {
arrowCounter.value = arrowCounter.value - 1
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 onEnter = () => {
search.value = results.value[arrowCounter.value]
closeResults()
arrowCounter.value = 0
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 openWithSearch = () => {
// search.value = ''
// onChange()
// mopInput.value.focus()
// }
const setCurrentResult = () => {
if (dropdown.results) {
const currentIndex = dropdown.activeItemIndex || 0
const result = dropdown.results[currentIndex]
setResult(result)
}
dropdown.activeItemIndex = 0
}
</script>

<style scoped>
Expand All @@ -126,7 +132,6 @@ const onEnter = () => {
}

.input-wrapper {
min-width: 40ch;
border: 1px solid transparent;
padding: 0rem;
margin: 0rem;
Expand Down Expand Up @@ -167,25 +172,27 @@ label {
.autocomplete-results {
position: absolute;
width: calc(100% - 1ch + 1.5px);
z-index: 1;
z-index: 999;
padding: 0;
margin: 0;
color: #000000;
color: var(--sc-input-active-border-color);
border: 1px solid var(--sc-input-active-border-color);
border-radius: 0 0 0.25rem 0.25rem;
border-top: none;
background-color: #fff;
}

.autocomplete-result {
list-style: none;
text-align: left;
padding: 4px 6px;
cursor: pointer;
border-bottom: 0.5px solid lightgray;
}

.autocomplete-result.is-active,
.autocomplete-result:hover {
background-color: var(--sc-row-color-zebra-light);
color: #000000;
color: var(--sc-input-active-border-color);
}
</style>
45 changes: 37 additions & 8 deletions aform/tests/dropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } 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 All @@ -78,9 +79,9 @@ describe('dropdown input component', () => {
expect(updateEvents![3]).toEqual(['Apple'])
})

it('emits filter change event when dropdown item is selected using mouse in async', async () => {
it('emits filter change event when dropdown item is selected using mouse in sync', async () => {
const wrapper = mount(ADropdown, {
props: { modelValue: dropdownData.value, label: dropdownData.label, items: dropdownData.items, isAsync: true },
props: { modelValue: dropdownData.value, label: dropdownData.label, items: dropdownData.items, isAsync: false },
})

await wrapper.find('input').setValue('')
Expand All @@ -94,11 +95,39 @@ describe('dropdown input component', () => {
await wrapper.vm.$nextTick()

valueUpdateEvents = wrapper.emitted('update:modelValue')
expect(valueUpdateEvents).toHaveLength(1)
expect(valueUpdateEvents![0]).toEqual([''])
expect(valueUpdateEvents).toHaveLength(2)
expect(valueUpdateEvents![1]).toEqual(['Apple'])
})

it('emits filter change event when dropdown item is selected using mouse in async', async () => {
const mockFilterFunction = vi.fn(search => {
if (search === 'a') {
return ['Apple', 'Orange', 'Pear']
}
return []
})

const filterChangedEvents = wrapper.emitted('filterChanged')
expect(filterChangedEvents).toHaveLength(1)
expect(filterChangedEvents![0]).toEqual([''])
const wrapper = mount(ADropdown, {
props: {
modelValue: dropdownData.value,
label: dropdownData.label,
items: dropdownData.items,
isAsync: true,
filterFunction: mockFilterFunction,
},
})

const input = wrapper.find('input')
await input.setValue('a')
await wrapper.vm.$nextTick()

expect(mockFilterFunction).toHaveBeenCalledWith('a')
expect(mockFilterFunction).toHaveBeenCalledTimes(1)

const liElements = wrapper.findAll('li')
expect(liElements).toHaveLength(3)
expect(liElements.at(0)?.text()).toBe('Apple')
expect(liElements.at(1)?.text()).toBe('Orange')
expect(liElements.at(2)?.text()).toBe('Pear')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@stonecrop/aform",
"comment": "allow external clicks, fix on result selection and clear search when necesary\"",
"type": "none"
}
],
"packageName": "@stonecrop/aform"
}
Loading
Loading