import type { HaloDocument, SearchOption, SearchResult, } from '@halo-dev/api-client'; import { msg } from '@lit/localize'; import resetStyles from '@unocss/reset/tailwind.css?inline'; import { type DebouncedFunction, debounce } from 'es-toolkit'; import { uniqBy } from 'es-toolkit/compat'; import { css, html, LitElement, unsafeCSS } from 'lit'; import { property, state } from 'lit/decorators.js'; import { createRef, type Ref, ref } from 'lit/directives/ref.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { HISTORY_KEY, MAX_HISTORY_ITEMS } from './constants'; import baseStyles from './styles/base'; export class SearchForm extends LitElement { @property({ type: String }) baseUrl = ''; @property({ type: Object }) options = {}; @state() private keyword = ''; @state() private searchResult?: SearchResult; @state() private loading: boolean = false; @state() private selectedIndex = 0; @state() private historyHits: HaloDocument[] = []; inputRef: Ref = createRef(); constructor() { super(); this.addEventListener('keydown', this.handleKeydown); this.historyHits = JSON.parse( localStorage.getItem(HISTORY_KEY) || '[]' ) as HaloDocument[]; setTimeout(() => { this.inputRef.value?.focus(); }, 0); } override render() { return html`
e.preventDefault()} > ${ this.keyword ? html` ` : '' }
${this.keyword ? this.renderItems() : this.renderHistoryItems()}
${[ { text: html`${msg('Select')}`, kbdIcons: ['i-lucide-arrow-up', 'i-lucide-arrow-down'], }, { text: html`${msg('Confirm')}`, kbdIcons: ['i-lucide-corner-down-left'], }, { text: html`${msg('Close')}`, kbdIcons: ['i-mdi-keyboard-esc'], }, ].map( (item) => html`
${item.kbdIcons.map( (icon) => html` ` )} ${item.text}
` )}
`; } handleClearInput() { this.keyword = ''; this.searchResult = undefined; if (this.inputRef.value) { this.inputRef.value.value = ''; this.inputRef.value.focus(); } } renderItems() { return html`
${!this.searchResult?.hits?.length ? this.renderEmpty() : ''}
    ${this.searchResult?.hits?.map((hit, index) => this.renderListItem(hit, index, 'i-lucide-file-text') )}
`; } renderEmpty() { return html`
${msg('No search results')}
`; } renderHistoryItems() { return html`
${ this.historyHits.length ? html`

${msg('Recent')}

${msg('Clear')}
    ${this.historyHits?.map((hit, index) => this.renderListItem(hit, index, 'i-lucide-history') )}
` : this.renderEmpty() }
`; } renderListItem(hit: HaloDocument, index: number, listIcon: string) { return html`
  • { this.selectedIndex = index; }} data-index=${index} >

    ${unsafeHTML(hit.title)}

    ${ hit.description ? html`

    ${unsafeHTML(hit.description)}

    ` : '' }
  • `; } onInput(e: InputEvent) { const input = e.target as HTMLInputElement; const value = input.value; this.keyword = value || ''; this.selectedIndex = 0; if (value === '') { this.searchResult = undefined; return; } this.loading = true; this.fetchHits(value); } private handleClearHistory() { localStorage.removeItem(HISTORY_KEY); this.historyHits = []; } fetchHits: DebouncedFunction<(keyword: string) => Promise> = debounce( async (keyword: string) => { const searchOptions: SearchOption = { ...this.options, highlightPostTag: '', highlightPreTag: '', keyword, limit: 20, }; const response = await fetch( `/apis/api.halo.run/v1alpha1/indices/-/search`, { headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(searchOptions), method: 'post', } ); const data = (await response.json()) as SearchResult; this.searchResult = data; this.loading = false; }, 300 ); handleOpenLink(hit: HaloDocument, event?: MouseEvent) { const updatedHistory = uniqBy([hit, ...this.historyHits], 'id').slice( 0, MAX_HISTORY_ITEMS ); localStorage.setItem(HISTORY_KEY, JSON.stringify(updatedHistory)); this.historyHits = updatedHistory; if (event && this.shouldUseNativeNavigation(event)) { return; } event?.preventDefault(); this.navigate(hit.permalink); queueMicrotask(() => { this.dispatchEvent( new CustomEvent('search-widget:navigate', { bubbles: true, composed: true, }) ); }); } private shouldUseNativeNavigation(event: MouseEvent) { return ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ); } private navigate(url: string) { const win = window as Window & { pjax?: { loadUrl(url: string): void; }; }; if (win.pjax) { win.pjax.loadUrl(url); return; } window.location.href = url; } handleKeydown = (e: KeyboardEvent) => { const { key, ctrlKey } = e; const hits = this.keyword ? this.searchResult?.hits : this.historyHits; if (!hits) { return; } if (key === 'ArrowUp' || (key === 'k' && ctrlKey)) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.handleScrollIntoSelected(); e.preventDefault(); } if (key === 'ArrowDown' || (key === 'j' && ctrlKey)) { this.selectedIndex = Math.min(hits.length || 0, this.selectedIndex + 1); this.handleScrollIntoSelected(); e.preventDefault(); } if (key === 'Enter') { const hit = hits[this.selectedIndex]; if (hit) { this.handleOpenLink(hit); e.preventDefault(); } } }; private handleScrollIntoSelected() { const selectedElement = this.shadowRoot?.querySelector( `[data-index="${this.selectedIndex}"]` ); if (selectedElement) { selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center', }); } } static override styles = [ unsafeCSS(resetStyles), baseStyles, css` @unocss-placeholder; `, ]; } customElements.get('search-form') || customElements.define('search-form', SearchForm); declare global { interface HTMLElementTagNameMap { 'search-form': SearchForm; } }