palette

<tosi-palette> looks like a small search field. Click it (or press its shortcut) and it opens a centred modal: a query field on top, ranked results below, and the keys to drive it along the bottom. It is the "command palette" pattern, for search, navigation, or anything else you can turn a query into a list of things to pick.

Results come from sources: functions from the query to a list of results, or a promise of one. A slow source shows a spinner and never holds up the fast ones; one that takes longer than timeout milliseconds (10 seconds by default) is abandoned and says so. Each keystroke aborts the previous search's sources through the signal they are given.

Rows can be different kinds of thing: a result's kind picks a renderer for it, and any kind without one gets the default row (an icon, the title with the query highlighted, a one-line description and a path). Results are ranked by score and grouped by group; set rank to order them yourself. With nothing typed, quickLinks are listed instead.

Choosing a result fires action (with the result as event.detail) and calls the result's own action, then closes the palette, unless that action returns false.

import { tosiPalette, postNotification } from 'tosijs-ui'

const fruit = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Grape', 'Mango', 'Orange']

const palette = tosiPalette({
  placeholder: 'Search fruit and colours',
  shortcut: 'mod+k',
  quickLinks: [
    { title: 'Apple', description: 'A quick link, shown before you type', icon: 'star' },
    { title: 'Cherry', icon: 'star' },
  ],
  sources: [
    // a plain function: answers at once
    (query) =>
      fruit
        .filter((name) => name.toLowerCase().includes(query.toLowerCase()))
        .map((name) => ({
          title: name,
          description: `${name} is a fruit`,
          path: `fruit/${name.toLowerCase()}`,
          icon: 'heart',
          group: 'Fruit',
          score: name.toLowerCase().startsWith(query.toLowerCase()) ? 2 : 1,
        })),
    // an async function: the palette shows a spinner until it answers
    async (query, { signal }) => {
      await new Promise((resolve) => setTimeout(resolve, 600))
      if (signal.aborted) return []
      return ['red', 'green', 'blue']
        .filter((colour) => colour.includes(query.toLowerCase()))
        .map((colour) => ({
          title: colour,
          kind: 'colour',
          group: 'Colours (slow source)',
          colour,
        }))
    },
  ],
  // a renderer for one kind of row
  renderers: {
    colour: (result) => {
      const row = document.createElement('div')
      row.style.cssText = 'display:flex;align-items:center;gap:8px'
      const swatch = document.createElement('span')
      swatch.style.cssText = `width:16px;height:16px;border-radius:4px;background:${result.colour}`
      row.append(swatch, result.title)
      return row
    },
  },
})
palette.addEventListener('action', (event) => {
  postNotification({ message: `You chose ${event.detail.title}`, duration: 2 })
})
preview.append(palette)
test('opens a centred modal, ranks results and closes on a choice', async () => {
  const palette = preview.querySelector('tosi-palette')
  palette.open()
  const dialog = palette.querySelector('dialog')
  expect(dialog.open).toBe(true)
  palette.search('ap')
  const titles = [...palette.querySelectorAll('[role="option"]')].map((o) => o.textContent)
  expect(titles[0]).toContain('Apple')
  expect(titles.some((t) => t.includes('Grape'))).toBe(true)
  palette.close()
  expect(dialog.open).toBe(false)
})

Properties

open(), close() and search(query) drive it from code, so any button can open it. Pressing Enter with no result chosen fires action with { query } as its detail, so a multiline prompt can be submitted.

interface PaletteResult {
  title: string
  description?: string
  path?: string
  icon?: string | Element // an icon name, or an element
  kind?: string           // picks a renderer
  group?: string
  score?: number          // higher first
  action?: (result: PaletteResult, palette: TosiPalette) => void | boolean
}