Frontile

Command

A command palette: a search field over a ranked, optionally grouped list of commands or destinations. Results are ordered by how well they match, so the closest one is always first.

Import

import { Command, CommandDialog } from 'frontile';

Usage

The usual form is a dialog, opened from a button or from a keyboard shortcut anywhere on the page. mod is Cmd on Apple platforms and Ctrl elsewhere — press it now. Pass an array to accept several, e.g. @shortcut={{array "/" "mod+k"}}.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { CommandDialog, Button, Kbd } from 'frontile';
import {
  UserIcon,
  StarIcon,
  SearchIcon,
  CodeIcon
} from 'site/components/icons';

const commands = [
  {
    key: 'calendar',
    label: 'Calendar',
    section: 'Suggestions',
    Icon: StarIcon
  },
  {
    key: 'search-emoji',
    label: 'Search Emoji',
    section: 'Suggestions',
    Icon: SearchIcon
  },
  {
    key: 'calculator',
    label: 'Calculator',
    section: 'Suggestions',
    Icon: CodeIcon
  },
  {
    key: 'profile',
    label: 'Profile',
    section: 'Settings',
    Icon: UserIcon,
    shortcut: 'mod+p'
  },
  {
    key: 'billing',
    label: 'Billing',
    section: 'Settings',
    Icon: CodeIcon,
    shortcut: 'mod+b'
  },
  {
    key: 'settings',
    label: 'Settings',
    section: 'Settings',
    Icon: CodeIcon,
    shortcut: 'mod+s'
  }
];

export default class CommandDialogExample extends Component {
  @tracked isOpen = false;
  @tracked lastSelected;

  open = () => (this.isOpen = true);
  close = () => (this.isOpen = false);

  select = (key) => {
    this.lastSelected = key;
    this.isOpen = false;
  };

