Auto-complete Demo

This page demo how to implement server-filtered auto-complete input field.

The server holds a list of 106,165 dictionary words.

When the user input some text, the server filter the list by running: words.filter(word => word.includes(input))

To avoid freezing the UI. When there exists more than 20 matches, the server will only send top 20 matches (with lowest edit distance) to the browser.

It all happens on the server so the client does not need to download additional libraries.

Demo

Total number of matches: (Input at least one character to get auto-complete list)

Source Code of auto-complete-demo.tsx
(import statements omitted for simplicity, click to expand)
import { o } from '../jsx/jsx.js'
import type { attrs } from '../jsx/types'
import { existsSync, readdirSync, readFileSync } from 'fs'
import { join } from 'path'
import { Update } from '../components/update.js'
import { distance } from 'fastest-levenshtein'
import SourceCode from '../components/source-code.js'
import { getContextSearchParams, Context } from '../context.js'
import { Routes } from '../routes.js'
import { Locale, Title } from '../components/locale.js'
const wordSet = new Set<string>()
const dictDir = '/usr/share/dict'
if (existsSync(dictDir)) {
  loadWordsFromDir()
}
if (wordSet.size === 0) {
  loadWordsFromPackage()
}

function loadWordsFromDir() {
  readdirSync(dictDir).forEach(file => {
    file = join(dictDir, file)
    readFileSync(file)
      .toString()
      .split('\n')
      .forEach(line => {
        line = line.trim()
        if (line) {
          wordSet.add(line)
        }
      })
  })
}

function loadWordsFromPackage() {
  readFileSync('package.json')
    .toString()
    .match(/"(.*?)"/g)
    ?.map(s => s.slice(1, -1)) // remove double quotes
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    .filter(s => !(+s.at(-1)! + 1)) // skip package versions
    .filter(s => !s.includes('.com')) // skip email address
    .forEach(s => wordSet.add(s))
}

const words = Array.from(wordSet)

const TopN = 20

let content = (
  <div id="auto-complete-demo">
    <h1>Auto-complete Demo</h1>
    <p>
      This page demo how to implement server-filtered auto-complete input field.
    </p>
    <p>
      The server holds a list of {words.length.toLocaleString()} dictionary
      words.
    </p>
    <p>
      When the user input some text, the server filter the list by running:{' '}
      <code>{`words.filter(word => word.includes(input))`}</code>
    </p>
    <p>
      To avoid freezing the UI. When there exists more than {TopN} matches, the
      server will only send top {TopN} matches (with lowest{' '}
      <a href="https://www.npmjs.com/package/fastest-levenshtein">
        edit distance
      </a>
      ) to the browser.
    </p>
    <p>
      It all happens on the server so the client does not need to download
      additional libraries.
    </p>
    <fieldset style="display: inline-block">
      <legend>Demo</legend>
      <p>
        Total number of matches:{' '}
        <span id="num-matches">
          (Input at least one character to get auto-complete list)
        </span>
      </p>
      <label for="input-package">Try to input an English word: </label>
      <input
        id="input-package"
        list="package-list"
        oninput="emit('/auto-complete?input='+this.value)"
        placeholder="e.g. apple"
      />
      <datalist id="package-list"></datalist>
    </fieldset>
    <SourceCode page="auto-complete-demo.tsx" />
  </div>
)

function renderUpdate(input: string) {
  const allMatches = words.filter(word => word.includes(input))
  const topMatches = allMatches
    .map(word => [word, distance(word, input)] as const)
    .sort((a, b) => a[1] - b[1])
    .slice(0, TopN)
  return (
    <Update
      to="/auto-complete"
      message={[
        'batch',
        [
          [
            'update-in',
            '#auto-complete-demo #package-list',
            [topMatches.map(([word]) => <option value={word} />)],
          ],
          [
            'update-in',
            '#auto-complete-demo #num-matches',
            allMatches.length.toLocaleString(),
          ],
        ],
      ]}
    />
  )
}

export function AutoCompleteDemo(_attrs: attrs, context: Context) {
  if (context.type === 'ws') {
    const input = getContextSearchParams(context)?.get('input')
    if (input) {
      return renderUpdate(input)
    }
  }
  return content
}

let t = <Locale en="Auto Complete" zh_hk="自動輸入框" zh_cn="自动输入框" />

let routes = {
  '/auto-complete': {
    menuText: t,
    title: <Title t={t} />,
    description: (
      <Locale
        en="Server-driven auto-complete input box demo"
        zh_hk="伺服器驅動的自動完成輸入框範例"
        zh_cn="服务器驱动的自动完成输入框示例"
      />
    ),
    node: <AutoCompleteDemo />,
  },
} satisfies Routes

export default { routes }