svelte-multiselect Svelte MultiSelect

<MultiSelect /> can implement a navigation command menu in 70 lines of code (50 without styles).

svelte<script lang="ts">
  import { goto } from '$app/navigation'
  import { resolve } from '$app/paths'
  import type { Pathname } from '$app/types'
  import { CommandMenu } from '$lib'
  import { routes } from '../../index'

  const resolve_path = resolve as (path: Pathname) => string
  const actions = routes.map(({ route }) => ({
    label: route,
    action: () => goto(resolve_path(route)),
  }))
</script>

<CommandMenu {actions} triggers={[`n`]} />

Site Search with Pagefind

PageSearch wraps CommandMenu with full-text search over statically generated pages. fallback_actions are matched locally on every keystroke, so known routes show up without waiting on the index (and remain the only results when the index is absent). Install pagefind as a development dependency, then index the rendered site after the application build. Run this script before previewing or deploying:

{
  "scripts": {
    "build:site": "vite build && pagefind --site build"
  }
}

The navigate callback receives the selected result’s query, label, and description as its second argument. A persistent layout can carry the query across client-side navigation and apply highlight_matches to the destination content:

<script lang="ts">
  import { afterNavigate, goto } from '$app/navigation'
  import { highlight_matches } from 'svelte-multiselect/attachments'
  import type { PageSearchNavigateDetails } from 'svelte-multiselect/types'

  let highlight_query = $state(``)

  const navigate = async (url: string, { query }: PageSearchNavigateDetails) => {
    await goto(url)
    highlight_query = ``
    queueMicrotask(() => (highlight_query = query))
  }

  afterNavigate(() => (highlight_query = ``))
</script>

<main
  {@attach highlight_matches({
    query: highlight_query,
    css_class: `site-search-match`,
    duration_ms: 8000,
  })}
>
  ...
</main>

<style>
  :global(::highlight(site-search-match)) {
    background: gold;
    color: inherit;
  }
</style>

See the attachments demo for highlight_matches options and effects. Stemmed Pagefind results may have no exact substring.

Shortcuts, Descriptions & Recent Actions

Actions can carry a description, metadata, badge, keywords, shortcut, and disabled state. The default filter searches all visible fields plus keywords and supports multiple terms. Shortcuts render as -style key hints and trigger globally while the menu is closed unless global_shortcuts={false}. Pass recent_actions_key to persist triggered actions to localStorage and rank them first when the menu reopens.

Open with cmd/ctrl+p or press ctrl+shift+l / ctrl+shift+u anywhere on this page. Last triggered: none

svelte<script lang="ts">
  import { CommandMenu } from '$lib'

  let last_triggered = $state(``)

  const actions = [
    {
      label: `Toggle theme`,
      description: `Switch between light and dark mode`,
      metadata: [`Appearance`],
      badge: `Setting`,
      keywords: [`color scheme`],
      shortcut: `ctrl+shift+l`,
      action: (label: string) => (last_triggered = label),
    },
    {
      label: `Copy page URL`,
      description: `Copy the current address to the clipboard`,
      shortcut: `ctrl+shift+u`,
      action: (label: string) => (last_triggered = label),
    },
    { label: `Open settings`, action: (label: string) => (last_triggered = label) },
  ]
</script>

<CommandMenu
  {actions}
  triggers={[`p`]}
  recent_actions_key="demo-recent-actions"
  placeholder="Recently used actions float to the top..."
/>
<p>
  Open with <kbd>cmd/ctrl+p</kbd> or press <kbd>ctrl+shift+l</kbd> /
  <kbd>ctrl+shift+u</kbd> anywhere on this page. Last triggered:
  <strong>{last_triggered || `none`}</strong>
