search-field

<tosi-search-field> is a search box that suggests refinements as you type, in the spirit of Finder's search field and a search engine's power-search operators. Type doc and it offers the text "doc" or Word documents; pick one and it becomes a tag in the query, consuming what you typed. You can keep typing, so a query is a list of tags plus whatever text is left in the field.

What gets suggested is up to you: hints is a list of rules, each a function from the typed text (and the tags already chosen) to zero or more hints. Every rule runs on each keystroke and their hints are listed in rule order.

import { tosiSearchField } from 'tosijs-ui'

const kinds = [
  { caption: 'Word documents', match: 'documents', ext: 'docx', background: '#1565c0' },
  { caption: 'Images', match: 'images pictures photos', ext: 'png', background: '#2e7d32' },
  { caption: 'PDFs', match: 'pdf portable', ext: 'pdf', background: '#c62828' },
]

const field = tosiSearchField({
  placeholder: 'Search files',
  hints: [
    // always offer the text itself
    (text) => ({
      caption: `Name contains “${text}”`,
      tag: { caption: `“${text}”`, kind: 'text', value: text },
    }),
    // offer a kind when the text starts one of its words
    (text) =>
      kinds
        .filter((k) => k.match.split(' ').some((w) => w.startsWith(text.toLowerCase())))
        .map((k) => ({
          caption: `Kind is ${k.caption}`,
          tag: { caption: k.caption, kind: 'type', value: k.ext, background: k.background },
        })),
    // "today" / "yesterday" become a date tag
    (text) =>
      ['today', 'yesterday']
        .filter((d) => d.startsWith(text.toLowerCase()))
        .map((d) => ({
          caption: `Modified ${d}`,
          tag: { caption: `Modified ${d}`, kind: 'date', value: d, background: '#6a1b9a' },
        })),
  ],
})

const output = document.createElement('pre')
const show = () => {
  output.textContent = JSON.stringify(field.value, null, 2)
}
field.addEventListener('change', show)
field.addEventListener('input', show)
field.addEventListener('action', () => {
  output.textContent = 'search! ' + JSON.stringify(field.value)
})
show()
preview.append(field, output)
.preview tosi-search-field {
  margin-right: var(--touch-size, 44px);
}
test('typing discloses hints; picking one makes a tag and consumes the text', async () => {
  const field = preview.querySelector('tosi-search-field')
  await field.whenHydrated
  let changes = 0
  field.addEventListener('change', () => (changes += 1))
  field.typeText('doc')
  expect(field.hintCount).toBe(2)
  const input = field.querySelector('input')
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
  expect(field.value.tags.map((t) => t.value)).toEqual(['docx'])
  expect(field.value.text).toBe('')
  expect(field.hintCount).toBe(0)
  expect(changes).toBe(1)

  field.typeText('tod')
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
  expect(field.value.tags.map((t) => t.kind)).toEqual(['type', 'date'])

  // Backspace in an empty field removes the last tag
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
  expect(field.value.tags.length).toBe(1)
  expect(changes).toBe(3)
  field.value = { tags: [], text: '' }
})
test('the clear button clears tags and text', async () => {
  const field = preview.querySelector('tosi-search-field')
  await field.whenHydrated
  field.value = { tags: [{ caption: 'PDFs', kind: 'type', value: 'pdf' }], text: 'q' }
  field.render()
  const clear = field.querySelector('[part="clear"]')
  expect(clear.hidden).toBe(false)
  clear.click()
  expect(field.value).toEqual({ tags: [], text: '' })
})
test('Enter with no hint highlighted is an action, not a tag', async () => {
  const field = preview.querySelector('tosi-search-field')
  await field.whenHydrated
  let actions = 0
  field.addEventListener('action', () => (actions += 1))
  field.typeText('report')
  field
    .querySelector('input')
    .dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
  expect(actions).toBe(1)
  expect(field.value).toEqual({ tags: [], text: 'report' })
  field.value = { tags: [], text: '' }
})

As a filter

Give each tag a test(item), and a textTest for the text left in the field, and the field's filter is ready to hand to a <tosi-table>. Here it filters 3,655 emoji: type food, flag or cat, and pick a category or subcategory, or just keep typing to match names.

(Both examples leave room at the field's right end, where the example's own toolbar floats; otherwise it covers the clear button.)

import { tosiSearchField, tosiTable } from 'tosijs-ui'
import { div } from 'tosijs'.elements

const emojiRequest = await fetch('https://raw.githubusercontent.com/tonioloewald/emoji-metadata/master/emoji-metadata.json')
const emojiData = await emojiRequest.json()

