<form-combobox value="">
<label for="city-input">Choose a city</label>
<div class="input">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-popup"
aria-autocomplete="list"
autocomplete="off"
required
/>
<ol id="city-popup" role="listbox" hidden>
<li role="option" tabindex="-1">Amsterdam</li>
<li role="option" tabindex="-1">Berlin</li>
<li role="option" tabindex="-1">Copenhagen</li>
<li role="option" tabindex="-1">Dublin</li>
<li role="option" tabindex="-1">Edinburgh</li>
<li role="option" tabindex="-1">Frankfurt</li>
<li role="option" tabindex="-1">Geneva</li>
<li role="option" tabindex="-1">Helsinki</li>
<li role="option" tabindex="-1">Istanbul</li>
<li role="option" tabindex="-1">Jakarta</li>
<li role="option" tabindex="-1">Kairo</li>
<li role="option" tabindex="-1">London</li>
<li role="option" tabindex="-1">Madrid</li>
<li role="option" tabindex="-1">New York</li>
<li role="option" tabindex="-1">Oslo</li>
<li role="option" tabindex="-1">Paris</li>
<li role="option" tabindex="-1">Qingdao</li>
<li role="option" tabindex="-1">Rome</li>
<li role="option" tabindex="-1">Stockholm</li>
<li role="option" tabindex="-1">Tokyo</li>
<li role="option" tabindex="-1">Ulan Bator</li>
<li role="option" tabindex="-1">Vienna</li>
<li role="option" tabindex="-1">Warsaw</li>
<li role="option" tabindex="-1">Xi'an</li>
<li role="option" tabindex="-1">Yokohama</li>
<li role="option" tabindex="-1">Zurich</li>
</ol>
<button type="button" class="clear" aria-label="Clear input" hidden>
✕
</button>
</div>
<p class="error" aria-live="assertive" id="city-error"></p>
<p class="description" aria-live="polite" id="city-description">
Tell us where you live so we can set your timezone for our calendar and
notification features.
</p>
</form-combobox>
form-combobox {
width: 100%;
& label,
& p,
& button {
opacity: var(--opacity-dimmed);
transition: opacity var(--transition-short) var(--easing-inout);
}
& label {
display: block;
font-size: var(--font-size-s);
color: var(--color-text);
margin-bottom: var(--space-xxs);
}
& input {
display: inline-block;
box-sizing: border-box;
background: var(--color-input);
color: var(--color-text);
border: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-xs) var(--input-height) var(--space-xs)
var(--space-xxs);
font-size: var(--font-size-m);
height: var(--input-height);
width: 100%;
transition: color var(--transition-short) var(--easing-inout);
&[aria-invalid="true"] {
box-shadow: 0 0 var(--space-xxs) 2px var(--color-error-invalid);
}
&::placeholder {
color: var(--color-text);
opacity: var(--opacity-translucent);
}
}
.input {
position: relative;
}
.clear {
position: absolute;
bottom: 0;
right: 0;
border: 0;
border-radius: 50%;
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
color: var(--color-input);
background-color: var(--color-text-soft);
width: calc(0.6 * var(--input-height));
height: calc(0.6 * var(--input-height));
margin: calc(0.2 * var(--input-height));
padding: 0;
&:hover {
background-color: var(--color-text);
}
}
[role="listbox"] {
position: absolute;
gap: 0;
list-style: none;
top: 100%;
left: 0;
width: calc(100% - 2px);
padding: 0;
margin: 0;
background: var(--color-secondary);
border: 1px solid var(--color-border);
border-top: none;
z-index: 1;
[role="option"] {
margin: 0;
padding: var(--space-xs) var(--space-s);
font-size: var(--font-size-s);
line-height: var(--line-height-s);
color: var(--color-text);
cursor: pointer;
transition: background var(--transition-short) var(--easing-inout);
&[aria-selected="true"] {
background: var(--color-primary);
color: var(--color-text-inverted);
}
&:hover {
background: var(--color-secondary-hover);
&[aria-selected="true"] {
background: var(--color-primary-hover);
}
}
}
}
.error,
.description {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-xs);
line-height: var(--line-height-s);
&:empty {
display: none;
}
}
.error {
color: color-mix(in srgb, var(--color-text) 50%, var(--color-error));
}
.description {
color: var(--color-text-soft);
}
&:focus-within {
& label,
& p,
& button {
opacity: var(--opacity-solid);
}
& input {
color: var(--color-text);
}
}
}
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>
}
}