@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
221 lines (206 loc) • 5.24 kB
text/typescript
import {
type Component,
type Computed,
UNSET,
batch,
component,
effect,
fromSelector,
on,
setAttribute,
setProperty,
setText,
show,
state,
} from '../../..'
import { createClearFunction } from '../../functions/shared/clear-input'
export type FormComboboxProps = {
value: string
length: number
error: string
description: string
clear(): void
}
type FormComboboxMode = 'idle' | 'editing' | 'selected'
export default component<FormComboboxProps>(
'form-combobox',
{
value: '',
length: 0,
error: '',
description: '',
clear() {},
},
(el, { first, all }) => {
const input = el.querySelector('input')
if (!input) throw new Error('Input element not found')
// Internal signals
const mode = state<FormComboboxMode>('idle')
const focusIndex = state(-1)
const filterText = state('')
const showPopup = state(false)
const options = fromSelector<HTMLLIElement>(
'[role="option"]:not([hidden])',
)(el) as Computed<HTMLLIElement[]>
const isExpanded = () => mode.get() === 'editing' && showPopup.get()
// Internal function
const commit = (value: string) => {
input.value = value
// Clear any custom validity messages
input.setCustomValidity('')
// Force validation state update
input.checkValidity()
batch(() => {
// Set mode to selected when input is changed
mode.set('selected')
el.value = value
el.length = value.length
el.error = input.validationMessage ?? ''
filterText.set(value.toLowerCase())
focusIndex.set(-1)
showPopup.set((input.required && !input.value) || false)
})
}
// Add clear method to component
el.clear = createClearFunction(input)
return [
// Effects and event listeners on component
setAttribute('value'),
() =>
effect(() => {
const m = mode.get()
const i = focusIndex.get()
if (m === 'idle') return
else if (m === 'editing' && i >= 0)
options.get().at(i)?.focus()
else input.focus()
}),
on('keydown', e => {
const { key, altKey } = e
if (['ArrowDown', 'ArrowUp'].includes(key)) {
e.preventDefault()
e.stopPropagation()
// Set mode to editing when navigating options
mode.set('editing')
if (altKey) showPopup.set(key === 'ArrowDown')
else
focusIndex.update(v =>
key === 'ArrowDown'
? Math.min(v + 1, options.get().length - 1)
: Math.max(v - 1, -1),
)
}
}),
on('keyup', e => {
const { key } = e
if (key === 'Delete') {
e.preventDefault()
e.stopPropagation()
commit('')
}
}),
on('focusout', () => {
requestAnimationFrame(() => {
// Set mode to idle when no element in our component has focus
if (!el.contains(document.activeElement)) mode.set('idle')
})
}),
// Effects on error and description
first('.error', setText('error')),
first('.description', setText('description')),
// Effects and event listeners on input
first(
'input',
setProperty('ariaInvalid', () => String(!!el.error)),
setAttribute('aria-errormessage', () =>
el.error && el.querySelector('.error')?.id
? el.querySelector('.error')?.id
: UNSET,
),
setAttribute('aria-describedby', () =>
el.description && el.querySelector('.description')?.id
? el.querySelector('.description')?.id
: UNSET,
),
setProperty('ariaExpanded', () => String(isExpanded())),
on('change', () => {
input.checkValidity()
el.value = input.value
el.error = input.validationMessage ?? ''
}),
on('input', () => {
batch(() => {
// Set mode to editing when typing
mode.set('editing')
showPopup.set(true)
filterText.set(input.value.trim().toLowerCase())
el.length = input.value.length
})
}),
),
// Effects and event listeners on clear button
first(
'.clear',
show(() => !!el.length),
on('click', () => {
el.clear()
}),
),
// Effect on listbox
first(
'[role="listbox"]',
show(isExpanded),
on('keyup', (e: Event) => {
const { key } = e as KeyboardEvent
if (key === 'Enter') {
commit(
options
.get()
.at(focusIndex.get())
?.textContent?.trim() || '',
)
} else if (key === 'Escape') {
commit(el.value)
} else {
const lowKey = key.toLowerCase()
const nextIndex = options
.get()
.findIndex(option =>
(
option.textContent?.trim().toLowerCase() ||
''
).startsWith(lowKey),
)
if (nextIndex !== -1) focusIndex.set(nextIndex)
}
}),
),
// Effects and event listeners on options
all<HTMLLIElement>(
'[role="option"]',
setProperty('ariaSelected', target =>
String(
target.textContent?.trim().toLowerCase() ===
el.value.toLowerCase(),
),
),
show(target =>
target.textContent
?.trim()
.toLowerCase()
.includes(filterText.get()),
),
on('click', (e: Event) => {
commit(
(e.target as HTMLLIElement).textContent?.trim() || '',
)
}),
),
]
},
)
declare global {
interface HTMLElementTagNameMap {
'form-combobox': Component<FormComboboxProps>
}
}