Add filterable and clearable to CSelect.

main
fandi.susanto.bts 1 month ago
parent bad6e8cb75
commit 5f3230cb7b

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, nextTick, ref, useId, watch } from 'vue'
import CButton from '../button/CButton.vue'
import type { CFormControlSize } from '../form/types' import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl' import { useFormControl } from '../form/useFormControl'
import type { import type {
@ -21,6 +22,8 @@ const props = withDefaults(
optionValue?: CSelectValueAccessor optionValue?: CSelectValueAccessor
optionKey?: CSelectKeyAccessor optionKey?: CSelectKeyAccessor
placeholder?: string placeholder?: string
clearable?: boolean
filterable?: boolean
size?: CFormControlSize size?: CFormControlSize
disabled?: boolean disabled?: boolean
required?: boolean required?: boolean
@ -33,6 +36,8 @@ const props = withDefaults(
optionValue: undefined, optionValue: undefined,
optionKey: undefined, optionKey: undefined,
placeholder: undefined, placeholder: undefined,
clearable: false,
filterable: false,
size: 'medium', size: 'medium',
disabled: false, disabled: false,
required: false, required: false,
@ -42,10 +47,18 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: CSelectValue | null] 'update:modelValue': [value: CSelectValue | null]
clear: []
}>() }>()
const { controlId, describedBy, invalid, required } = useFormControl(props) const { controlId, describedBy, invalid, required } = useFormControl(props)
const objectKeys = new WeakMap<object, number>() const objectKeys = new WeakMap<object, number>()
const listboxId = `c-select-listbox-${useId()}`
const inputElement = ref<HTMLInputElement | null>(null)
const listElement = ref<HTMLUListElement | null>(null)
const isOpen = ref(false)
const isFiltering = ref(false)
const query = ref('')
const highlightedKey = ref<string | number | null>(null)
let nextObjectKey = 0 let nextObjectKey = 0
const selection = computed({ const selection = computed({
@ -91,7 +104,6 @@ const visibleOptions = computed(() =>
const record = source as Record<string, unknown> const record = source as Record<string, unknown>
if (record.hidden) return [] if (record.hidden) return []
const value = resolveValue(source, props.optionValue) const value = resolveValue(source, props.optionValue)
const explicitKey = props.optionKey const explicitKey = props.optionKey
? typeof props.optionKey === 'function' ? typeof props.optionKey === 'function'
? props.optionKey(source) ? props.optionKey(source)
@ -115,49 +127,326 @@ const visibleOptions = computed(() =>
const option = source as CSelectOption const option = source as CSelectOption
if (option.hidden) return [] if (option.hidden) return []
const valueKey = const valueKey =
typeof option.value === 'object' typeof option.value === 'object'
? `object:${objectKey(option.value)}` ? `object:${objectKey(option.value)}`
: `${typeof option.value}:${String(option.value)}:${index}` : `${typeof option.value}:${String(option.value)}:${index}`
return [{ ...option, key: valueKey }] return [{ ...option, key: valueKey }]
}), }),
) )
const selectedOption = computed(() =>
visibleOptions.value.find((option) => Object.is(option.value, selection.value)),
)
const selectedLabel = computed(() => selectedOption.value?.label ?? '')
const filterText = computed(() => (isFiltering.value ? query.value.trim() : ''))
const filteredOptions = computed(() => {
const search = filterText.value.toLocaleLowerCase()
return search
? visibleOptions.value.filter((option) => option.label.toLocaleLowerCase().includes(search))
: visibleOptions.value
})
const highlightedIndex = computed(() =>
filteredOptions.value.findIndex((option) => option.key === highlightedKey.value),
)
const activeDescendant = computed(() =>
highlightedIndex.value >= 0 ? `${listboxId}-option-${highlightedIndex.value}` : undefined,
)
watch(
selectedLabel,
(label) => {
if (!isOpen.value) query.value = label
},
{ immediate: true },
)
watch(filteredOptions, () => {
if (
isOpen.value &&
(highlightedIndex.value < 0 || filteredOptions.value[highlightedIndex.value]?.disabled)
) {
highlightFirstEnabled()
}
})
watch(highlightedIndex, (index) => {
if (index < 0) return
nextTick(() => {
const option = listElement.value?.children[index]
if (option instanceof HTMLElement) option.scrollIntoView({ block: 'nearest' })
})
})
watch(
[required, selection, () => props.filterable],
() => {
nextTick(() => {
inputElement.value?.setCustomValidity(
props.filterable && required.value && selection.value === null
? 'Please select an option.'
: '',
)
})
},
{ immediate: true },
)
function highlightFirstEnabled() {
highlightedKey.value = filteredOptions.value.find((option) => !option.disabled)?.key ?? null
}
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()
}
function closeList(restoreLabel = true) {
isOpen.value = false
isFiltering.value = false
highlightedKey.value = null
if (restoreLabel) query.value = selectedLabel.value
}
function handleFocus(event: FocusEvent) {
openList()
const input = event.currentTarget as HTMLInputElement
input.select()
}
function handleClick(event: MouseEvent) {
openList()
if (!isFiltering.value) (event.currentTarget as HTMLInputElement).select()
}
function handleInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
isFiltering.value = true
isOpen.value = true
nextTick(highlightFirstEnabled)
}
function moveHighlight(direction: 1 | -1) {
if (!isOpen.value) {
openList()
return
}
const options = filteredOptions.value
if (!options.length) return
let index = highlightedIndex.value
for (let attempt = 0; attempt < options.length; attempt += 1) {
index = (index + direction + options.length) % options.length
if (!options[index]?.disabled) {
highlightedKey.value = options[index]?.key ?? null
return
}
}
}
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
emit('update:modelValue', option.value)
query.value = option.label
closeList(false)
inputElement.value?.focus()
}
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 {
const option = filteredOptions.value[highlightedIndex.value]
if (option) 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 clearSelection() {
emit('update:modelValue', null)
query.value = ''
closeList(false)
emit('clear')
}
</script> </script>
<template> <template>
<select <div
v-model="selection" class="c-select-control"
v-bind="$attrs" :class="{ 'is-clearable': clearable, 'is-filterable': filterable }"
class="c-select" @focusout="filterable ? handleFocusOut($event) : undefined"
:class="[
`is-${size}`,
{
'is-invalid': invalid,
'has-placeholder': Boolean(placeholder) && (modelValue ?? null) === null,
},
]"
:id="controlId"
:disabled="disabled"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
> >
<option v-if="placeholder" :value="null" disabled>{{ placeholder }}</option> <template v-if="filterable">
<option <div class="combobox">
v-for="option in visibleOptions" <input
:key="option.key" ref="inputElement"
:value="option.value" v-bind="$attrs"
:disabled="option.disabled" class="c-select search"
:class="[`is-${size}`, { 'is-invalid': invalid, 'has-placeholder': !query }]"
:id="controlId"
:value="query"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
:aria-expanded="isOpen ? 'true' : 'false'"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendant"
:aria-required="required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@focus="handleFocus"
@click="handleClick"
@input="handleInput"
@keydown="handleKeydown"
/>
</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': Object.is(option.value, selection),
'is-disabled': option.disabled,
}"
role="option"
:aria-selected="Object.is(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>
</ul>
</template>
<select
v-else
v-model="selection"
v-bind="$attrs"
class="c-select"
:class="[
`is-${size}`,
{
'is-invalid': invalid,
'has-placeholder': Boolean(placeholder) && (modelValue ?? null) === null,
},
]"
:id="controlId"
:disabled="disabled"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
> >
{{ option.label }} <option v-if="placeholder" :value="null" disabled>{{ placeholder }}</option>
</option> <option
<slot /> v-for="option in visibleOptions"
</select> :key="option.key"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
<slot />
</select>
<CButton
v-if="clearable"
class="clear"
icon="×"
:size="size"
:disabled="disabled || (modelValue ?? null) === null"
:aria-controls="controlId"
aria-label="Clear selection"
@click="clearSelection"
/>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.c-select-control {
position: relative;
display: flex;
width: 100%;
min-width: 0;
.c-select,
.combobox {
flex: 1 1 auto;
min-width: 0;
}
&.is-clearable {
.c-select {
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;
&::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%);
}
}
.c-select { .c-select {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -172,6 +461,10 @@ const visibleOptions = computed(() =>
border: 1px solid var(--c-control-border-color, #bfc5ce); border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px); border-radius: var(--c-border-radius, 3px);
&.search {
cursor: text;
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
border-color: var(--c-control-hover-border-color, #929aa6); border-color: var(--c-control-hover-border-color, #929aa6);
} }
@ -214,4 +507,49 @@ const visibleOptions = computed(() =>
font-size: 14px; 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);
}
&.is-disabled {
color: var(--c-disabled-text-color, #8a9099);
}
}
.empty {
color: var(--c-muted-text-color, #68717d);
}
</style> </style>

@ -15,6 +15,7 @@ const products = [
] ]
const selectedProduct = ref<(typeof products)[number] | null>(products[0] ?? null) const selectedProduct = ref<(typeof products)[number] | null>(products[0] ?? null)
const selectedProductId = ref<number | null>(1) const selectedProductId = ref<number | null>(1)
const filteredProduct = ref<(typeof products)[number] | null>(null)
const warehouses: CSelectOption[] = [ const warehouses: CSelectOption[] = [
{ label: 'North warehouse', value: 'north' }, { label: 'North warehouse', value: 'north' },
@ -68,6 +69,17 @@ const objectUsage = `<CSelect
:options="products" :options="products"
option-label="name" option-label="name"
option-key="id" option-key="id"
clearable
/>`
const filterableUsage = `<CSelect
v-model="filteredProduct"
:options="products"
option-label="name"
option-key="id"
placeholder="Search products"
filterable
clearable
/>` />`
</script> </script>
@ -76,8 +88,8 @@ const objectUsage = `<CSelect
<header class="page-header"> <header class="page-header">
<div><p class="category">Forms</p><h1>Select</h1></div> <div><p class="category">Forms</p><h1>Select</h1></div>
<p> <p>
<code>CSelect</code> styles a native single-value select and supports either data-driven <code>CSelect</code> is a single-value selection control. It uses a native select by
options or ordinary HTML option slots. default and can become a searchable combobox for longer data-driven lists.
</p> </p>
</header> </header>
<CSeparator /> <CSeparator />
@ -87,7 +99,8 @@ const objectUsage = `<CSelect
<p> <p>
Pass raw objects with <code>option-label</code> to choose the displayed field. When Pass raw objects with <code>option-label</code> to choose the displayed field. When
<code>option-value</code> is omitted, selecting an option binds the complete object. Use <code>option-value</code> is omitted, selecting an option binds the complete object. Use
<code>option-key</code> to provide stable rendering identity. <code>option-key</code> to provide stable rendering identity. Add <code>clearable</code> when
users should be able to return the model to <code>null</code>.
</p> </p>
<div class="preview"> <div class="preview">
<CFormField label="Product"> <CFormField label="Product">
@ -96,6 +109,7 @@ const objectUsage = `<CSelect
:options="products" :options="products"
option-label="name" option-label="name"
option-key="id" option-key="id"
clearable
/> />
</CFormField> </CFormField>
<span>Selected object: {{ selectedProduct }}</span> <span>Selected object: {{ selectedProduct }}</span>
@ -104,6 +118,35 @@ const objectUsage = `<CSelect
<CCodeBlock class="code-sample" :code="objectUsage" /> <CCodeBlock class="code-sample" :code="objectUsage" />
</section> </section>
<section class="section">
<h2>Filter existing options</h2>
<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
user-entered values should be allowed.
</p>
<p>
This mode works with data-driven <code>options</code>. Use Arrow Up or Arrow Down to move,
Enter to select, and Escape to discard the current search and restore the selected label.
</p>
<div class="preview">
<CFormField label="Product search">
<CSelect
v-model="filteredProduct"
:options="products"
option-label="name"
option-key="id"
placeholder="Search products"
filterable
clearable
/>
</CFormField>
<span>Selected object: {{ filteredProduct }}</span>
</div>
<CCodeBlock class="code-sample" :code="filterableUsage" />
</section>
<section class="section"> <section class="section">
<h2>Data-driven options</h2> <h2>Data-driven options</h2>
<p> <p>
@ -142,7 +185,9 @@ const objectUsage = `<CSelect
<h2>Native option slot</h2> <h2>Native option slot</h2>
<p> <p>
Use the default slot when native <code>option</code> or <code>optgroup</code> markup is more Use the default slot when native <code>option</code> or <code>optgroup</code> markup is more
convenient. Data-driven and slotted options can also be combined. convenient. Data-driven and slotted options can also be combined. Slotted options are
available in the default native mode; <code>filterable</code> reads from the
<code>options</code> prop instead.
</p> </p>
<div class="preview"> <div class="preview">
<CFormField label="Priority"> <CFormField label="Priority">
@ -173,10 +218,12 @@ const objectUsage = `<CSelect
<div><dt><code>option-label</code></dt><dd>Property path or function used to label raw object options.</dd></div> <div><dt><code>option-label</code></dt><dd>Property path or function used to label raw object options.</dd></div>
<div><dt><code>option-value</code></dt><dd>Property path or function selecting the bound value. Omit it to bind the complete object.</dd></div> <div><dt><code>option-value</code></dt><dd>Property path or function selecting the bound value. Omit it to bind the complete object.</dd></div>
<div><dt><code>option-key</code></dt><dd>Property path or function providing a stable string or number key for raw objects.</dd></div> <div><dt><code>option-key</code></dt><dd>Property path or function providing a stable string or number key for raw objects.</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>placeholder</code></dt><dd>Disabled initial option displayed while the model is null.</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>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>disabled</code></dt><dd>Disables selection and focus.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</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>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl> </dl>
</section> </section>

Loading…
Cancel
Save