  <template>
    <div class='demo-stack items-center'>
      <div class='flex items-center gap-4'>
        <Button @variant='outline' {{on 'click' this.open}}>
          Open palette
          <Kbd @keys='mod+k' @size='sm' @variant='outline' @class='ml-2' />
        </Button>
        {{#if this.lastSelected}}
          <span class='font-body text-body-sm text-neutral'>Selected:
            {{this.lastSelected}}</span>
        {{/if}}
      </div>

      <CommandDialog
        @isOpen={{this.isOpen}}
        @onOpen={{this.open}}
        @onClose={{this.close}}
        @onSelect={{this.select}}
        @shortcut='mod+k'
        @items={{commands}}
        @groupBy='section'
        @label='Search commands'
        @placeholder='Type a command or search…'
        as |c|
      >
        <c.Input />
        <c.List>
          <:item as |ctx|>
            <ctx.Item @key={{ctx.key}} @shortcut={{ctx.item.shortcut}}>
              <:start><ctx.item.Icon /></:start>
              <:default>{{ctx.label}}</:default>
            </ctx.Item>
          </:item>
          <:empty>No results for "{{c.query}}"</:empty>
        </c.List>
        <c.Footer />
      </CommandDialog>
    </div>
  </template>
}

The dialog opens with a short scale-and-rise and honors prefers-reduced-motion by keeping the fade but dropping the movement. An unmodified shortcut such as / is ignored while the user is typing in a field, so it still types a slash in a text input.

Anatomy

Command yields the palette's parts plus its current state, so you compose the layout rather than configuring it. The same parts are yielded by CommandDialog.

  • Profile
  • Billing
Arrow up Arrow down Navigate Enter Select Esc Close
import { Command } from 'frontile';

const commands = [
  { key: 'profile', label: 'Profile' },
  { key: 'billing', label: 'Billing' }
];

<template>
  <div class='demo-stack'>
    <Command @items={{commands}} @isBordered={{true}} as |c|>
      {{! the search field — carries the combobox semantics }}
      <c.Input @placeholder='Search…' />

      {{! the results — ranked, grouped, keyboard navigable }}
      <c.List>
        <:item as |ctx|>
          {{! ctx yields the item, its key, its label, and a bound Item component }}
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
        <:empty>Nothing matched.</:empty>
        <:loading>Searching…</:loading>
        {{! async only: shown before anything has been typed }}
        <:prompt>Start typing to search.</:prompt>
      </c.List>

      {{! keyboard hints; c.query, c.resultCount and c.isLoading are yielded too }}
      <c.Footer />
    </Command>
  </div>
</template>

Inline

Command on its own renders in place — for a page-level search, a sidebar, or inside a custom overlay. @isBordered draws its own surface.

  • Profile
  • Billing
  • Settings
  • Calendar
  • Search Emoji
import { Command } from 'frontile';

const commands = [
  { key: 'profile', label: 'Profile' },
  { key: 'billing', label: 'Billing' },
  { key: 'settings', label: 'Settings' },
  { key: 'calendar', label: 'Calendar' },
  { key: 'search-emoji', label: 'Search Emoji' }
];

<template>
  <div class='demo-stack'>
    <Command
      @items={{commands}}
      @isBordered={{true}}
      @placeholder='Type a command or search…'
      as |c|
    >
      <c.Input />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

Ranking

The default filter ranks by relevance rather than filtering in place, which is what keeps an exact match from being buried under a longer name that merely contains the query. Type button below: Button stays on top even though ButtonGroup comes first in the array.

It also matches acronyms — try bg for ButtonGroup, or tb for ToggleButton.

  • ButtonGroup
  • ToggleButton
  • CloseButton
  • Button
  • CheckboxGroup
import { Command } from 'frontile';

const components = [
  { key: 'button-group', label: 'ButtonGroup' },
  { key: 'toggle-button', label: 'ToggleButton' },
  { key: 'close-button', label: 'CloseButton' },
  { key: 'button', label: 'Button' },
  { key: 'checkbox-group', label: 'CheckboxGroup' }
];

<template>
  <div class='demo-stack'>
    <Command
      @items={{components}}
      @isBordered={{true}}
      @placeholder="Try 'button' or 'bg'…"
      as |c|
    >
      <c.Input />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

By default only the item's label is searched. @searchFields searches more than that — a category, keywords — with the first field primary and the rest down-weighted and combined by max, so a weak hit on a category never outranks a strong hit on the label.

  • Button Buttons
  • Chip Buttons
  • Modal Overlays
import { Command } from 'frontile';

const components = [
  { key: 'button', label: 'Button', section: 'Buttons' },
  { key: 'chip', label: 'Chip', section: 'Buttons' },
  { key: 'modal', label: 'Modal', section: 'Overlays' }
];

const searchFields = (item) => [item.label, item.section];

<template>
  <div class='demo-stack'>
    <Command
      @items={{components}}
      @searchFields={{searchFields}}
      @isBordered={{true}}
      @placeholder="Try 'overlays'…"
      as |c|
    >
      <c.Input />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}} @description={{ctx.item.section}}>
            {{ctx.label}}
          </ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

Pass @filter to score items yourself. Return a number to rank — higher first, 0 for no match — or a boolean to filter without reordering.

  • Button
  • Checkbox
  • Table
import { Command } from 'frontile';
import { createFuzzyFilter } from 'frontile/utils/filter';

// A lower threshold trades precision for recall: this matches `btn` to Button.
const looseFilter = createFuzzyFilter({ threshold: 0 });

const components = [
  { key: 'button', label: 'Button' },
  { key: 'checkbox', label: 'Checkbox' },
  { key: 'table', label: 'Table' }
];

<template>
  <div class='demo-stack'>
    <Command
      @items={{components}}
      @filter={{looseFilter}}
      @isBordered={{true}}
      @placeholder="Try 'btn'…"
      as |c|
    >
      <c.Input />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

Grouping

@groupBy sections the results under headings. A group whose items all filter out disappears entirely — heading and separator with it — because sections are built from the ranked results rather than declared as markup. Type cal below and watch Settings go.

By default groups are ordered by their best-scoring member, so the closest match is always on top. @groups pins the groups you name to the top, in that order — anything not named still renders after them, so pinning a "Recent" section cannot hide search results.

  • Suggestions
    • Calendar
    • Search Emoji
    • Calculator
  • Settings
    • Profile
    • Billing
    • Settings
import { Command } from 'frontile';
import { array } from '@ember/helper';

const commands = [
  { key: 'calendar', label: 'Calendar', section: 'Suggestions' },
  { key: 'search-emoji', label: 'Search Emoji', section: 'Suggestions' },
  { key: 'calculator', label: 'Calculator', section: 'Suggestions' },
  { key: 'profile', label: 'Profile', section: 'Settings' },
  { key: 'billing', label: 'Billing', section: 'Settings' },
  { key: 'settings', label: 'Settings', section: 'Settings' }
];