</p>

  1. CommandMenu.svelte source code
    svelte<script lang="ts" generics="Action extends CmdAction = CmdAction">
      import type { ComponentProps } from 'svelte'
      import type { HTMLAttributes } from 'svelte/elements'
      import { fade } from 'svelte/transition'
      import MultiSelect from './MultiSelect.svelte'
      import type { CmdAction, MultiSelectProps } from './types'
      import {
        cmd_action_matches,
        chain_handlers,
        format_cmd_metadata,
        matches_shortcut,
        split_shortcut,
      } from './utils'
    
      // MultiSelect's option snippet param (option + idx/selected/active/disabled)
      type OptionSnippetParams = Parameters<
        NonNullable<MultiSelectProps<Action>[`option`]>
      >[0]
      type AddParams = Parameters<NonNullable<MultiSelectProps<Action>[`onadd`]>>[0]
      type DialogEvent = Parameters<
        NonNullable<HTMLAttributes<HTMLDialogElement>[`oncancel`]>
      >[0]
    
      let {
        actions,
        activeIndex: active_idx = $bindable(null),
        activeOption: active_option = $bindable(null),
        triggers = [`k`],
        close_keys = [`Escape`],
        fade_duration = 200,
        dialog_style = ``,
        open = $bindable(false),
        dialog = $bindable(null),
        input = $bindable(null),
        aria_label = `Command menu`,
        filterFunc: filter_func,
        fuzzy = true,
        inputProps: input_props,
        input_aria_label = aria_label === `Command menu` ? `Search commands` : aria_label,
        matchingOptions: matching_actions = $bindable([]),
        noMatchingOptionsMsg: no_matching_options_msg = `No matching commands`,
        onadd,
        onkeydown,
        option: option_snippet,
        placeholder = `Type a command…`,
        searchText: search_text = $bindable(``),
        dialog_props,
        global_shortcuts = true,
        recent_actions_key = null,
        max_recent = 20,
        ...rest
      }: Omit<
        ComponentProps<typeof MultiSelect<Action>>,
        `activeOptionFallbackKey` | `autoActiveFirstOption` | `key` | `options`
      > & {
        actions: Action[]
        triggers?: string[]
        close_keys?: string[]
        fade_duration?: number // in ms
        dialog_style?: string // inline style for the dialog element
        open?: boolean
        dialog?: HTMLDialogElement | null
        input?: HTMLInputElement | null
        input_aria_label?: string
        aria_label?: string
        placeholder?: string
        dialog_props?: HTMLAttributes<HTMLDialogElement>
        // run action.shortcut hotkeys globally while the menu is closed (default: true)
        global_shortcuts?: boolean
        // localStorage key to persist recently triggered actions. When set, recent
        // actions rank first in the dropdown (most recent on top). null = disabled
        recent_actions_key?: string | null
        max_recent?: number // cap on persisted recent actions (default: 20)
      } = $props()
    
      // === Recent actions (frecency ranking) ===
      let recent_action_ids = $state<string[]>([])
    
      const get_action_id = (action: CmdAction): string => `${action.id ?? action.label}`
      const recent_limit = $derived(
        Number.isFinite(max_recent) ? Math.max(0, Math.floor(max_recent)) : 20,
      )
      const action_id_counts = $derived.by(() => {
        const counts = new Map<string, number>()
        for (const action of actions) {
          const action_id = get_action_id(action)
          counts.set(action_id, (counts.get(action_id) ?? 0) + 1)
        }
        return counts
      })
      const action_ids_are_unique = $derived(action_id_counts.size === actions.length)
      const get_action_signature = (action: CmdAction): string =>
        JSON.stringify([
          action.id,
          action.label,
          action.description,
          action.badge,
          action.disabled,
          action.group,
          action.metadata,
          action.keywords,
          action.shortcut,
        ])
      const action_id_is_unique = (action: Action) =>
        action_id_counts.get(get_action_id(action)) === 1
      const get_action_key = (action: Action) =>
        action_id_is_unique(action) ? get_action_id(action) : action.action
      const get_action_fallback_key = (action: Action) =>
        action_id_is_unique(action) ? get_action_id(action) : get_action_signature(action)
    
      // load persisted recents (client-only since $effect doesn't run during SSR)
      $effect(() => {
        if (!recent_actions_key || !action_ids_are_unique) return
        try {
          const stored: unknown = JSON.parse(localStorage.getItem(recent_actions_key) ?? `[]`)
          recent_action_ids = Array.isArray(stored)
            ? stored.filter((recent) => typeof recent === `string`).slice(0, recent_limit)
            : []
        } catch {
          recent_action_ids = [] // ignore corrupted storage
        }
      })
    
      function record_recent(action: Action) {
        if (!recent_actions_key || !action_ids_are_unique) return
        const action_id = get_action_id(action)
        recent_action_ids = [
          action_id,
          ...recent_action_ids.filter((recent_id) => recent_id !== action_id),
        ].slice(0, recent_limit)
        try {
          localStorage.setItem(recent_actions_key, JSON.stringify(recent_action_ids))
        } catch {
          // Storage full or unavailable; recents do not persist.
        }
      }
    
      // recently triggered actions first (most recent on top), rest keep original order
      const sorted_actions = $derived.by(() => {
        if (!recent_actions_key || !action_ids_are_unique || recent_action_ids.length === 0)
          return actions
        // drop stale persisted ids (actions removed/renamed since) so they don't
        // occupy low ranks and push real recents below non-recent actions
        const current_ids = new Set(actions.map(get_action_id))
        const rank = new Map(
          recent_action_ids
            .filter((recent_id) => current_ids.has(recent_id))
            .map((action_id, idx) => [action_id, idx]),
        )
        return [...actions].toSorted(
          (left_action, right_action) =>
            (rank.get(get_action_id(left_action)) ?? actions.length) -
            (rank.get(get_action_id(right_action)) ?? actions.length),
        )
      })
    
      // === Shortcut display ===
      // Map shortcut segments to display symbols (deterministic across platforms)
      const key_symbols: Record<string, string> = {
        meta: `⌘`,
        cmd: `⌘`,
        shift: `⇧`,
        alt: `⌥`,
        ctrl: `Ctrl`,
        enter: `↵`,
        backspace: `⌫`,
        delete: `⌦`,
        escape: `Esc`,
        arrowup: `↑`,
        arrowdown: `↓`,
        arrowleft: `←`,
        arrowright: `→`,
      }
      const format_shortcut = (shortcut: string): string[] =>
        split_shortcut(shortcut).map((part) => {
          const key_segment = part.trim().toLowerCase()
          // title-case unknown multi-char segments, upper-case single chars (empty stays empty)
          const title_case = key_segment.charAt(0).toUpperCase() + key_segment.slice(1)
          return (
            key_symbols[key_segment] ??
            (key_segment.length > 1 ? title_case : key_segment.toUpperCase())
          )
        })
    
      // Dynamic actions may contain metadata not present in the static action list.
      const has_action_meta = $derived(
        Boolean(rest.loadOptions) ||
          actions.some(
            (action) =>
              action.shortcut || action.description || action.badge || action.metadata,
          ),
      )
      $effect(() => {
        if (!open) return
        if (dialog && !dialog.open) {
          try {
            dialog.showModal()
          } catch {
            // showModal missing (older DOM impls) or dialog not in document
            dialog.setAttribute(`open`, ``)
          }
        }
        if (input && document.activeElement !== input) input.focus()
      })
    
      function close_menu() {
        open = false
        active_idx = null
        active_option = null
        search_text = ``
      }
    
      function toggle(event: KeyboardEvent): boolean {
        const should_open =
          !open && triggers.includes(event.key) && (event.metaKey || event.ctrlKey)
        const should_close = open && close_keys.includes(event.key)
        if (!should_open && !should_close) return false
        event.preventDefault()
        if (should_open) open = true
        else close_menu()
        return true
      }
    
      function handle_dialog_cancel(event: DialogEvent) {
        if (!close_keys.includes(`Escape`)) event.preventDefault()
        dialog_props?.oncancel?.(event)
      }
    
      function handle_window_keydown(event: KeyboardEvent) {
        const is_close_key = open && close_keys.includes(event.key)
        if (event.defaultPrevented && !is_close_key) return
        if (toggle(event)) return
        // run action hotkeys globally while the menu is closed
        if (open || !global_shortcuts) return
        const action = actions.find(
          (cmd_action) =>
            !cmd_action.disabled && matches_shortcut(event, cmd_action.shortcut),
        )
        if (!action) return
        event.preventDefault()
        record_recent(action)
        action.action(action.label)
      }
    
      function close_if_outside(event: MouseEvent) {
        const target = event.target
        // dialog is null until the next flush after open is set, e.g. when a button's
        // click handler sets open=true and the same click bubbles up to window - don't
        // treat that click as outside. Element (not HTMLElement) so SVG targets count.
        if (!open || !dialog || !(target instanceof Element)) return
        // backdrop clicks on a modal dialog have target === dialog, so close unless the click
        // is on this menu's MultiSelect (scoped inside the dialog) or its options list
        if (dialog.contains(target) && target.closest(`div.multiselect`)) return
        const listbox_id = input?.getAttribute(`aria-controls`)
        const listbox = listbox_id && document.querySelector(`#${CSS.escape(listbox_id)}`)
        if (listbox && listbox.contains(target)) return
        close_menu()
      }
    
      function trigger_action_and_close(params: AddParams) {
        const { option } = params
        if (!option?.action || option.disabled) return
        record_recent(option)
        close_menu()
        option.action(option.label)
        onadd?.(params)
      }
    </script>
    
    <svelte:window onkeydown={handle_window_keydown} onclick={close_if_outside} />
    
    {#snippet action_item({ option }: OptionSnippetParams)}
      {@const metadata = format_cmd_metadata(option.metadata)}
      <span class="cmd-action">
        <span class="cmd-label">
          {option.label}
          {#if option.description}
            <small class="cmd-description">{option.description}</small>
          {/if}
        </span>
        {#if metadata || option.badge || option.shortcut}
          <span class="cmd-meta">
            {#if metadata}<small class="cmd-metadata">{metadata}</small>{/if}
            {#if option.badge}<span class="cmd-badge">{option.badge}</span>{/if}
            {#if option.shortcut}
              <span class="cmd-shortcut" aria-hidden="true">
                {#each format_shortcut(option.shortcut) as part, idx (idx)}
                  <kbd>{part}</kbd>
                {/each}
              </span>
            {/if}
          </span>
        {/if}
      </span>
    {/snippet}
    
    {#if open}
      <dialog
        bind:this={dialog}
        transition:fade={{ duration: fade_duration }}
        style={dialog_style}
        aria-label={aria_label}
        {...dialog_props}
        onclose={chain_handlers(close_menu, dialog_props?.onclose)}
        oncancel={handle_dialog_cancel}
      >
        <MultiSelect
          {...rest}
          options={sorted_actions}
          bind:activeIndex={active_idx}
          bind:activeOption={active_option}
          activeOptionFallbackKey={get_action_fallback_key}
          autoActiveFirstOption
          bind:input
          bind:matchingOptions={matching_actions}
          bind:searchText={search_text}
          filterFunc={filter_func ??
            ((action, search) => cmd_action_matches(action, search, fuzzy))}
          {fuzzy}
          inputProps={{ 'aria-label': input_aria_label, ...input_props }}
          noMatchingOptionsMsg={no_matching_options_msg}
          {placeholder}
          key={get_action_key}
          onadd={trigger_action_and_close}
          onkeydown={(event) => {
            toggle(event)
            onkeydown?.(event)
          }}
          option={option_snippet ?? (has_action_meta ? action_item : undefined)}
          --sms-bg="var(--sms-options-bg)"
          --sms-width="var(--cmd-width, min(38rem, 90vw))"
          --sms-max-width="none"
          --sms-placeholder-color="lightgray"
          --sms-padding="3pt"
          --sms-options-margin="1px 0"
          --sms-options-border-radius="0 0 1ex 1ex"
        />
      </dialog>
    {/if}
    
    <style>
      .cmd-action {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 1em;
        min-width: 0;
      }
      .cmd-description {
        display: block;
        opacity: 0.6;
        font-size: var(--cmd-description-font-size, 0.75em);
      }
      .cmd-meta {
        display: flex;
        min-width: 0;
        flex-shrink: 0;
        align-items: center;
        gap: 0.5em;
      }
      .cmd-metadata {
        max-width: var(--cmd-metadata-max-width, 14em);
        overflow: hidden;
        opacity: 0.6;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .cmd-badge {
        padding: 0.05em 0.4em;
        border-radius: 999px;
        background: var(--cmd-badge-bg, color-mix(in srgb, currentColor 10%, transparent));
        opacity: 0.7;
        font-size: var(--cmd-badge-font-size, 0.7em);
      }
      .cmd-shortcut {
        flex-shrink: 0;
        display: flex;
        gap: 2pt;
      }
      .cmd-shortcut kbd {
        background: var(
          --cmd-kbd-bg,
          light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.15))
        );
        border-radius: 3pt;
        padding: 0 4pt;
        font-size: var(--cmd-kbd-font-size, 0.8em);
        line-height: 1.5;
      }
      :where(dialog) {
        position: fixed;
        top: 30%;
        left: 0;
        right: 0;
        bottom: auto;
        margin: 0 auto;
        border: none;
        padding: var(--cmd-dialog-padding, 6pt);
        background-color: transparent;
        display: flex;
        /* Let command results/popovers escape the dialog; default clipping hides suggestions. */
        overflow: visible;
        color: light-dark(#222, #eee);
        z-index: var(--cmd-menu-z-index, 10);
        font-size: 2.4ex;
      }
    </style>
    
  2. PageSearch.svelte source code
    svelte<script module lang="ts">
      import type {
        CmdAction,
        LoadOptionsParams,
        LoadOptionsResult,
        PageSearchNavigateDetails,
      } from './types'
      import { slug_to_title } from './utils'
    
      type PagefindSubResult = { title: string; url: string; plain_excerpt: string }
    
      type PagefindResultData = {
        url: string
        plain_excerpt: string
        meta: Record<string, string>
        sub_results: PagefindSubResult[]
      }
    
      type PagefindResult = { id: string; data: () => Promise<PagefindResultData> }
    
      type PagefindApi = {
        search: (query: string) => Promise<{ results: PagefindResult[] } | null>
      }
    
      type PagefindSearchCache = {
        query: string
        results: Promise<PagefindResult[]>
        actions: CmdAction[]
        next_result_idx: number
      }
    
      type PagefindLoaderOptions = {
        load_pagefind?: () => Promise<PagefindApi>
        navigate?: (url: string, details: PageSearchNavigateDetails) => unknown
        pagefind_key?: string
        pagefind_path?: string
        transform_url?: (url: string) => string
      }
    
      const MAX_DESCRIPTION_LENGTH = 240
    
      const strip_html_extension = (url: string): string => {
        const suffix_start = url.search(/[?#]/)
        const [path, suffix] =
          suffix_start < 0 ? [url, ``] : [url.slice(0, suffix_start), url.slice(suffix_start)]
        return `${path.replace(/\/index\.html$/, `/`).replace(/\.html$/, ``)}${suffix}`
      }
    
      // fresh object per call: the returned array is assigned into MultiSelect's $state, and
      // a shared one would hand every instance the same (deeply proxied) array
      const no_results = (): LoadOptionsResult<CmdAction> => ({ options: [], hasMore: false })
    
      const decode_html_entities = (text: string): string => {
        const textarea = document.createElement(`textarea`)
        textarea.innerHTML = text
        return textarea.value
      }
    
      const page_title_from_url = (url: string): string => {
        const path = url
          .split(/[?#]/)[0]
          .replace(/(?:\/index)?\.html$/, ``)
          .replace(/\/+$/, ``)
        if (!path) return `Home`
        const final_segment = path.slice(path.lastIndexOf(`/`) + 1)
        return slug_to_title(decodeURIComponent(final_segment))
      }
    
      const pagefind_result_to_actions = (
        result_id: string,
        query: string,
        result: PagefindResultData,
        get_options: () => PagefindLoaderOptions,
      ): CmdAction[] => {
        const page_title = result.meta.title || page_title_from_url(result.url)
        const sections = result.sub_results.length ? result.sub_results : [undefined]
    
        return sections.map((section, section_idx) => {
          const source_url = section?.url ?? result.url
          const section_title = section?.title.trim()
          const label =
            section_title && section_title !== page_title
              ? `${page_title} › ${section_title}`
              : page_title
          const full_description = decode_html_entities(
            section?.plain_excerpt ?? result.plain_excerpt,
          )
            .replaceAll(/\s+/g, ` `)
            .trim()
          const description =
            full_description.length > MAX_DESCRIPTION_LENGTH
              ? `${full_description.slice(0, MAX_DESCRIPTION_LENGTH - 1).trimEnd()}…`
              : full_description
    
          const id = `pagefind:${result_id}:${section_idx}:${source_url}`
          const action = () => {
            const { navigate, transform_url } = get_options()
            const current_url = transform_url?.(source_url) ?? source_url
            if (navigate) void navigate(current_url, { query, label, description })
            else globalThis.location.assign(current_url)
          }
          return { id, label, description, action }
        })
      }
    
      const create_pagefind_loader = (get_options: () => PagefindLoaderOptions) => {
        let pagefind_api_promise: Promise<PagefindApi> | undefined
        let search_cache: PagefindSearchCache | undefined
        let previous_pagefind_source: string | undefined
        let pagefind_generation = 0
    
        return async ({
          search,
          offset,
          limit,
        }: LoadOptionsParams): Promise<LoadOptionsResult<CmdAction>> => {
          const {
            load_pagefind,
            pagefind_key = ``,
            pagefind_path = `/pagefind/pagefind.js`,
          } = get_options()
          // Key the cache on the source, not on closure identity: an inline load_pagefind
          // arrow gets a fresh identity every render, and treating that as a source change
          // would re-import pagefind and re-run the search on each keystroke. Callers that
          // swap between custom loaders over different indexes distinguish them with
          // pagefind_key, since their identity alone can't tell one index from another.
          const pagefind_source = load_pagefind
            ? `custom-loader:${pagefind_key}`
            : pagefind_path
          if (pagefind_source !== previous_pagefind_source) {
            pagefind_api_promise = undefined
            search_cache = undefined
            previous_pagefind_source = pagefind_source
            pagefind_generation++
          }
          const generation = pagefind_generation
          const load_api =
            load_pagefind ??
            (async () => (await import(/* @vite-ignore */ pagefind_path)) as PagefindApi)
          const query = search.trim()
          // fallback_actions are handed to CommandMenu as static options, which match them
          // locally without waiting on Pagefind, so this loader only returns index hits
          if (!query) return no_results()
          try {
            if (offset === 0 || search_cache?.query !== query) {
              search_cache = {
                query,
                results: (async () => {
                  const pagefind_api = await (pagefind_api_promise ??= load_api())
                  return (await pagefind_api.search(query))?.results ?? []
                })(),
                actions: [],
                next_result_idx: 0,
              }
            }
            const cache = search_cache
            const page_results = await cache.results
            if (page_results.length === 0) return no_results()
            const target_count = offset + limit
    
            while (
              cache.actions.length < target_count &&
              cache.next_result_idx < page_results.length
            ) {
              const result_batch = page_results.slice(
                cache.next_result_idx,
                cache.next_result_idx + limit,
              )
              cache.next_result_idx += result_batch.length
              const settled_results = await Promise.allSettled(
                result_batch.map(async (result) =>
                  pagefind_result_to_actions(
                    result.id,
                    query,
                    await result.data(),
                    get_options,
                  ),
                ),
              )
              cache.actions.push(
                ...settled_results.flatMap((result) =>
                  result.status === `fulfilled` ? result.value : [],
                ),
              )
            }
            if (cache.actions.length === 0) return no_results()
    
            return {
              options: cache.actions.slice(offset, target_count),
              hasMore:
                cache.actions.length > target_count ||
                cache.next_result_idx < page_results.length,
            }
          } catch {
            // only retire our own loader promise: the source may have switched while this
            // request was in flight, and the newer one has already installed its own
            if (generation === pagefind_generation) pagefind_api_promise = undefined
            return no_results()
          }
        }
      }
    </script>
    
    <script lang="ts">
      import type { ComponentProps } from 'svelte'
      import CommandMenu from './CommandMenu.svelte'
    
      type Props = Omit<
        ComponentProps<typeof CommandMenu>,
        | `actions`
        | `dialog`
        | `input`
        | `loadOptions`
        | `max_recent`
        | `open`
        | `recent_actions_key`
      > &
        PagefindLoaderOptions & {
          batch_size?: number
          debounce_ms?: number
          dialog?: HTMLDialogElement | null
          // matched locally, so these stay searchable while the Pagefind index loads
          fallback_actions?: CmdAction[]
          input?: HTMLInputElement | null
          open?: boolean
          strip_html_suffix?: boolean
        }
    
      let {
        fallback_actions = [],
        load_pagefind,
        navigate,
        pagefind_key,
        pagefind_path,
        transform_url,
        strip_html_suffix = false,
        batch_size = 12,
        debounce_ms = 120,
        fuzzy = false,
        open = $bindable(false),
        dialog = $bindable(null),
        input = $bindable(null),
        ...rest
      }: Props = $props()
    
      const load_options = create_pagefind_loader(() => ({
        load_pagefind,
        navigate,
        pagefind_key,
        pagefind_path,
        transform_url: (url) => {
          const normalized_url = strip_html_suffix ? strip_html_extension(url) : url
          return transform_url?.(normalized_url) ?? normalized_url
        },
      }))
    </script>
    
    <CommandMenu
      actions={fallback_actions}
      bind:open
      bind:dialog
      bind:input
      {fuzzy}
      aria_label="Site search"
      loadOptions={{
        fetch: load_options,
        debounceMs: debounce_ms,
        batchSize: Number.isFinite(batch_size) ? Math.max(1, Math.floor(batch_size)) : 12,
      }}
      placeholder="Search every page..."
      noMatchingOptionsMsg="No matching pages"
      {...rest}
    />