feat: add CAutoComplete component with basic functionality and remote search support

- Implemented CAutoComplete.vue with props for modelValue, options, debounceWait, loading, and more.
- Added search functionality with debounce for remote city suggestions.
- Created AutoComplete.vue page for documentation and examples.
- Updated CSelect.vue to support remote searching and caching of selected values.
- Enhanced CMultiSelect.vue to handle object comparisons for selected values.
main
fandi.susanto.bts 1 month ago
parent 3a8489d818
commit 18eae8fa23

@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import CButton from '../button/CButton.vue'
import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl'
import CIcon from '../icon/CIcon.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: string
options?: string[]
placeholder?: string
clearable?: boolean
debounceWait?: number
loading?: boolean
minSearchLength?: number
size?: CFormControlSize
disabled?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: '',
options: () => [],
placeholder: undefined,
clearable: false,
debounceWait: 300,
loading: false,
minSearchLength: 0,
size: 'medium',
disabled: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
search: [query: string]
select: [value: string]
clear: []
}>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
const listboxId = `c-auto-complete-listbox-${useId()}`
const inputElement = ref<HTMLInputElement | null>(null)
const listElement = ref<HTMLUListElement | null>(null)
const isOpen = ref(false)
const highlightedIndex = ref(-1)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let lastSearchQuery: string | undefined
const filteredOptions = computed(() => {
const search = props.modelValue.trim().toLocaleLowerCase()
return search
? props.options.filter((option) => option.toLocaleLowerCase().includes(search))
: props.options
})
const searchTooShort = computed(
() => props.modelValue.trim().length < Math.max(0, props.minSearchLength),
)
const activeDescendant = computed(() =>
!props.loading && !searchTooShort.value && highlightedIndex.value >= 0
? `${listboxId}-option-${highlightedIndex.value}`
: undefined,
)
watch(filteredOptions, () => {
if (!isOpen.value || props.loading || searchTooShort.value) return
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
})
watch(
() => props.loading,
(loading) => {
if (loading) highlightedIndex.value = -1
else if (isOpen.value && !searchTooShort.value) {
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
}
},
)
watch(highlightedIndex, (index) => {
if (index < 0) return
nextTick(() => {
const option = listElement.value?.children[index]
if (option instanceof HTMLElement) option.scrollIntoView({ block: 'nearest' })
})
})
function openList() {
if (props.disabled) return
isOpen.value = true
highlightedIndex.value = -1
}
function emitSearch(query: string) {
lastSearchQuery = query
emit('search', query)
}
function cancelScheduledSearch() {
if (searchTimer !== undefined) clearTimeout(searchTimer)
searchTimer = undefined
}
function scheduleSearch(value: string) {
cancelScheduledSearch()
const query = value.trim()
if (query.length < Math.max(0, props.minSearchLength)) {
if (lastSearchQuery !== '') emitSearch('')
return
}
if (!query) {
emitSearch('')
return
}
const wait = Math.max(0, props.debounceWait)
if (!wait) emitSearch(query)
else {
searchTimer = setTimeout(() => {
searchTimer = undefined
emitSearch(query)
}, wait)
}
}
function closeList() {
isOpen.value = false
highlightedIndex.value = -1
}
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
scheduleSearch(value)
isOpen.value = true
}
function chooseOption(value: string) {
cancelScheduledSearch()
emit('update:modelValue', value)
emit('select', value)
closeList()
inputElement.value?.focus()
}
function moveHighlight(direction: 1 | -1) {
if (!isOpen.value) openList()
if (props.loading || searchTooShort.value) return
const count = filteredOptions.value.length
if (!count) return
highlightedIndex.value =
highlightedIndex.value < 0
? direction === 1
? 0
: count - 1
: (highlightedIndex.value + direction + count) % count
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter' && isOpen.value && highlightedIndex.value >= 0) {
event.preventDefault()
const option = filteredOptions.value[highlightedIndex.value]
if (option !== undefined) chooseOption(option)
} else if (event.key === 'Escape' && isOpen.value) {
event.preventDefault()
closeList()
} else if (event.key === 'Tab') {
closeList()
}
}
function handleFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (!(next instanceof Node) || !(event.currentTarget as HTMLElement).contains(next)) closeList()
}
function clearValue() {
emit('update:modelValue', '')
scheduleSearch('')
closeList()
emit('clear')
}
onBeforeUnmount(() => {
cancelScheduledSearch()
})
</script>
<template>
<div
class="c-auto-complete-control"
:class="{ 'is-clearable': clearable }"
@focusout="handleFocusOut"
>
<div class="combobox">
<input
ref="inputElement"
v-bind="$attrs"
class="field"
:class="[`is-${size}`, { 'is-invalid': invalid }]"
:id="controlId"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-busy="loading ? 'true' : undefined"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendant"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@focus="openList"
@click="openList"
@input="handleInput"
@keydown="handleKeydown"
/>
<span class="indicator" aria-hidden="true">
<CIcon v-if="loading" :rotate="1"></CIcon>
<span v-else></span>
</span>
</div>
<CButton
v-if="clearable"
class="clear"
icon="×"
:size="size"
:disabled="disabled || !modelValue"
:aria-controls="controlId"
aria-label="Clear value"
@click="clearValue"
/>
<ul
v-show="isOpen"
ref="listElement"
:id="listboxId"
class="options"
role="listbox"
:aria-busy="loading ? 'true' : undefined"
>
<li v-if="loading" class="empty" role="presentation">Loading</li>
<li v-else-if="searchTooShort" class="empty" role="presentation">
Type at least {{ Math.max(0, minSearchLength) }}
{{ Math.max(0, minSearchLength) === 1 ? 'character' : 'characters' }}
</li>
<template v-else>
<li
v-for="(option, index) in filteredOptions"
:id="`${listboxId}-option-${index}`"
:key="`${option}:${index}`"
class="option"
:class="{
'is-highlighted': index === highlightedIndex,
'is-selected': option === modelValue,
}"
role="option"
:aria-selected="option === modelValue ? 'true' : 'false'"
@mousemove="highlightedIndex = index"
@mousedown.prevent
@click="chooseOption(option)"
>
{{ option }}
</li>
<li v-if="!filteredOptions.length" class="empty" role="presentation">
No matching suggestions
</li>
</template>
</ul>
</div>
</template>
<style scoped lang="scss">
.c-auto-complete-control {
position: relative;
display: flex;
width: 100%;
min-width: 0;
&.is-clearable {
.field {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.clear {
flex: none;
margin-inline-start: -1px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
}
.combobox {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
}
.indicator {
position: absolute;
inset-inline-end: 9px;
top: 50%;
display: inline-flex;
color: var(--c-muted-text-color, #68717d);
font-size: 10px;
line-height: 1;
pointer-events: none;
transform: translateY(-50%);
}
.field {
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
height: 30px;
min-width: 0;
padding: 4px 28px 4px 7px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.2;
background-color: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
&:hover:not(:disabled) {
border-color: var(--c-control-hover-border-color, #929aa6);
}
&:focus {
position: relative;
z-index: 1;
border-color: var(--c-focus-color, #3578c6);
outline: 1px solid var(--c-focus-color, #3578c6);
}
&:disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
background-color: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
&.is-invalid {
border-color: var(--c-danger-color, #b42318);
&:focus {
outline-color: var(--c-danger-color, #b42318);
}
}
&.is-small {
height: 26px;
padding-block: 3px;
font-size: 12px;
}
&.is-large {
height: 34px;
padding-block: 5px;
font-size: 14px;
}
}
.options {
position: absolute;
z-index: 20;
inset-inline: 0;
top: calc(100% + 2px);
max-height: 210px;
padding: 2px;
margin: 0;
overflow-y: auto;
color: var(--c-text-color, #20242a);
list-style: none;
background: var(--c-surface-color, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
box-shadow: 0 2px 6px rgb(0 0 0 / 14%);
}
.option,
.empty {
min-height: 26px;
padding: 4px 7px;
line-height: 18px;
}
.option {
cursor: default;
&.is-highlighted {
background: var(--c-hover-color, #eef1f5);
}
&.is-selected {
color: var(--c-primary-text-color, #fff);
background: var(--c-primary-color, #286aa6);
}
}
.empty {
color: var(--c-muted-text-color, #68717d);
}
</style>

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { computed, nextTick, ref, toRaw, useId, watch } from 'vue'
import CButton from '../button/CButton.vue'
import type { CFormControlSize } from '../form/types'
@ -96,6 +96,15 @@ function objectKey(value: object) {
function valuesMatch(left: CSelectValue, right: CSelectValue) {
if (Object.is(left, right)) return true
if (
typeof left === 'object' &&
left !== null &&
typeof right === 'object' &&
right !== null &&
Object.is(toRaw(left), toRaw(right))
) {
return true
}
if (
props.optionValue ||
!props.optionKey ||

@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, toRaw, useId, watch } from 'vue'
import CButton from '../button/CButton.vue'
import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl'
import CIcon from '../icon/CIcon.vue'
import type {
CSelectKeyAccessor,
CSelectLabelAccessor,
@ -24,6 +25,9 @@ const props = withDefaults(
placeholder?: string
clearable?: boolean
filterable?: boolean
debounceWait?: number
loading?: boolean
minSearchLength?: number
size?: CFormControlSize
disabled?: boolean
required?: boolean
@ -38,6 +42,9 @@ const props = withDefaults(
placeholder: undefined,
clearable: false,
filterable: false,
debounceWait: 300,
loading: false,
minSearchLength: 0,
size: 'medium',
disabled: false,
required: false,
@ -47,6 +54,7 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: CSelectValue | null]
search: [query: string]
clear: []
}>()
@ -59,6 +67,9 @@ const isOpen = ref(false)
const isFiltering = ref(false)
const query = ref('')
const highlightedKey = ref<string | number | null>(null)
const cachedSelection = ref<{ value: CSelectValue; label: string } | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let lastSearchQuery: string | undefined
let nextObjectKey = 0
const selection = computed({
@ -100,6 +111,15 @@ function objectKey(value: object) {
function valuesMatch(left: CSelectValue | null, right: CSelectValue | null) {
if (Object.is(left, right)) return true
if (
typeof left === 'object' &&
left !== null &&
typeof right === 'object' &&
right !== null &&
Object.is(toRaw(left), toRaw(right))
) {
return true
}
if (
props.optionValue ||
!props.optionKey ||
@ -166,7 +186,19 @@ const visibleOptions = computed(() =>
const selectedOption = computed(() =>
visibleOptions.value.find((option) => valuesMatch(option.value, selection.value)),
)
const selectedLabel = computed(() => selectedOption.value?.label ?? '')
const selectedLabel = computed(() => {
const value = selection.value
if (value === null) return ''
if (selectedOption.value) return selectedOption.value.label
if (!props.optionValue && typeof value === 'object') {
const label = resolveLabel(value, props.optionLabel)
if (label) return label
}
if (cachedSelection.value && valuesMatch(cachedSelection.value.value, value)) {
return cachedSelection.value.label
}
return ''
})
const filterText = computed(() => (isFiltering.value ? query.value.trim() : ''))
const filteredOptions = computed(() => {
const search = filterText.value.toLocaleLowerCase()
@ -177,14 +209,28 @@ const filteredOptions = computed(() => {
const highlightedIndex = computed(() =>
filteredOptions.value.findIndex((option) => option.key === highlightedKey.value),
)
const searchTooShort = computed(
() =>
(isFiltering.value || selection.value === null) &&
query.value.trim().length < Math.max(0, props.minSearchLength),
)
const activeDescendant = computed(() =>
highlightedIndex.value >= 0 ? `${listboxId}-option-${highlightedIndex.value}` : undefined,
!props.loading && !searchTooShort.value && highlightedIndex.value >= 0
? `${listboxId}-option-${highlightedIndex.value}`
: undefined,
)
watch(
selectedLabel,
(label) => {
if (!isOpen.value) query.value = label
[selection, selectedOption],
([value, option]) => {
if (value === null) cachedSelection.value = null
else if (option) cachedSelection.value = { value, label: option.label }
else if (!props.optionValue && typeof value === 'object') {
const label = resolveLabel(value, props.optionLabel)
if (label) cachedSelection.value = { value, label }
}
if (!isOpen.value) query.value = selectedLabel.value
},
{ immediate: true },
)
@ -192,12 +238,22 @@ watch(
watch(filteredOptions, () => {
if (
isOpen.value &&
!props.loading &&
!searchTooShort.value &&
(highlightedIndex.value < 0 || filteredOptions.value[highlightedIndex.value]?.disabled)
) {
highlightFirstEnabled()
}
})
watch(
() => props.loading,
(loading) => {
if (loading) highlightedKey.value = null
else if (isOpen.value && !searchTooShort.value) highlightFirstEnabled()
},
)
watch(highlightedIndex, (index) => {
if (index < 0) return
nextTick(() => {
@ -224,15 +280,54 @@ function highlightFirstEnabled() {
highlightedKey.value = filteredOptions.value.find((option) => !option.disabled)?.key ?? null
}
function emitSearch(queryValue: string) {
lastSearchQuery = queryValue
emit('search', queryValue)
}
function cancelScheduledSearch() {
if (searchTimer !== undefined) clearTimeout(searchTimer)
searchTimer = undefined
}
function scheduleSearch(value: string) {
cancelScheduledSearch()
const search = value.trim()
if (search.length < Math.max(0, props.minSearchLength)) {
if (lastSearchQuery !== '') emitSearch('')
return
}
if (!search) {
emitSearch('')
return
}
const wait = Math.max(0, props.debounceWait)
if (!wait) emitSearch(search)
else {
searchTimer = setTimeout(() => {
searchTimer = undefined
emitSearch(search)
}, wait)
}
}
function openList() {
if (props.disabled) return
isOpen.value = true
const selected = selectedOption.value
highlightedKey.value = selected && !selected.disabled ? selected.key : null
if (highlightedKey.value === null) highlightFirstEnabled()
highlightedKey.value =
!props.loading && !searchTooShort.value && selected && !selected.disabled
? selected.key
: null
if (highlightedKey.value === null && !props.loading && !searchTooShort.value) {
highlightFirstEnabled()
}
}
function closeList(restoreLabel = true) {
cancelScheduledSearch()
isOpen.value = false
isFiltering.value = false
highlightedKey.value = null
@ -253,8 +348,12 @@ function handleClick(event: MouseEvent) {
function handleInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
isFiltering.value = true
scheduleSearch(query.value)
isOpen.value = true
nextTick(highlightFirstEnabled)
nextTick(() => {
if (!props.loading && !searchTooShort.value) highlightFirstEnabled()
else highlightedKey.value = null
})
}
function moveHighlight(direction: 1 | -1) {
@ -262,6 +361,7 @@ function moveHighlight(direction: 1 | -1) {
openList()
return
}
if (props.loading || searchTooShort.value) return
const options = filteredOptions.value
if (!options.length) return
@ -275,14 +375,10 @@ function moveHighlight(direction: 1 | -1) {
}
}
function highlightEdge(edge: 'first' | 'last') {
if (!isOpen.value) openList()
const options = edge === 'first' ? filteredOptions.value : [...filteredOptions.value].reverse()
highlightedKey.value = options.find((option) => !option.disabled)?.key ?? null
}
function chooseOption(option: (typeof visibleOptions.value)[number]) {
if (option.disabled) return
cancelScheduledSearch()
cachedSelection.value = { value: option.value, label: option.label }
emit('update:modelValue', option.value)
query.value = option.label
closeList(false)
@ -293,16 +389,10 @@ function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' && isOpen.value) {
event.preventDefault()
highlightEdge('first')
} else if (event.key === 'End' && isOpen.value) {
event.preventDefault()
highlightEdge('last')
} else if (event.key === 'Enter') {
event.preventDefault()
if (!isOpen.value) openList()
else {
else if (!props.loading && !searchTooShort.value) {
const option = filteredOptions.value[highlightedIndex.value]
if (option) chooseOption(option)
}
@ -320,11 +410,17 @@ function handleFocusOut(event: FocusEvent) {
}
function clearSelection() {
cachedSelection.value = null
emit('update:modelValue', null)
query.value = ''
if (props.filterable) scheduleSearch('')
closeList(false)
emit('clear')
}
onBeforeUnmount(() => {
cancelScheduledSearch()
})
</script>
<template>
@ -349,6 +445,7 @@ function clearSelection() {
role="combobox"
aria-autocomplete="list"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-busy="loading ? 'true' : undefined"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendant"
:aria-required="required ? 'true' : undefined"
@ -359,31 +456,49 @@ function clearSelection() {
@input="handleInput"
@keydown="handleKeydown"
/>
<span class="indicator" aria-hidden="true">
<CIcon v-if="loading" :rotate="1"></CIcon>
<span v-else></span>
</span>
</div>
<ul ref="listElement" v-show="isOpen" :id="listboxId" class="options" role="listbox">
<li
v-for="(option, index) in filteredOptions"
:id="`${listboxId}-option-${index}`"
:key="option.key"
class="option"
:class="{
'is-highlighted': option.key === highlightedKey,
'is-selected': valuesMatch(option.value, selection),
'is-disabled': option.disabled,
}"
role="option"
:aria-selected="valuesMatch(option.value, selection) ? 'true' : 'false'"
:aria-disabled="option.disabled ? 'true' : undefined"
@mousemove="!option.disabled && (highlightedKey = option.key)"
@mousedown.prevent
@click="chooseOption(option)"
>
{{ option.label }}
</li>
<li v-if="!filteredOptions.length" class="empty" role="presentation">
No matching options
<ul
ref="listElement"
v-show="isOpen"
:id="listboxId"
class="options"
role="listbox"
:aria-busy="loading ? 'true' : undefined"
>
<li v-if="loading" class="empty" role="presentation">Loading</li>
<li v-else-if="searchTooShort" class="empty" role="presentation">
Type at least {{ Math.max(0, minSearchLength) }}
{{ Math.max(0, minSearchLength) === 1 ? 'character' : 'characters' }}
</li>
<template v-else>
<li
v-for="(option, index) in filteredOptions"
:id="`${listboxId}-option-${index}`"
:key="option.key"
class="option"
:class="{
'is-highlighted': option.key === highlightedKey,
'is-selected': valuesMatch(option.value, selection),
'is-disabled': option.disabled,
}"
role="option"
:aria-selected="valuesMatch(option.value, selection) ? 'true' : 'false'"
:aria-disabled="option.disabled ? 'true' : undefined"
@mousemove="!option.disabled && (highlightedKey = option.key)"
@mousedown.prevent
@click="chooseOption(option)"
>
{{ option.label }}
</li>
<li v-if="!filteredOptions.length" class="empty" role="presentation">
No matching options
</li>
</template>
</ul>
</template>
@ -461,18 +576,18 @@ function clearSelection() {
.combobox {
position: relative;
display: flex;
}
&::after {
position: absolute;
inset-inline-end: 9px;
top: 50%;
color: var(--c-muted-text-color, #68717d);
font-size: 10px;
line-height: 1;
pointer-events: none;
content: '▼';
transform: translateY(-50%);
}
.indicator {
position: absolute;
inset-inline-end: 9px;
top: 50%;
display: inline-flex;
color: var(--c-muted-text-color, #68717d);
font-size: 10px;
line-height: 1;
pointer-events: none;
transform: translateY(-50%);
}
.c-select {

@ -1,4 +1,5 @@
export { default as CAppBar } from './components/app-bar/CAppBar.vue'
export { default as CAutoComplete } from './components/auto-complete/CAutoComplete.vue'
export { default as CButton } from './components/button/CButton.vue'
export { default as CCheckbox } from './components/checkbox/CCheckbox.vue'
export { default as CFormField } from './components/form-field/CFormField.vue'

@ -113,6 +113,13 @@ export function useTopNavigation() {
active: route.name === 'multi-select',
command: () => void router.push({ name: 'multi-select' }),
},
{
id: 'auto-complete',
label: 'Auto Complete',
icon: '⌕',
active: route.name === 'auto-complete',
command: () => void router.push({ name: 'auto-complete' }),
},
{ type: 'separator' },
{
id: 'icon',
@ -230,6 +237,13 @@ export function useComponentNavigation() {
active: route.name === 'multi-select',
command: () => void router.push({ name: 'multi-select' }),
},
{
id: 'auto-complete',
label: 'Auto Complete',
icon: '⌕',
active: route.name === 'auto-complete',
command: () => void router.push({ name: 'auto-complete' }),
},
{ type: 'separator' },
{
id: 'icon',

@ -0,0 +1,254 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { CAutoComplete, CFormField, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const cities = [
'Bandung',
'Bekasi',
'Bogor',
'Denpasar',
'Jakarta',
'Makassar',
'Medan',
'Semarang',
'Surabaya',
'Yogyakarta',
]
const destination = ref('Jakarta')
const department = ref('')
const smallValue = ref('Bandung')
const largeValue = ref('Surabaya')
const remoteCity = ref('')
const remoteOptions = ref<string[]>([])
const remoteLoading = ref(false)
let remoteTimer: ReturnType<typeof setTimeout> | undefined
function searchCities(query: string) {
if (remoteTimer !== undefined) clearTimeout(remoteTimer)
if (!query) {
remoteOptions.value = []
remoteLoading.value = false
return
}
remoteLoading.value = true
remoteTimer = setTimeout(() => {
remoteOptions.value = cities.filter((city) =>
city.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
)
remoteLoading.value = false
}, 600)
}
onBeforeUnmount(() => {
if (remoteTimer !== undefined) clearTimeout(remoteTimer)
})
const basicJavaScript = `const cities = [
'Bandung',
'Bekasi',
'Bogor',
'Denpasar',
'Jakarta',
'Makassar',
'Medan',
'Semarang',
'Surabaya',
'Yogyakarta',
]
const destination = ref('Jakarta')`
const basicUsage = `<CAutoComplete
v-model="destination"
:options="cities"
placeholder="Enter a destination"
clearable
/>`
const arbitraryUsage = `<CAutoComplete
v-model="department"
:options="['Sales', 'Finance', 'Operations']"
placeholder="Choose or enter a department"
/>
<p>Current value: {{ department }}</p>`
const remoteJavaScript = `const suggestions = ref([])
const loading = ref(false)
let controller
async function searchCustomers(query) {
controller?.abort()
if (!query) {
suggestions.value = []
loading.value = false
return
}
const request = new AbortController()
controller = request
loading.value = true
try {
const response = await fetch(
\`/api/customers?q=\${encodeURIComponent(query)}\`,
{ signal: request.signal },
)
suggestions.value = await response.json()
} catch (error) {
if (error.name !== 'AbortError') throw error
} finally {
if (controller === request) loading.value = false
}
}`
const remoteUsage = `<CAutoComplete
v-model="customer"
:options="suggestions"
:loading="loading"
:debounce-wait="350"
:min-search-length="2"
@search="searchCustomers"
/>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Auto Complete</h1></div>
<p>
<code>CAutoComplete</code> is a text input with string suggestions. Users may select a
suggestion or keep any arbitrary text they enter.
</p>
</header>
<CSeparator />
<section class="section">
<h2>String suggestions</h2>
<p>
Pass a simple array of strings through <code>options</code>. Matching is case-insensitive
and checks the entire string. Because the options have no object mapping, the API does not
need <code>option-label</code>, <code>option-value</code>, or <code>option-key</code>.
</p>
<div class="preview">
<CFormField label="Destination">
<CAutoComplete
v-model="destination"
:options="cities"
placeholder="Enter a destination"
clearable
/>
</CFormField>
<span>Current value: {{ destination }}</span>
</div>
<CCodeBlock class="code-sample" :code="basicJavaScript" language="javascript" />
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Arbitrary values</h2>
<p>
Typing updates <code>v-model</code> immediately, even when the text is absent from
<code>options</code>. Suggestions assist entry but do not constrain it. The
<code>select</code> event is emitted only when a listed suggestion is explicitly chosen.
</p>
<div class="preview">
<CFormField label="Department" hint="You may enter a department not shown in the list.">
<CAutoComplete
v-model="department"
:options="['Sales', 'Finance', 'Operations']"
placeholder="Choose or enter a department"
/>
</CFormField>
<span>Current value: {{ department }}</span>
</div>
<CCodeBlock class="code-sample" :code="arbitraryUsage" />
</section>
<section class="section">
<h2>Keyboard usage</h2>
<p>
Use Arrow Up or Arrow Down to move through suggestions, Enter to accept the highlighted
suggestion, and Escape to close the list without changing the current text. Tab keeps the
arbitrary value and moves focus normally.
</p>
</section>
<section class="section">
<h2>Remote suggestions</h2>
<p>
The component does not make network requests itself. After the user pauses typing,
<code>search</code> emits the trimmed query and the parent replaces <code>options</code>.
<code>debounce-wait</code> controls that pause, while <code>min-search-length</code> prevents
short queries from starting a search.
</p>
<p>
When the query drops below the minimum, an empty search is emitted immediately so the
parent can cancel work and clear stale results. Set <code>loading</code> while awaiting the
response; users can continue typing while the spinner is visible.
</p>
<div class="preview">
<CFormField label="Remote city search" hint="Type at least two characters.">
<CAutoComplete
v-model="remoteCity"
:options="remoteOptions"
:loading="remoteLoading"
:debounce-wait="350"
:min-search-length="2"
placeholder="Search cities"
clearable
@search="searchCities"
/>
</CFormField>
<span>Current value: {{ remoteCity }}</span>
</div>
<CCodeBlock class="code-sample" :code="remoteJavaScript" language="javascript" />
<CCodeBlock class="code-sample" :code="remoteUsage" />
</section>
<section class="section">
<h2>Sizes and states</h2>
<div class="preview">
<CFormField label="Small (26px)">
<CAutoComplete v-model="smallValue" :options="cities" size="small" />
</CFormField>
<CFormField label="Large (34px)">
<CAutoComplete v-model="largeValue" :options="cities" size="large" />
</CFormField>
<CFormField label="Disabled">
<CAutoComplete model-value="Jakarta" :options="cities" disabled />
</CFormField>
<CFormField label="Invalid">
<CAutoComplete model-value="Unknown office" :options="cities" invalid />
</CFormField>
</div>
</section>
<section class="section">
<h2>Properties and events</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>Current string, including values absent from the suggestion list.</dd></div>
<div><dt><code>options</code></dt><dd>Array of strings used as suggestions.</dd></div>
<div><dt><code>debounce-wait</code></dt><dd>Milliseconds to wait before emitting a non-empty <code>search</code>. Defaults to <code>300</code>.</dd></div>
<div><dt><code>loading</code></dt><dd>Shows remote loading feedback without disabling text entry.</dd></div>
<div><dt><code>min-search-length</code></dt><dd>Minimum trimmed query length required before emitting a non-empty search.</dd></div>
<div><dt><code>clearable</code></dt><dd>Adds a compact button that clears the value to an empty string.</dd></div>
<div><dt><code>placeholder</code></dt><dd>Text displayed while the value is empty.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables input and focus.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
<div><dt><code>search</code></dt><dd>Emitted with the debounced query, or immediately with an empty string when below the minimum.</dd></div>
<div><dt><code>select</code></dt><dd>Emitted with the string when a suggestion is explicitly selected.</dd></div>
<div><dt><code>clear</code></dt><dd>Emitted after the clear button resets the value.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { CFormField, CSelect, CSeparator } from '@/index'
import type { CSelectOption, CSelectValue } from '@/index'
@ -16,6 +16,31 @@ const products = [
const selectedProduct = ref<(typeof products)[number] | null>(products[0] ?? null)
const selectedProductId = ref<number | null>(1)
const filteredProduct = ref<(typeof products)[number] | null>(null)
const remoteProductId = ref<number | null>(1)
const remoteProducts = ref([...products])
const remoteLoading = ref(false)
let remoteTimer: ReturnType<typeof setTimeout> | undefined
function searchProducts(query: string) {
if (remoteTimer !== undefined) clearTimeout(remoteTimer)
if (!query) {
remoteProducts.value = []
remoteLoading.value = false
return
}
remoteLoading.value = true
remoteTimer = setTimeout(() => {
remoteProducts.value = products.filter((product) =>
product.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
)
remoteLoading.value = false
}, 600)
}
onBeforeUnmount(() => {
if (remoteTimer !== undefined) clearTimeout(remoteTimer)
})
const warehouses: CSelectOption[] = [
{ label: 'North warehouse', value: 'north' },
@ -81,6 +106,19 @@ const filterableUsage = `<CSelect
filterable
clearable
/>`
const remoteUsage = `<CSelect
v-model="selectedProductId"
:options="products"
option-label="name"
option-value="id"
option-key="id"
filterable
:loading="loading"
:debounce-wait="350"
:min-search-length="2"
@search="searchProducts"
/>`
</script>
<template>
@ -123,7 +161,7 @@ const filterableUsage = `<CSelect
<p>
Add <code>filterable</code> when a long list should be searchable. Typing narrows the
available options but does not create a new value: <code>v-model</code> changes only when
the user selects an existing option. Use <code>CAutoComplete</code> later when arbitrary
the user selects an existing option. Use <code>CAutoComplete</code> when arbitrary
user-entered values should be allowed.
</p>
<p>
@ -147,6 +185,43 @@ const filterableUsage = `<CSelect
<CCodeBlock class="code-sample" :code="filterableUsage" />
</section>
<section class="section">
<h2>Remote options and cached selection</h2>
<p>
In filterable mode, <code>search</code> emits the trimmed query after
<code>debounce-wait</code>. The parent performs the request and replaces
<code>options</code>. Use <code>min-search-length</code> to avoid short requests and set
<code>loading</code> while awaiting the response.
</p>
<p>
Searching never changes <code>v-model</code>. The last resolved label is cached with its
selected value, so replacing the optionseven with results that omit the selected
recorddoes not make the committed selection disappear. Escape or blur restores that
cached label. An initially loaded primitive value must still have its matching option
supplied at least once so its label can be learned.
</p>
<div class="preview">
<CFormField label="Remote product" hint="Select Book, then search for Stove and press Escape.">
<CSelect
v-model="remoteProductId"
:options="remoteProducts"
option-label="name"
option-value="id"
option-key="id"
placeholder="Search products"
filterable
clearable
:loading="remoteLoading"
:debounce-wait="350"
:min-search-length="2"
@search="searchProducts"
/>
</CFormField>
<span>Selected ID: {{ remoteProductId }}</span>
</div>
<CCodeBlock class="code-sample" :code="remoteUsage" />
</section>
<section class="section">
<h2>Data-driven options</h2>
<p>
@ -220,11 +295,15 @@ const filterableUsage = `<CSelect
<div><dt><code>option-key</code></dt><dd>Property path or function providing stable rendering and object-selection identity.</dd></div>
<div><dt><code>clearable</code></dt><dd>Shows a compact × button that clears the selected model to <code>null</code>.</dd></div>
<div><dt><code>filterable</code></dt><dd>Replaces the native select with a searchable, existing-options-only combobox.</dd></div>
<div><dt><code>debounce-wait</code></dt><dd>Milliseconds to wait before emitting a non-empty <code>search</code>. Defaults to <code>300</code>.</dd></div>
<div><dt><code>loading</code></dt><dd>Shows loading feedback in filterable mode without changing the selection.</dd></div>
<div><dt><code>min-search-length</code></dt><dd>Minimum trimmed query length required before emitting a non-empty search.</dd></div>
<div><dt><code>placeholder</code></dt><dd>Disabled initial option displayed while the model is null.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables selection and focus.</dd></div>
<div><dt><code>required</code></dt><dd>Requires an actual option selection, including in filterable mode.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
<div><dt><code>search</code></dt><dd>Emitted with the debounced query, or immediately with an empty string when below the minimum.</dd></div>
</dl>
</section>
</article>

@ -91,6 +91,11 @@ const router = createRouter({
name: 'multi-select',
component: () => import('@/pages/components/MultiSelect.vue'),
},
{
path: 'auto-complete',
name: 'auto-complete',
component: () => import('@/pages/components/AutoComplete.vue'),
},
{
path: 'icon',
name: 'icon',

Loading…
Cancel
Save