<template>
  <div class='demo-stack'>
    <Command
      @items={{commands}}
      @groupBy='section'
      @groups={{array 'Suggestions' 'Settings'}}
      @disabledKeys={{array 'calculator'}}
      @isBordered={{true}}
      @placeholder='Type a command or search…'
      as |c|
    >
      <c.Input />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

Rows

Rows are Listbox options, so they take the same :start / :end blocks, @shortcut and @description. Icons in :start are sized and muted for you.

  • Profile Ctrl P
  • Favorites Ctrl F
  • Search Ctrl K
import { Command } from 'frontile';
import { UserIcon, StarIcon, SearchIcon } from 'site/components/icons';

const commands = [
  { key: 'profile', label: 'Profile', shortcut: 'mod+p', Icon: UserIcon },
  { key: 'favorites', label: 'Favorites', shortcut: 'mod+f', Icon: StarIcon },
  { key: 'search', label: 'Search', shortcut: 'mod+k', Icon: SearchIcon }
];

<template>
  <div class='demo-stack'>
    <Command @items={{commands}} @isBordered={{true}} as |c|>
      <c.Input @placeholder='Search…' />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}} @shortcut={{ctx.item.shortcut}}>
            <:start><ctx.item.Icon /></:start>
            <:default>{{ctx.label}}</:default>
          </ctx.Item>
        </:item>
      </c.List>
    </Command>
  </div>
</template>

c.Footer renders the palette's keyboard hints. Give it a block to say something else — it yields Kbd for a keycap and Hint for one hint's layout, so custom hints match the built-in ones without copying any classes.

  • Button
  • Checkbox
Enter Go to page Ctrl C Copy link
import { Command } from 'frontile';

const commands = [
  { key: 'button', label: 'Button' },
  { key: 'checkbox', label: 'Checkbox' }
];

<template>
  <div class='demo-stack'>
    <Command @items={{commands}} @isBordered={{true}} @size='sm' as |c|>
      <c.Input @placeholder='Search components…' />
      <c.List>
        <:item as |ctx|>
          <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
        </:item>
      </c.List>
      <c.Footer as |f|>
        <f.Hint><f.Kbd @keys='enter' /> Go to page</f.Hint>
        <f.Hint><f.Kbd @keys='mod+c' /> Copy link</f.Hint>
      </c.Footer>
    </Command>
  </div>
</template>

Pass @onSearch to fetch results instead of filtering @items. It is debounced, and stale responses are discarded so the latest query always wins — a slow early request can never overwrite a newer one. Built-in filtering is disabled, since the server did the filtering.

Before anything is typed an async palette has nothing to show and nothing to report, so it renders the :prompt block rather than claiming there are no results.

Search for a country…
import Component from '@glimmer/component';
import { Command } from 'frontile';

const ALL = [
  'Argentina',
  'Australia',
  'Austria',
  'Brazil',
  'Canada',
  'Denmark',
  'Finland',
  'France',
  'Germany',
  'Japan',
  'Mexico',
  'Netherlands',
  'New Zealand',
  'Norway',
  'Poland',
  'South Africa',
  'Spain',
  'Sweden'
];

export default class AsyncCommandExample extends Component {
  search = async (query) => {
    // Stand-in for a network request.
    await new Promise((resolve) => setTimeout(resolve, 400));

    if (!query) return [];

    return ALL.filter((name) =>
      name.toLowerCase().includes(query.toLowerCase())
    ).map((name) => ({ key: name.toLowerCase(), label: name }));
  };

  <template>
    <div class='demo-stack'>
      <Command
        @onSearch={{this.search}}
        @isBordered={{true}}
        @placeholder='Search countries…'
        as |c|
      >
        <c.Input />
        <c.List>
          <:item as |ctx|>
            <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
          </:item>
          <:loading>Searching…</:loading>
          <:prompt>Search for a country…</:prompt>
          <:empty>No matches for "{{c.query}}"</:empty>
        </c.List>
      </Command>
    </div>
  </template>
}

To filter externally without @onSearch — against a store you already have, say — use @disableFiltering with @query and @onQueryChange.

Mixing static and remote results

