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 6 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
102 changes: 49 additions & 53 deletions aform/src/components/form/ADropdown.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<template>
<div class="autocomplete" :class="{ isOpen: isOpen }">
<div ref="autocomplete" class="autocomplete" :class="{ isOpen: isOpen }" v-on-click-outside="closeResultsHandler">
<div class="input-wrapper">
<input
ref="mopInput"
type="text"
@input="onChange"
@focus="onChange"
@focus="onFocus"
v-model="search"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
Expand All @@ -17,7 +16,7 @@
v-else
v-for="(result, i) in results"
:key="i"
@click="setResult(result)"
@click.stop="setResult(result)"
class="autocomplete-result"
:class="{ 'is-active': i === arrowCounter }">
{{ result }}
Expand All @@ -29,86 +28,89 @@
</template>

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

const { label, items, isAsync } = defineProps<{
import { ref, useTemplateRef } from 'vue'
import { vOnClickOutside } from '@vueuse/components'
const { label, items, isAsync, filterFunction } = defineProps<{
label: string
items?: string[]
isAsync?: boolean
filterFunction?: (search: string) => Promise<string[]>
}>()

const emit = defineEmits(['filterChanged'])

const autocomplete = useTemplateRef<HTMLElement>('autocomplete')
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 setResult = result => {
search.value = result
const closeResultsHandler = () => {
closeResults()
arrowCounter.value = 0
}

const filterResults = () => {
if (!search.value) {
results.value = items
const onChange = async () => {
isOpen.value = true
if (isAsync && filterFunction) {
isLoading.value = true
try {
const filteredResults = await filterFunction(search.value)
results.value = filteredResults
} catch {
results.value = []
} finally {
isLoading.value = false
}
} else {
results.value = items.filter(item => {
return item.toLowerCase().indexOf(search.value.toLowerCase()) > -1
})
filterResults()
}
}

const onChange = () => {
const onFocus = () => {
isOpen.value = true
if (isAsync) {
isLoading.value = true
emit('filterChanged', search.value)
results.value = []
arrowCounter.value = 0
} else {
filterResults()
results.value = items
arrowCounter.value = items.indexOf(search.value)
}
}

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

const closeResults = () => {
const closeResults = (result?: string) => {
isOpen.value = false

// TODO: (test) when would this occur? how should this be tested?
if (!items.includes(search.value)) {
if (!items.includes(result || search.value)) {
search.value = ''
}
}

const filterResults = () => {
if (!search.value) {
results.value = items
} else {
results.value = items.filter(item => item.toLowerCase().includes(search.value.toLowerCase()))
}
}

const onArrowDown = () => {
if (arrowCounter.value < results.value.length) {
arrowCounter.value = arrowCounter.value + 1
arrowCounter.value += 1
}
}

const onArrowUp = () => {
if (arrowCounter.value > 0) {
arrowCounter.value = arrowCounter.value - 1
arrowCounter.value -= 1
}
}

const onEnter = () => {
search.value = results.value[arrowCounter.value]
closeResults()
closeResults(results.value[arrowCounter.value])
arrowCounter.value = 0
}

Expand All @@ -124,15 +126,12 @@ const onEnter = () => {
.autocomplete {
position: relative;
}

.input-wrapper {
min-width: 40ch;
border: 1px solid transparent;
padding: 0rem;
margin: 0rem;
margin-right: 1ch;
}

input {
width: calc(100% - 1ch);
outline: 1px solid transparent;
Expand All @@ -142,13 +141,11 @@ input {
min-height: 1.15rem;
border-radius: 0.25rem;
}

input:focus {
border: 1px solid var(--sc-input-active-border-color);
border-radius: 0.25rem 0.25rem 0 0;
border-bottom: none;
}

label {
display: block;
min-height: 1.15rem;
Expand All @@ -163,29 +160,28 @@ label {
margin: calc(-1.5rem - calc(2.15rem / 2)) 0 0 1ch;
padding: 0 0.25ch 0 0.25ch;
}

.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>
44 changes: 36 additions & 8 deletions aform/tests/dropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'

import ADropdown from '../src/components/form/ADropdown.vue'
Expand Down Expand Up @@ -78,9 +78,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 +94,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'])
})

const filterChangedEvents = wrapper.emitted('filterChanged')
expect(filterChangedEvents).toHaveLength(1)
expect(filterChangedEvents![0]).toEqual([''])
it('emits filter change event when dropdown item is selected using mouse in async', async () => {
const mockFilterFunction = vi.fn(async search => {
if (search === 'a') {
return ['Apple', 'Orange', 'Pear']
}
return []
})

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"
}
40 changes: 40 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading