Something went wrong. Try again.
Website hub for the atmosphere community, aggregating posts, events, regional, and more
Something went wrong. Try again.
21 kB · 702 lines
Astro
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703---interface Props { id: string; name: string; label: string; placeholder?: string; inputClass?: string; required?: boolean; minLength?: number; autocomplete?: string; inputmode?: 'text' | 'email' | 'tel' | 'url' | 'search' | 'numeric' | 'decimal' | 'none';}
const { id, name, label, placeholder, inputClass, required, minLength = 1, autocomplete = 'off', inputmode,} = Astro.props;
const popoverId = `${id}-popover`;const listboxId = `${id}-listbox`;const statusId = `${id}-status`;const hintId = `${id}-hint`;---
<cmb-combobox class="cmb" data-min-length={minLength}> <label class="visually-hidden" for={id}>{label}</label> <div class="cmb-input-wrap"> <span class="cmb-leading" aria-hidden="true"><slot name="leading-icon" /></span> <input id={id} name={name} type="text" class:list={['cmb-input', inputClass]} placeholder={placeholder} role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls={popoverId} aria-describedby={hintId} autocomplete={autocomplete} autocorrect="off" autocapitalize="off" spellcheck="false" inputmode={inputmode} required={required} /> </div> <div class="cmb-popover" id={popoverId} hidden> <ul class="cmb-listbox" id={listboxId} role="listbox" aria-label={`${label} suggestions`}></ul> <p class="cmb-message cmb-message--error" data-state="error" hidden>Couldn't load suggestions. Try again.</p> </div> <div class="cmb-status visually-hidden" id={statusId} role="status" aria-live="polite"></div> <span class="visually-hidden" id={hintId}> When suggestions are available, use up and down arrows to review and Enter to select. Touch device users, explore by touch or with swipe gestures. </span></cmb-combobox>
<script> type Suggestion = { value: string; label?: string; sublabel?: string; avatar?: string; }; // Parents wire suggestion fetching by listening for `combobox-search` and // setting `event.detail.results` to a Promise<Suggestion[]>. This avoids // having sibling scripts mutate the element's properties from outside. type SearchDetail = { query: string; results: Promise<Suggestion[]> | null; };
const isIosDevice = () => typeof navigator !== 'undefined' && /(iPod|iPhone|iPad)/.test(navigator.userAgent) && /AppleWebKit/.test(navigator.userAgent);
class Combobox extends HTMLElement { private input!: HTMLInputElement; private popoverEl!: HTMLElement; private listbox!: HTMLUListElement; private errorMsg!: HTMLElement; private status!: HTMLElement; private hintId = ''; private suggestions: Suggestion[] = []; private lastQuery = ''; private activeIndex = -1; private requestSeq = 0; private debounceTimer: number | null = null; private debounceMs = 150; private minLength = 1; private isIos = false;
connectedCallback() { this.input = this.querySelector<HTMLInputElement>('input[role="combobox"]')!; this.popoverEl = this.querySelector<HTMLElement>('.cmb-popover')!; this.listbox = this.querySelector<HTMLUListElement>('[role="listbox"]')!; this.errorMsg = this.querySelector<HTMLElement>('[data-state="error"]')!; this.status = this.querySelector<HTMLElement>('[role="status"]')!; this.hintId = this.input.getAttribute('aria-describedby') ?? ''; this.minLength = Number(this.dataset.minLength ?? 1); this.isIos = isIosDevice();
this.input.addEventListener('input', this.onInput); this.input.addEventListener('keydown', this.onKeyDown); this.popoverEl.addEventListener('mousedown', this.onPopoverMouseDown); this.listbox.addEventListener('click', this.onListboxClick); this.listbox.addEventListener('mousemove', this.onListboxMouseMove); document.addEventListener('pointerdown', this.onDocumentPointerDown); }
disconnectedCallback() { this.input.removeEventListener('input', this.onInput); this.input.removeEventListener('keydown', this.onKeyDown); this.popoverEl.removeEventListener('mousedown', this.onPopoverMouseDown); this.listbox.removeEventListener('click', this.onListboxClick); this.listbox.removeEventListener('mousemove', this.onListboxMouseMove); document.removeEventListener('pointerdown', this.onDocumentPointerDown); if (this.debounceTimer) clearTimeout(this.debounceTimer); }
private onInput = () => { const q = this.input.value.trim(); this.toggleHint(q.length === 0); if (this.debounceTimer) clearTimeout(this.debounceTimer); if (q.length < this.minLength) { this.requestSeq += 1; this.close(); return; } this.debounceTimer = window.setTimeout(() => this.fetchSuggestions(q), this.debounceMs); };
private toggleHint(show: boolean) { if (!this.hintId) return; if (show) this.input.setAttribute('aria-describedby', this.hintId); else this.input.removeAttribute('aria-describedby'); }
private onDocumentPointerDown = (e: PointerEvent) => { if (this.popoverEl.hidden) return; if (this.contains(e.target as Node)) return; this.close(); };
private async fetchSuggestions(q: string) { const detail: SearchDetail = { query: q, results: null }; this.dispatchEvent( new CustomEvent<SearchDetail>('combobox-search', { detail, bubbles: true }), ); if (!detail.results) return; const seq = ++this.requestSeq; this.lastQuery = q; this.input.setAttribute('aria-busy', 'true'); try { const items = await detail.results; if (seq !== this.requestSeq) return; this.suggestions = items.slice(0, 10); this.activeIndex = this.suggestions.length > 0 ? 0 : -1; this.renderResults(); } catch { if (seq !== this.requestSeq) return; this.renderError(); } finally { if (seq === this.requestSeq) this.input.removeAttribute('aria-busy'); } }
private renderResults() { this.input.removeAttribute('aria-busy'); if (this.suggestions.length === 0) { const li = document.createElement('li'); li.setAttribute('role', 'option'); li.setAttribute('aria-disabled', 'true'); li.className = 'cmb-option cmb-option--disabled'; li.textContent = 'No matches'; this.listbox.replaceChildren(li); this.listbox.hidden = false; this.errorMsg.hidden = true; this.popoverEl.hidden = false; this.input.setAttribute('aria-expanded', 'true'); this.input.removeAttribute('aria-activedescendant'); this.status.textContent = 'No matches'; return; }
const n = this.suggestions.length; const frag = document.createDocumentFragment(); this.suggestions.forEach((s, i) => { const li = document.createElement('li'); li.id = `${this.input.id}-opt-${i}`; li.setAttribute('role', 'option'); li.className = 'cmb-option'; li.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false'); li.setAttribute('aria-posinset', String(i + 1)); li.setAttribute('aria-setsize', String(n)); li.dataset.index = String(i);
if (s.avatar) { const img = document.createElement('img'); img.className = 'cmb-option-avatar'; img.src = s.avatar; img.alt = ''; img.width = 40; img.height = 40; img.loading = 'lazy'; img.decoding = 'async'; img.addEventListener('error', () => img.remove(), { once: true }); li.appendChild(img); }
const text = document.createElement('span'); text.className = 'cmb-option-text'; const labelEl = document.createElement('span'); labelEl.className = 'cmb-option-label'; this.fillHighlighted(labelEl, s.label ?? s.value, this.lastQuery); text.appendChild(labelEl); if (s.sublabel) { const subEl = document.createElement('span'); subEl.className = 'cmb-option-sub'; subEl.textContent = s.sublabel; text.appendChild(subEl); } li.appendChild(text);
frag.appendChild(li); });
this.listbox.replaceChildren(frag); this.listbox.hidden = false; this.errorMsg.hidden = true; this.popoverEl.hidden = false; this.input.setAttribute('aria-expanded', 'true'); if (this.activeIndex >= 0) { this.input.setAttribute('aria-activedescendant', `${this.input.id}-opt-${this.activeIndex}`); } else { this.input.removeAttribute('aria-activedescendant'); } this.status.textContent = `${n} result${n === 1 ? '' : 's'} available, use up and down arrows to navigate.`; }
private renderError() { this.input.removeAttribute('aria-busy'); this.suggestions = []; this.activeIndex = -1; this.listbox.hidden = true; this.listbox.replaceChildren(); this.errorMsg.hidden = false; this.popoverEl.hidden = false; this.input.setAttribute('aria-expanded', 'true'); this.input.removeAttribute('aria-activedescendant'); this.status.textContent = "Couldn't load suggestions. Try again."; }
private onKeyDown = (e: KeyboardEvent) => { const open = !this.popoverEl.hidden && this.suggestions.length > 0; switch (e.key) { case 'ArrowDown': if (!open) return; e.preventDefault(); if (this.activeIndex >= this.suggestions.length - 1) { this.setActive(0, true, false); this.announceWrap('top', 0); } else { this.setActive(this.activeIndex + 1); } break; case 'ArrowUp': if (!open) return; e.preventDefault(); if (this.activeIndex <= 0) { const last = this.suggestions.length - 1; this.setActive(last, true, false); this.announceWrap('bottom', last); } else { this.setActive(this.activeIndex - 1); } break; case 'Home': if (!open) return; e.preventDefault(); this.setActive(0); break; case 'End': if (!open) return; e.preventDefault(); this.setActive(this.suggestions.length - 1); break; case 'Enter': if (open && this.activeIndex >= 0) { e.preventDefault(); this.select(this.activeIndex); } break; case 'Escape': if (!this.popoverEl.hidden) { e.preventDefault(); this.close(); } break; case 'Tab': this.close(); break; } };
private onPopoverMouseDown = (e: MouseEvent) => { e.preventDefault(); };
private onListboxClick = (e: MouseEvent) => { const li = (e.target as HTMLElement).closest<HTMLLIElement>('[role="option"]'); if (!li || li.getAttribute('aria-disabled') === 'true') return; this.select(Number(li.dataset.index)); };
private onListboxMouseMove = (e: MouseEvent) => { // iOS Safari swallows the click if mouseenter changes background — skip hover. if (this.isIos) return; const li = (e.target as HTMLElement).closest<HTMLLIElement>('[role="option"]'); if (!li || li.getAttribute('aria-disabled') === 'true') return; const i = Number(li.dataset.index); if (i !== this.activeIndex) this.setActive(i, false, false); };
private fillHighlighted(parent: HTMLElement, text: string, query: string) { if (!query) { parent.textContent = text; return; } const lower = text.toLowerCase(); const q = query.toLowerCase(); let i = 0; let idx = lower.indexOf(q, i); while (idx !== -1) { if (idx > i) parent.appendChild(document.createTextNode(text.slice(i, idx))); const mark = document.createElement('mark'); mark.className = 'cmb-match'; mark.textContent = text.slice(idx, idx + q.length); parent.appendChild(mark); i = idx + q.length; idx = lower.indexOf(q, i); } if (i < text.length) parent.appendChild(document.createTextNode(text.slice(i))); }
private announceWrap(edge: 'top' | 'bottom', i: number) { const s = this.suggestions[i]; if (!s) return; const label = s.label ?? s.value; this.status.textContent = `Wrapped to ${edge}. ${label} (${i + 1} of ${this.suggestions.length}).`; }
private setActive(i: number, scroll = true, announce = true) { const prev = this.listbox.querySelector('[aria-selected="true"]'); if (prev) prev.setAttribute('aria-selected', 'false'); this.activeIndex = i; const next = this.listbox.querySelector<HTMLElement>(`[data-index="${i}"]`); if (!next) return; next.setAttribute('aria-selected', 'true'); this.input.setAttribute('aria-activedescendant', next.id); if (scroll) next.scrollIntoView({ block: 'nearest' }); if (announce) { const s = this.suggestions[i]; if (s) { this.status.textContent = `${s.label ?? s.value} (${i + 1} of ${this.suggestions.length})`; } } }
private select(i: number) { const s = this.suggestions[i]; if (!s) return; this.input.value = s.value; this.input.dispatchEvent(new Event('change', { bubbles: true })); this.dispatchEvent(new CustomEvent<Suggestion>('autocomplete-select', { detail: s, bubbles: true })); this.close(); this.input.focus(); }
private close() { this.popoverEl.hidden = true; this.listbox.replaceChildren(); this.listbox.hidden = false; this.errorMsg.hidden = true; this.input.setAttribute('aria-expanded', 'false'); this.input.removeAttribute('aria-activedescendant'); this.input.removeAttribute('aria-busy'); this.suggestions = []; this.activeIndex = -1; this.status.textContent = ''; this.toggleHint(this.input.value.length === 0); } }
if (!customElements.get('cmb-combobox')) { customElements.define('cmb-combobox', Combobox); }</script>
<style is:global> /* Scoped via the `cmb-` prefix because options are rendered by JS at runtime and don't receive Astro's per-component data attribute. */ .cmb { position: relative; display: block; }
.cmb-input-wrap { position: relative; display: block; }
.cmb-leading { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; color: var(--foreground-muted); pointer-events: none; } .cmb-leading:empty { display: none; } .cmb-input-wrap:has(.cmb-leading:not(:empty)) .cmb-input { padding-left: 36px; }
/* Popover surface */ .cmb-popover { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: var(--z-popover-raised); padding: 4px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--elevation-high); overflow: hidden; transform-origin: top center; animation: cmb-pop 120ms cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes cmb-pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
/* Indeterminate progress strip while fetching (only when popover is rendered) */ .cmb-popover::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient( 90deg, transparent 0%, var(--primary) 50%, transparent 100% ); background-size: 40% 100%; background-repeat: no-repeat; background-position: -40% 0; opacity: 0; pointer-events: none; }
.cmb:has(.cmb-input[aria-busy='true']) .cmb-popover::before { opacity: 1; animation: cmb-progress 1.1s linear infinite; }
@keyframes cmb-progress { from { background-position: -40% 0; } to { background-position: 140% 0; } }
/* Listbox */ .cmb-listbox { list-style: none; margin: 0; padding: 0; max-height: 280px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.cmb-listbox::-webkit-scrollbar { width: 10px; } .cmb-listbox::-webkit-scrollbar-track { background: transparent; } .cmb-listbox::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 3px solid var(--card); } .cmb-listbox::-webkit-scrollbar-thumb:hover { background: var(--foreground-muted); }
/* Option */ .cmb-option { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-md); padding: var(--space-base) var(--space-md); border-radius: var(--radius-sm); color: var(--foreground); font-size: var(--text-sm); line-height: var(--leading-tight); cursor: pointer; user-select: none; transition: background-color 90ms ease, color 90ms ease; }
.cmb-option + .cmb-option { margin-top: 2px; }
/* Active descendant — also functions as hover state on desktop */ .cmb-option[aria-selected='true'] { background: var(--primary-muted); color: var(--primary); }
/* Disabled "No matches" state */ .cmb-option--disabled { display: block; padding: var(--space-base) var(--space-md); color: var(--foreground-muted); font-style: italic; text-align: center; cursor: default; } .cmb-option--disabled::before { content: none; }
/* Avatar */ .cmb-option-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; background: var(--primary-muted); box-shadow: var(--elevation-low); }
/* Text column */ .cmb-option-text { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; min-width: 0; }
.cmb-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); font-weight: 700; letter-spacing: -0.005em; line-height: 1.25; }
/* Substring match within the label. Visual-only; <mark> isn't reliably announced. */ .cmb-match { background: transparent; color: inherit; font-weight: 800; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; }
.cmb-option-sub { color: var(--foreground-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[aria-selected='true'] .cmb-option-sub { color: var(--primary); opacity: 0.85; }
/* Base style for non-option content shown inside the popover (e.g. the error notice). */ .cmb-message { margin: 0; padding: var(--space-sm) var(--space-md); color: var(--foreground-muted); font-size: var(--text-sm); line-height: var(--leading-normal); }
.cmb-message--error { display: flex; align-items: flex-start; gap: var(--space-sm); color: var(--error); }
/* `display: flex` above would otherwise win over the [hidden] UA rule. */ .cmb-message[hidden], .cmb-listbox[hidden] { display: none; }
.cmb-message--error::before { content: '!'; flex: 0 0 auto; display: inline-grid; place-items: center; width: 18px; height: 18px; margin-top: 1px; border-radius: 50%; background: var(--error); color: var(--error-foreground); font-size: var(--text-xs); font-weight: 700; line-height: 1; }
@media (prefers-reduced-motion: reduce) { .cmb-popover { animation: none; } .cmb:has(.cmb-input[aria-busy='true']) .cmb-popover::before { animation: none; background: var(--primary); background-size: 100% 100%; opacity: 0.6; } .cmb-option { transition: none; } }
@media (forced-colors: active) { .cmb-popover { border: 1px solid CanvasText; }
.cmb-popover::before { background: Highlight !important; }
.cmb-option { forced-color-adjust: none; }
.cmb-option[aria-selected='true'] { background: Highlight; color: HighlightText; outline: 2px solid HighlightText; outline-offset: -2px; }
[aria-selected='true'] .cmb-option-sub { color: HighlightText; opacity: 1; }
.cmb-option-avatar { box-shadow: 0 0 0 1px CanvasText; }
.cmb-message--error { color: LinkText; } .cmb-message--error::before { background: LinkText; color: Canvas; } }</style>