A real palette usually has both: navigation and recents you already hold, plus records that only the server can find. @onSearch alone does not cover this — when it is set, the resolved results replace @items, and local filtering is off, so static entries would disappear as soon as the first response landed.

Instead, own the merge and reuse the library's own scorer, which is exported:

  • Recent
    • Acme Corp Recent
  • Navigation
    • Accounts Navigation
    • Billing Navigation
    • Settings Navigation
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Command } from 'frontile';
import { filterAndRankItems } from 'frontile/utils/filter';
import { array } from '@ember/helper';

const NAVIGATION = [
  { key: 'nav:accounts', label: 'Accounts', section: 'Navigation' },
  { key: 'nav:billing', label: 'Billing', section: 'Navigation' },
  { key: 'nav:settings', label: 'Settings', section: 'Navigation' }
];

const RECENTS = [{ key: 'recent:acme', label: 'Acme Corp', section: 'Recent' }];

// Stand-in for a server that matches fields the client never sees — here, a
// trade name. Try "acme": the second row has no "acme" in its label at all.
const REMOTE = [
  { key: 'acct:1', label: 'Wile E. Coyote Enterprises', section: 'Accounts' },
  { key: 'acct:2', label: 'Acme Anvils LLC', section: 'Accounts' }
];

export default class MixedCommandExample extends Component {
  @tracked query = '';
  @tracked remote = [];
  @tracked isLoading = false;

  updateQuery = async (query) => {
    this.query = query;

    if (!query.trim()) {
      this.remote = [];
      return;
    }

    this.isLoading = true;
    await new Promise((resolve) => setTimeout(resolve, 300));
    this.remote = REMOTE;
    this.isLoading = false;
  };

  // Static entries are ranked with the same scorer the component would use.
  // Remote entries are appended as-is: the server already decided.
  get items() {
    const local =
      filterAndRankItems(
        [...RECENTS, ...NAVIGATION],
        this.query,
        (item) => item.label
      ) ?? [];

    return [...local, ...this.remote];
  }

  <template>
    <div class='demo-stack'>
      <Command
        @items={{this.items}}
        @query={{this.query}}
        @onQueryChange={{this.updateQuery}}
        @isLoading={{this.isLoading}}
        @disableFiltering={{true}}
        @groupBy='section'
        @groups={{array 'Recent' 'Navigation' 'Accounts'}}
        @isBordered={{true}}
        @placeholder="Try 'acme'…"
        as |c|
      >
        <c.Input />
        <c.List>
          <:item as |ctx|>
            <ctx.Item @key={{ctx.key}} @description={{ctx.item.section}}>
              {{ctx.label}}
            </ctx.Item>
          </:item>
          <:empty>No results for "{{c.query}}"</:empty>
        </c.List>
      </Command>
    </div>
  </template>
}

Three things make this work:

  • @disableFiltering stops the component re-filtering your remote results. This is the part that bites: the server often matches on fields the client cannot see, so running the local fuzzy filter over its output silently discards legitimate hits — the Wile E. Coyote Enterprises row above would score 0 against acme and vanish.
  • filterAndRankItems, exported from frontile/utils/filter, ranks the static half with exactly the same scorer, so the two halves feel consistent. It takes the same labelFor/@filter shapes the component does, including an array of fields.
  • @groups keeps the halves in separate, pinned sections, so you never have to decide whether a remote hit should outrank a nav item. Groups with nothing left disappear on their own — type acme and Navigation drops out.

Debounce the fetch and discard stale responses yourself here; that is what @onSearch would otherwise have done for you.

Sizes

@size sets how tall the results area is. The list keeps a minimum height on purpose: without one the palette collapses and re-expands on every keystroke as results narrow, which reads as jitter.

  • Profile
  • Billing
  • Profile
  • Billing
  • Profile
  • Billing
import { Command } from 'frontile';
import { array } from '@ember/helper';

const commands = [
  { key: 'profile', label: 'Profile' },
  { key: 'billing', label: 'Billing' }
];