const unique = (prop) => [...new Set(emojiData.map((emoji) => emoji[prop]))]
const categories = unique('category')
const subcategories = unique('subcategory')
// does `text` start any word of `label`? ("dri" matches "Food & Drink")
const startsAWord = (label, text) =>
  label.toLowerCase().split(/[^a-z0-9]+/).some((word) => word.startsWith(text.toLowerCase()))

const field = tosiSearchField({
  placeholder: 'Filter emoji',
  textTest: (emoji, text) => emoji.name.includes(text.toLowerCase()),
  hints: [
    (text) => ({
      caption: `Name contains “${text}”`,
      tag: { caption: `“${text}”`, test: (emoji) => emoji.name.includes(text.toLowerCase()) },
    }),
    (text) =>
      categories
        .filter((category) => startsAWord(category, text))
        .map((category) => ({
          caption: `Category: ${category}`,
          tag: { caption: category, background: '#1565c0', test: (emoji) => emoji.category === category },
        })),
    (text) =>
      subcategories
        .filter((subcategory) => startsAWord(subcategory, text))
        .slice(0, 6)
        .map((subcategory) => ({
          caption: `Subcategory: ${subcategory}`,
          tag: { caption: subcategory, background: '#2e7d32', test: (emoji) => emoji.subcategory === subcategory },
        })),
  ],
})

const table = tosiTable({
  array: emojiData,
  rowHeight: 40,
  columns: [
    { prop: 'chars', name: 'emoji', width: 80, align: 'center', sort: false },
    { prop: 'name', width: 300 },
    { prop: 'category', width: 150 },
    { prop: 'subcategory', width: 150 },
  ],
})

const applyFilter = () => {
  table.filter = field.filter
}
field.addEventListener('change', applyFilter)
field.addEventListener('input', applyFilter)

preview.append(div({ class: 'emoji-search' }, field, table))
.preview .emoji-search {
  display: flex;
  flex-direction: column;
  gap: var(--spacing, 8px);
  height: 100%;
}

.preview .emoji-search tosi-search-field {
  margin-right: var(--touch-size, 44px);
}

.preview .emoji-search tosi-table {
  flex: 1 1 auto;
  min-height: 0;
}
test('the field filters the table', async () => {
  const field = preview.querySelector('tosi-search-field')
  const table = preview.querySelector('.emoji-search tosi-table')
  await field.whenHydrated
  const all = table.array.length
  field.typeText('food')
  const input = field.querySelector('input')
  const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }))
  key('ArrowDown')
  key('ArrowDown')
  key('Enter')
  expect(field.value.tags.map((t) => t.caption)).toEqual(['Food & Drink'])
  const food = field.filter(table.array)
  expect(food.length).toBeGreaterThan(0)
  expect(food.length).toBeLessThan(all)
  expect(food.every((emoji) => emoji.category === 'Food & Drink')).toBe(true)
  field.typeText('apple')
  const apples = field.filter(table.array)
  expect(apples.length).toBeGreaterThan(0)
  expect(apples.every((emoji) => emoji.name.includes('apple'))).toBe(true)
  field.clear()
  expect(field.filter(table.array).length).toBe(all)
})

Properties

hints: SearchHintRule[]

The rules. A rule is (text, tags) => SearchHint | SearchHint[] | null | undefined, called with the trimmed text whenever it changes (rules are not called for empty text). A SearchHint is { caption, tag }: caption is the row in the list, tag is what picking it adds to the query.

value: { tags: SearchTag[], text: string }

The query. A SearchTag is { caption, background?, color?, …anything }: the chip shows caption, coloured like <tosi-tag-list> chips (given only a background, the text is black or white, whichever contrasts more), and everything else is yours to interpret — kind, value, a field name, an operator. A tag may carry a test(item) predicate, which is what filter uses.

filter: (items) => items

The query as an array filter, for filtering a list or a <tosi-table>: an item passes when every tag's test passes and, if there is text left in the field, textTest(item, text). Tags without a test, and text without a textTest, don't filter anything.

textTest: (item, text) => boolean

How the typed text matches an item, for filter.

placeholder: string = 'search'

Shown while the field has no tags and no text.

disabled: boolean

Events

Keyboard

Focus stays in the text field throughout; the hints are a listbox it controls.

key does
↓ / ↑ highlight the next / previous hint (↑ from the first returns to the text)
Enter pick the highlighted hint, or fire action if none is highlighted
Escape close the hints; pressed again, clear the text
Backspace in an empty field, remove the last tag

Methods

clear()

Remove every tag and the text, as the field's ✕ button does. The button appears whenever there is something to clear.

typeText(text: string)

Put text in the field as if it had been typed, and update the hints. For tests and scripted demos.

hintCount: number

How many hints are currently listed (0 when the list is closed).