<template>
  <div class='demo-stack'>
    {{#each (array 'sm' 'md' 'lg') as |size|}}
      <Command @items={{commands}} @size={{size}} @isBordered={{true}} as |c|>
        <c.Input @placeholder='size={{size}}' />
        <c.List>
          <:item as |ctx|>
            <ctx.Item @key={{ctx.key}}>{{ctx.label}}</ctx.Item>
          </:item>
        </c.List>
      </Command>
    {{/each}}
  </div>
</template>

Accessibility

Command implements the ARIA combobox-with-list-autocomplete pattern. The combobox semantics live on the input — not on a wrapper — so focus never leaves the field while the user arrows through results.

Key Behavior
ArrowDown / ArrowUp Move the active option, crossing group boundaries
Home / PageUp Activate the first option
End / PageDown Activate the last option
Enter Select the active option
Escape Close the dialog

Provided for you:

  • role="combobox" and aria-autocomplete="list" on the input, with aria-activedescendant pointing at the active option's id. aria-expanded reflects whether the listbox is actually rendered, and aria-controls is only set while it is — an empty result set replaces the listbox, so neither is left claiming a popup that is not there.
  • A debounced aria-live="polite" region announcing "N results available" / "No results found", so the result count is not silent to a screen reader.
  • role="listbox" on the list and role="option" with aria-selected on each row. Groups render as role="group" labelled by their heading, with the intervening list marked role="none" so the listboxoption ownership chain stays intact.
  • Disabled rows (via @disabledKeys) get aria-disabled and cannot be selected.
  • The dialog traps focus, focuses the input on open, and restores focus on close.

What you must supply:

  • @label — the input's accessible name. Defaults to "Search", which is rarely specific enough when a page has more than one search.
  • Meaningful row text. An icon-only row needs its own accessible name.

The combobox keeps aria-activedescendant synchronized with the active option while keyboard navigation crosses groups and skips disabled rows.

API

Command

Element: HTMLDivElement

A command palette: a search input over a ranked, optionally grouped list.

Composed of the same primitives as Autocomplete — an input driving a Listbox through ListManager — rather than reimplementing keyboard navigation, option roles or active-item tracking.

Arguments

Name Type Default Description
class string -
classes SlotsToClasses<'base' | 'footer' | 'input' | 'kbd' | 'loading' | 'inputWrapper' | 'inputIcon' | 'list' | 'empty' | 'footerHint'> -
disabledKeys Array -
disableFiltering boolean false Render @items as given, without filtering or ranking. Use with @query/@onQueryChange when filtering happens elsewhere.
filter function - Scores or matches an item against the query. Defaults to a relevance filter; see {@link FilterFn}.
groupBy enum - Groups results under headings. Either a property name on the item or a function returning the heading. Items with no group render ungrouped, ahead of any groups.
groups Array -

Pins these groups to the top, in this order. Any group not listed still renders, after them, ordered by its best-scoring member — so pinning a "Recent" section cannot hide search results.

When omitted, every group is ordered by its best-scoring member, so the closest match is always on top.

isBordered boolean - Draw the palette's own surface, for use outside a dialog.
isLoading boolean - Show the loading state regardless of @onSearch.
items Array - The items to search. Ranked and grouped by the component unless @disableFiltering is set or @onSearch is provided.
label string 'Search' Accessible name for the search input.
onQueryChange function -
onSearch function -

Async search. Called (debounced) as the user types; the resolved items are rendered and a loading state shows while pending. Stale responses are discarded, so the latest query always wins. Built-in filtering is disabled, and @items is the list shown before the first search.

The resolved items replace @items — this is for a wholly remote list. To combine static entries (navigation, recents) with remote ones, do not use @onSearch: merge them yourself into @items, set @disableFiltering, and rank the static half with filterAndRankItems from frontile/utils/filter. See "Mixing static and remote results" in the docs.

onSelect function - Called when an item is chosen, by click or by Enter.
placeholder string -
query string - The query text. Pass with @onQueryChange to control it.
searchDebounce number 250 Debounce applied to @onSearch, in milliseconds.
searchFields function -

The text searched for each item. Defaults to its label.

Return several fields to search more than the label — a category, say, or keywords. The first is primary; the rest are down-weighted and combined by max, so a weak hit on a secondary field never outranks a strong hit on the label.

size enum 'md'

Blocks

Name Type Default Description
default * Array -

CommandInput

Element: HTMLInputElement

The palette's search field.

Carries the combobox semantics itself — role, aria-expanded, aria-controls and aria-activedescendant all belong on the input, not on a wrapper, so a screen reader announces the active option while focus never leaves the field.

Arguments

Name Type Default Description
activeDescendant string -
classes SlotsToClasses<'base' | 'footer' | 'input' | 'kbd' | 'loading' | 'inputWrapper' | 'inputIcon' | 'list' | 'empty' | 'footerHint'> -
controlsId string -
hasResults boolean -
label string 'Search' Accessible name for the input.
onInput function -
placeholder string -
setup ModifierLike<{ Element: HTMLInputElement; }> -
value string -

Blocks

Name Type Default Description
icon * Array - Replaces the leading search icon.

CommandList

Element: HTMLUListElement

Renders the ranked results.

Sections come from the ranked data rather than from markup, so a group whose items all filtered out is simply not rendered — heading, wrapper and separator disappear together, with no visibility tracking or forceMount escape hatch. Reordering is a keyed {{#each}} over a sorted array, which Glimmer handles natively.

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'footer' | 'input' | 'kbd' | 'loading' | 'inputWrapper' | 'inputIcon' | 'list' | 'empty' | 'footerHint'> -
disabledKeys Array -
groups Array -
id string -
inputElement HTMLInputElement -
isLoading boolean -
isSearchPrompt boolean -
onActiveItemChange function -
onSelect function -
size enum -

Blocks

Name Type Default Description
item * Array -
empty * Array -
loading * Array -
prompt * Array - Shown by an async palette before anything has been typed.

CommandFooter

Element: HTMLDivElement

The bar along the bottom of a palette that teaches its keyboard: how to move, how to choose, how to leave. Renders sensible defaults when given no block.

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'footer' | 'input' | 'kbd' | 'loading' | 'inputWrapper' | 'inputIcon' | 'list' | 'empty' | 'footerHint'> -

Blocks

Name Type Default Description
default * Array - Replaces the default hints. Yields a Kbd keycap so custom hints match the built-in ones.

CommandDialog

Element: HTMLDivElement

A Command palette in a dialog, with an optional global shortcut.

Arguments

Name Type Default Description
isOpen * boolean -
backdrop enum -
class string -
classes SlotsToClasses<'base' | 'footer' | 'input' | 'kbd' | 'loading' | 'inputWrapper' | 'inputIcon' | 'list' | 'empty' | 'footerHint'> -
didClose function -
disabledKeys Array -
disableFiltering boolean false Render @items as given, without filtering or ranking. Use with @query/@onQueryChange when filtering happens elsewhere.
disableTransitions boolean -
filter function - Scores or matches an item against the query. Defaults to a relevance filter; see {@link FilterFn}.
groupBy enum - Groups results under headings. Either a property name on the item or a function returning the heading. Items with no group render ungrouped, ahead of any groups.
groups Array -

Pins these groups to the top, in this order. Any group not listed still renders, after them, ordered by its best-scoring member — so pinning a "Recent" section cannot hide search results.

When omitted, every group is ordered by its best-scoring member, so the closest match is always on top.

isBordered boolean - Draw the palette's own surface, for use outside a dialog.
isLoading boolean - Show the loading state regardless of @onSearch.
items Array - The items to search. Ranked and grouped by the component unless @disableFiltering is set or @onSearch is provided.
label string 'Search' Accessible name for the search input.
onClose function -
onOpen function -
onQueryChange function -
onSearch function -

Async search. Called (debounced) as the user types; the resolved items are rendered and a loading state shows while pending. Stale responses are discarded, so the latest query always wins. Built-in filtering is disabled, and @items is the list shown before the first search.

The resolved items replace @items — this is for a wholly remote list. To combine static entries (navigation, recents) with remote ones, do not use @onSearch: merge them yourself into @items, set @disableFiltering, and rank the static half with filterAndRankItems from frontile/utils/filter. See "Mixing static and remote results" in the docs.

onSelect function - Called when an item is chosen, by click or by Enter.
placeholder string -
query string - The query text. Pass with @onQueryChange to control it.
searchDebounce number 250 Debounce applied to @onSearch, in milliseconds.
searchFields function -

The text searched for each item. Defaults to its label.

Return several fields to search more than the label — a category, say, or keywords. The first is primary; the rest are down-weighted and combined by max, so a weak hit on a secondary field never outranks a strong hit on the label.

shortcut enum -

Opens the palette from anywhere in the document, e.g. "mod+k" or "/". Requires @onOpen. Pass an array to accept more than one.

An unmodified shortcut is ignored while the user is typing in a field, so / still types a slash in a text input.

size enum 'md'

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke