Перейти к содержимому

Интерфейс

Палитра поиска

Модальное окно командной палитры с поиском по контенту и навигацией клавиатурой.


Палитра поиска

Модальное окно поиска с командной палитрой. Поддерживает горячие клавиши (Ctrl+K), навигацию стрелками, выделение совпадений и плавные анимации появления.

Превью

Нажмите на кнопку выше или используйте Ctrl+K для открытия поиска.

<script lang="ts">
  import { searchState } from "$lib/stores/search.svelte";
  import {
    contentUiDefaults,
    type SectionUiConfig,
  } from "$lib/config/content-ui";
  import { searchContent, type SearchMode } from "$lib/utils/search";
  import { scale } from "svelte/transition";
  import { cubicOut } from "svelte/easing";
  import { goto, onNavigate } from "$app/navigation";
  import { resolve } from "$app/paths";
  import { cn } from "$lib/utils/cn";
  import ScrollArea from "$lib/components/ui/ScrollArea.svelte";
  import Overlay from "$lib/components/ui/Overlay.svelte";
  import { onMount } from "svelte";
  import Search from "carbon-icons-svelte/lib/Search.svelte";
  import Return from "carbon-icons-svelte/lib/Return.svelte";
  import Book from "carbon-icons-svelte/lib/Book.svelte";
  import Wikis from "carbon-icons-svelte/lib/Wikis.svelte";
  import DockerLogo from "$lib/components/ui/DockerLogo.svelte";

  const {
    searchConfig = contentUiDefaults.search,
  }: { searchConfig?: SectionUiConfig["search"] } = $props();

  let query = $state("");
  let mode = $state<SearchMode>("global");
  const modes: { value: SearchMode; label: string; icon: typeof Wikis }[] = [
    { value: "global", label: "Везде", icon: Wikis },
    { value: "docs", label: "Документация", icon: Book },
    { value: "registry", label: "Реестр", icon: DockerLogo },
  ];
  let results = $derived(searchContent(query, searchConfig, mode));
  let selectedIndex = $state(0);
  let inputRef = $state<HTMLInputElement>();
  let contentHeight = $state(0);
  let resultsRef = $state<HTMLDivElement | null>(null);
  let selectedIndicatorTop = $state(0);
  let selectedIndicatorHeight = $state(0);
  let selectedIndicatorVisible = $state(false);
  let selectedGlowLeft = $state(0);
  let selectedGlowTop = $state(0);
  let selectedGlowHeight = $state(0);
  let selectedGlowVisible = $state(false);
  let selectedResultElement: HTMLElement | null = null;
  let selectedResultIsChild = false;
  let pendingSelectedIndicatorFrame: number | null = null;

  function handleGlobalKeydown(e: KeyboardEvent) {
    const hotkey = searchConfig.hotkey;
    if (!hotkey.enabled || !searchConfig.enabled) {
      return;
    }

    const matchesModifier = hotkey.metaOrCtrl ? e.metaKey || e.ctrlKey : true;
    const matchesKey = e.key.toLowerCase() === hotkey.key.toLowerCase();
    if (matchesModifier && matchesKey) {
      e.preventDefault();
      searchState.toggle();
    }
  }

  onMount(() => {
    window.addEventListener("keydown", handleGlobalKeydown);
    return () => {
      window.removeEventListener("keydown", handleGlobalKeydown);
    };
  });

  $effect(() => {
    if (!searchConfig.enabled) return;
    if (searchState.isOpen && inputRef) {
      const initialMode = searchState.consumeInitialMode();
      if (initialMode) mode = initialMode;
      inputRef.focus();
    }
  });

  $effect(() => {
    void results;
    selectedIndex = 0;
  });

  function updateSelectedIndicators() {
    if (!resultsRef || !selectedResultElement) {
      selectedIndicatorVisible = false;
      selectedGlowVisible = false;
      return;
    }

    const resultsRect = resultsRef.getBoundingClientRect();
    const nodeRect = selectedResultElement.getBoundingClientRect();

    selectedIndicatorTop = nodeRect.top - resultsRect.top;
    selectedIndicatorHeight = nodeRect.height;
    selectedIndicatorVisible = true;

    selectedGlowLeft = nodeRect.left - resultsRect.left + 12;
    selectedGlowTop = selectedIndicatorTop;
    selectedGlowHeight = selectedIndicatorHeight;
    selectedGlowVisible = selectedResultIsChild;
  }

  function scheduleSelectedIndicatorUpdate() {
    if (typeof window === "undefined") {
      updateSelectedIndicators();
      return;
    }

    if (pendingSelectedIndicatorFrame !== null) {
      window.cancelAnimationFrame(pendingSelectedIndicatorFrame);
    }

    pendingSelectedIndicatorFrame = window.requestAnimationFrame(() => {
      pendingSelectedIndicatorFrame = null;
      updateSelectedIndicators();
    });
  }

  function registerResult(
    node: HTMLElement,
    params: { selected: boolean; child: boolean },
  ) {
    if (params.selected) {
      selectedResultElement = node;
      selectedResultIsChild = params.child;
      scheduleSelectedIndicatorUpdate();
    }

    return {
      update(nextParams: { selected: boolean; child: boolean }) {
        if (nextParams.selected) {
          selectedResultElement = node;
          selectedResultIsChild = nextParams.child;
          scheduleSelectedIndicatorUpdate();
        } else if (selectedResultElement === node) {
          selectedResultElement = null;
          selectedResultIsChild = false;
          scheduleSelectedIndicatorUpdate();
        }
      },
      destroy() {
        if (selectedResultElement === node) {
          selectedResultElement = null;
          selectedResultIsChild = false;
          scheduleSelectedIndicatorUpdate();
        }
      },
    };
  }

  $effect(() => {
    const index = selectedIndex;
    const resultCount = results.length;
    void index;
    void resultCount;

    scheduleSelectedIndicatorUpdate();

    if (typeof window === "undefined") return;

    window.addEventListener("resize", scheduleSelectedIndicatorUpdate);

    return () => {
      window.removeEventListener("resize", scheduleSelectedIndicatorUpdate);
      if (pendingSelectedIndicatorFrame !== null) {
        window.cancelAnimationFrame(pendingSelectedIndicatorFrame);
        pendingSelectedIndicatorFrame = null;
      }
    };
  });

  function close() {
    searchState.close();
  }

  function handleKeydown(e: KeyboardEvent) {
    if (!searchState.isOpen) return;

    if (e.key === "Escape") {
      e.preventDefault();
      close();
      return;
    }

    if (results.length === 0) {
      return;
    }

    if (e.key === "ArrowDown") {
      e.preventDefault();
      selectedIndex = (selectedIndex + 1) % results.length;
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      selectedIndex = (selectedIndex - 1 + results.length) % results.length;
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (results[selectedIndex]) {
        selectResult(results[selectedIndex]);
      }
    }
  }

  $effect(() => {
    if (!searchConfig.enabled) return;
    if (searchState.isOpen) {
      window.addEventListener("keydown", handleKeydown);
      return () => {
        window.removeEventListener("keydown", handleKeydown);
      };
    }
  });

  function selectResult(result: ReturnType<typeof searchContent>[number]) {
    const href = `${result.slug}${result.anchor ?? ""}`;
    // @ts-expect-error arg cannot be cast as `resolve`'s expected type
    void goto(resolve(href));
    close();
  }

  onNavigate(() => {
    close();
  });

  function highlight(text: string, search: string) {
    if (!search.trim()) return [{ text, highlight: false }];

    const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const regex = new RegExp(`(${escapedSearch})`, "gi");
    const parts = text.split(regex);

    return parts.map((part) => ({
      text: part,
      highlight: part.toLowerCase() === search.toLowerCase(),
    }));
  }
</script>

{#if searchConfig.enabled && searchState.isOpen}
  <Overlay onClose={close} zIndex={60} backdropCursor="default">
    <div
      class="w-full max-w-164 rounded-lg bg-background shadow-2xl card"
      role="document"
      transition:scale={{
        duration: 300,
        start: 0.95,
        easing: cubicOut,
      }}
      onoutroend={() => {
        query = "";
        contentHeight = 0;
      }}
    >
      <div
        class="relative flex items-center px-3 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:shadow-2xs after:shadow-white after:content-[''] dark:after:bg-background-inset dark:after:shadow-border"
      >
        <Search size={24} class="mr-2 text-foreground-muted/70" />
        <input
          bind:this={inputRef}
          bind:value={query}
          class="command-palette-input flex h-12 w-full bg-transparent text-base tracking-normal text-foreground placeholder:text-foreground-muted/70 focus:outline-none focus-visible:border-none! focus-visible:ring-0! focus-visible:ring-offset-0! focus-visible:outline-none!"
          placeholder={searchConfig.dialogPlaceholder}
          aria-label={searchConfig.dialogPlaceholder}
        />
        <div
          class="mr-2 flex shrink-0 items-center rounded-md border border-border bg-background-inset p-0.5"
          aria-label="Режим поиска"
        >
          {#each modes as item (item.value)}
            {@const Icon = item.icon}
            <button
              type="button"
              class={cn(
                "flex size-7 items-center justify-center rounded-sm text-foreground-muted transition",
                mode === item.value && "bg-background text-accent shadow-sm",
              )}
              onclick={() => (mode = item.value)}
              aria-label={item.label}
              title={item.label}
            >
              <Icon size={15} />
            </button>
          {/each}
        </div>
        <kbd
          class="pointer-events-none inset-shadow relative hidden h-5 items-center gap-1 rounded-[calc(var(--radius-base)*1.5)] bg-background-inset px-1.5 font-mono text-[10px] font-medium tracking-normal text-foreground-muted/70 select-none sm:flex"
        >
          ESC
        </kbd>
      </div>

      <div
        class="overflow-hidden transition-[height] duration-300 ease-out"
        style="height: {contentHeight}px"
      >
        <div bind:clientHeight={contentHeight}>
          {#if results.length > 0}
            <ScrollArea
              viewportStyle="mask-image: linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent); -webkit-mask-image: linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent);"
              viewportClass="max-h-96 p-2"
            >
              <div
                class="command-results relative flex flex-col"
                bind:this={resultsRef}
                style={`
										--command-selected-top: ${selectedIndicatorTop.toString()}px;
										--command-selected-height: ${selectedIndicatorHeight.toString()}px;
										--command-selected-opacity: ${selectedIndicatorVisible ? "1" : "0"};
										--command-glow-left: ${selectedGlowLeft.toString()}px;
										--command-glow-top: ${selectedGlowTop.toString()}px;
										--command-glow-height: ${selectedGlowHeight.toString()}px;
										--command-glow-opacity: ${selectedGlowVisible ? "1" : "0"};
									`}
              >
                {#each results as result, i (`${result.slug}${result.anchor ?? ""}${i.toString()}`)}
                  {@const isChild =
                    result.matchType === "heading" ||
                    result.matchType === "content"}
                  {@const isSelected = i === selectedIndex}
                  <button
                    class={cn(
                      "group relative z-10 flex w-full flex-col items-start gap-1 rounded-sm px-3 py-2 text-sm font-medium tracking-normal transition-colors duration-150 ease-out",
                      isChild && "pl-8",
                      isSelected
                        ? "text-foreground"
                        : "text-foreground hover:text-foreground",
                    )}
                    onclick={() => {
                      selectResult(result);
                    }}
                    onmouseenter={() => (selectedIndex = i)}
                    use:registerResult={{
                      selected: isSelected,
                      child: isChild,
                    }}
                  >
                    {#if isChild}
                      <div
                        class={cn(
                          "absolute top-0 bottom-0 left-3 w-px bg-border",
                        )}
                      ></div>
                    {/if}

                    <div class="flex w-full flex-col items-start gap-0.5">
                      {#if result.matchType !== "content"}
                        <div
                          class="flex items-center gap-2 font-medium tracking-normal"
                        >
                          {#if result.matchType === "heading"}
                            <span class="opacity-70">#</span>
                          {/if}
                          <span>
                            {#each highlight(result.heading ?? result.title, query) as part, index (index)}
                              {#if part.highlight}
                                <span class="text-accent">{part.text}</span>
                              {:else}
                                {part.text}
                              {/if}
                            {/each}
                          </span>
                        </div>
                      {/if}
                      {#if result.snippet}
                        <div
                          class="line-clamp-1 text-left text-xs font-medium tracking-normal text-foreground-muted"
                        >
                          {#each highlight(result.snippet, query) as part, index (index)}
                            {#if part.highlight}
                              <span class="text-accent">{part.text}</span>
                            {:else}
                              {part.text}
                            {/if}
                          {/each}
                        </div>
                      {/if}
                    </div>
                  </button>
                {/each}
              </div>
            </ScrollArea>
          {:else if query}
            <div
              class="py-6 text-center text-sm tracking-normal text-foreground-muted/70"
            >
              {searchConfig.noResultsLabel}
            </div>
          {/if}
        </div>
      </div>
      <div
        class="relative flex w-full flex-row items-center justify-start gap-2 rounded-b-lg bg-background p-2 after:absolute after:inset-x-0 after:top-0 after:h-px after:bg-border after:shadow-2xs after:shadow-white after:content-[''] dark:after:bg-background-inset dark:after:shadow-border"
      >
        <kbd
          class="pointer-events-none inset-shadow relative hidden h-5 items-center gap-1 rounded-[calc(var(--radius-base)*1.5)] bg-background-inset px-1.5 font-mono text-[10px] font-medium text-foreground-muted/70 select-none sm:flex"
        >
          <Return class="size-3" />
        </kbd>
        <span
          class="text-xs font-medium tracking-normal text-foreground-muted/70"
        >
          {searchConfig.submitHintLabel}
        </span>
      </div>
    </div>
  </Overlay>
{/if}

<style>
  .command-palette-input:focus,
  .command-palette-input:focus-visible {
    outline: none !important;
    outline-color: transparent !important;
    outline-offset: 0 !important;
    box-shadow: none !important;
  }

  .command-results::before {
    content: "";
    position: absolute;
    inset-inline: 0px;
    top: 0;
    height: var(--command-selected-height);
    border-radius: var(--radius-sm);
    background: var(--color-background-muted);
    opacity: var(--command-selected-opacity);
    pointer-events: none;
    transform: translateY(var(--command-selected-top));
    transition:
      transform 150ms ease-out,
      height 150ms ease-out,
      opacity 150ms ease-out;
    will-change: transform, height, opacity;
    z-index: 0;
  }

  .command-results::after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 1px;
    height: var(--command-glow-height);
    border-radius: 9999px;
    background-image: linear-gradient(
      to bottom,
      transparent,
      oklch(from var(--color-accent) l c h / 0.68) 18%,
      var(--color-accent) 50%,
      oklch(from var(--color-accent) l c h / 0.68) 82%,
      transparent
    );
    filter: drop-shadow(0 0 6px oklch(from var(--color-accent) l c h / 0.38));
    opacity: var(--command-glow-opacity);
    pointer-events: none;
    transform: translate(var(--command-glow-left), var(--command-glow-top));
    transition:
      transform 150ms ease-out,
      height 150ms ease-out,
      opacity 150ms ease-out;
    will-change: transform, height, opacity;
    z-index: 20;
  }

  @media (max-width: 1023.98px) {
    .command-results::before {
      display: none;
    }
  }
</style>
<script lang="ts">
  import { searchState } from "$lib/stores/search.svelte";
  import {
    contentUiDefaults,
    type SectionUiConfig,
  } from "$lib/config/content-ui";
  import { searchContent, type SearchMode } from "$lib/utils/search";
  import { scale } from "svelte/transition";
  import { cubicOut } from "svelte/easing";
  import { goto, onNavigate } from "$app/navigation";
  import { resolve } from "$app/paths";
  import { cn } from "$lib/utils/cn";
  import ScrollArea from "$lib/components/ui/ScrollArea.svelte";
  import Overlay from "$lib/components/ui/Overlay.svelte";
  import { onMount } from "svelte";
  import Search from "carbon-icons-svelte/lib/Search.svelte";
  import Return from "carbon-icons-svelte/lib/Return.svelte";
  import Book from "carbon-icons-svelte/lib/Book.svelte";
  import Wikis from "carbon-icons-svelte/lib/Wikis.svelte";
  import DockerLogo from "$lib/components/ui/DockerLogo.svelte";

  const {
    searchConfig = contentUiDefaults.search,
  }: { searchConfig?: SectionUiConfig["search"] } = $props();

  let query = $state("");
  let mode = $state<SearchMode>("global");
  const modes: { value: SearchMode; label: string; icon: typeof Wikis }[] = [
    { value: "global", label: "Везде", icon: Wikis },
    { value: "docs", label: "Документация", icon: Book },
    { value: "registry", label: "Реестр", icon: DockerLogo },
  ];
  let results = $derived(searchContent(query, searchConfig, mode));
  let selectedIndex = $state(0);
  let inputRef = $state<HTMLInputElement>();
  let contentHeight = $state(0);
  let resultsRef = $state<HTMLDivElement | null>(null);
  let selectedIndicatorTop = $state(0);
  let selectedIndicatorHeight = $state(0);
  let selectedIndicatorVisible = $state(false);
  let selectedGlowLeft = $state(0);
  let selectedGlowTop = $state(0);
  let selectedGlowHeight = $state(0);
  let selectedGlowVisible = $state(false);
  let selectedResultElement: HTMLElement | null = null;
  let selectedResultIsChild = false;
  let pendingSelectedIndicatorFrame: number | null = null;

  function handleGlobalKeydown(e: KeyboardEvent) {
    const hotkey = searchConfig.hotkey;
    if (!hotkey.enabled || !searchConfig.enabled) {
      return;
    }

    const matchesModifier = hotkey.metaOrCtrl ? e.metaKey || e.ctrlKey : true;
    const matchesKey = e.key.toLowerCase() === hotkey.key.toLowerCase();
    if (matchesModifier && matchesKey) {
      e.preventDefault();
      searchState.toggle();
    }
  }

  onMount(() => {
    window.addEventListener("keydown", handleGlobalKeydown);
    return () => {
      window.removeEventListener("keydown", handleGlobalKeydown);
    };
  });

  $effect(() => {
    if (!searchConfig.enabled) return;
    if (searchState.isOpen && inputRef) {
      const initialMode = searchState.consumeInitialMode();
      if (initialMode) mode = initialMode;
      inputRef.focus();
    }
  });

  $effect(() => {
    void results;
    selectedIndex = 0;
  });

  function updateSelectedIndicators() {
    if (!resultsRef || !selectedResultElement) {
      selectedIndicatorVisible = false;
      selectedGlowVisible = false;
      return;
    }

    const resultsRect = resultsRef.getBoundingClientRect();
    const nodeRect = selectedResultElement.getBoundingClientRect();

    selectedIndicatorTop = nodeRect.top - resultsRect.top;
    selectedIndicatorHeight = nodeRect.height;
    selectedIndicatorVisible = true;

    selectedGlowLeft = nodeRect.left - resultsRect.left + 12;
    selectedGlowTop = selectedIndicatorTop;
    selectedGlowHeight = selectedIndicatorHeight;
    selectedGlowVisible = selectedResultIsChild;
  }

  function scheduleSelectedIndicatorUpdate() {
    if (typeof window === "undefined") {
      updateSelectedIndicators();
      return;
    }

    if (pendingSelectedIndicatorFrame !== null) {
      window.cancelAnimationFrame(pendingSelectedIndicatorFrame);
    }

    pendingSelectedIndicatorFrame = window.requestAnimationFrame(() => {
      pendingSelectedIndicatorFrame = null;
      updateSelectedIndicators();
    });
  }

  function registerResult(
    node: HTMLElement,
    params: { selected: boolean; child: boolean },
  ) {
    if (params.selected) {
      selectedResultElement = node;
      selectedResultIsChild = params.child;
      scheduleSelectedIndicatorUpdate();
    }

    return {
      update(nextParams: { selected: boolean; child: boolean }) {
        if (nextParams.selected) {
          selectedResultElement = node;
          selectedResultIsChild = nextParams.child;
          scheduleSelectedIndicatorUpdate();
        } else if (selectedResultElement === node) {
          selectedResultElement = null;
          selectedResultIsChild = false;
          scheduleSelectedIndicatorUpdate();
        }
      },
      destroy() {
        if (selectedResultElement === node) {
          selectedResultElement = null;
          selectedResultIsChild = false;
          scheduleSelectedIndicatorUpdate();
        }
      },
    };
  }

  $effect(() => {
    const index = selectedIndex;
    const resultCount = results.length;
    void index;
    void resultCount;

    scheduleSelectedIndicatorUpdate();

    if (typeof window === "undefined") return;

    window.addEventListener("resize", scheduleSelectedIndicatorUpdate);

    return () => {
      window.removeEventListener("resize", scheduleSelectedIndicatorUpdate);
      if (pendingSelectedIndicatorFrame !== null) {
        window.cancelAnimationFrame(pendingSelectedIndicatorFrame);
        pendingSelectedIndicatorFrame = null;
      }
    };
  });

  function close() {
    searchState.close();
  }

  function handleKeydown(e: KeyboardEvent) {
    if (!searchState.isOpen) return;

    if (e.key === "Escape") {
      e.preventDefault();
      close();
      return;
    }

    if (results.length === 0) {
      return;
    }

    if (e.key === "ArrowDown") {
      e.preventDefault();
      selectedIndex = (selectedIndex + 1) % results.length;
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      selectedIndex = (selectedIndex - 1 + results.length) % results.length;
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (results[selectedIndex]) {
        selectResult(results[selectedIndex]);
      }
    }
  }

  $effect(() => {
    if (!searchConfig.enabled) return;
    if (searchState.isOpen) {
      window.addEventListener("keydown", handleKeydown);
      return () => {
        window.removeEventListener("keydown", handleKeydown);
      };
    }
  });

  function selectResult(result: ReturnType<typeof searchContent>[number]) {
    const href = `${result.slug}${result.anchor ?? ""}`;
    // @ts-expect-error arg cannot be cast as `resolve`'s expected type
    void goto(resolve(href));
    close();
  }

  onNavigate(() => {
    close();
  });

  function highlight(text: string, search: string) {
    if (!search.trim()) return [{ text, highlight: false }];

    const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const regex = new RegExp(`(${escapedSearch})`, "gi");
    const parts = text.split(regex);

    return parts.map((part) => ({
      text: part,
      highlight: part.toLowerCase() === search.toLowerCase(),
    }));
  }
</script>

{#if searchConfig.enabled && searchState.isOpen}
  <Overlay onClose={close} zIndex={60} backdropCursor="default">
    <div
      class="w-full max-w-164 rounded-lg bg-background shadow-2xl card"
      role="document"
      transition:scale={{
        duration: 300,
        start: 0.95,
        easing: cubicOut,
      }}
      onoutroend={() => {
        query = "";
        contentHeight = 0;
      }}
    >
      <div
        class="relative flex items-center px-3 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:shadow-2xs after:shadow-white after:content-[''] dark:after:bg-background-inset dark:after:shadow-border"
      >
        <Search size={24} class="mr-2 text-foreground-muted/70" />
        <input
          bind:this={inputRef}
          bind:value={query}
          class="command-palette-input flex h-12 w-full bg-transparent text-base tracking-normal text-foreground placeholder:text-foreground-muted/70 focus:outline-none focus-visible:border-none! focus-visible:ring-0! focus-visible:ring-offset-0! focus-visible:outline-none!"
          placeholder={searchConfig.dialogPlaceholder}
          aria-label={searchConfig.dialogPlaceholder}
        />
        <div
          class="mr-2 flex shrink-0 items-center rounded-md border border-border bg-background-inset p-0.5"
          aria-label="Режим поиска"
        >
          {#each modes as item (item.value)}
            {@const Icon = item.icon}
            <button
              type="button"
              class={cn(
                "flex size-7 items-center justify-center rounded-sm text-foreground-muted transition",
                mode === item.value && "bg-background text-accent shadow-sm",
              )}
              onclick={() => (mode = item.value)}
              aria-label={item.label}
              title={item.label}
            >
              <Icon size={15} />
            </button>
          {/each}
        </div>
        <kbd
          class="pointer-events-none inset-shadow relative hidden h-5 items-center gap-1 rounded-[calc(var(--radius-base)*1.5)] bg-background-inset px-1.5 font-mono text-[10px] font-medium tracking-normal text-foreground-muted/70 select-none sm:flex"
        >
          ESC
        </kbd>
      </div>

      <div
        class="overflow-hidden transition-[height] duration-300 ease-out"
        style="height: {contentHeight}px"
      >
        <div bind:clientHeight={contentHeight}>
          {#if results.length > 0}
            <ScrollArea
              viewportStyle="mask-image: linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent); -webkit-mask-image: linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent);"
              viewportClass="max-h-96 p-2"
            >
              <div
                class="command-results relative flex flex-col"
                bind:this={resultsRef}
                style={`
										--command-selected-top: ${selectedIndicatorTop.toString()}px;
										--command-selected-height: ${selectedIndicatorHeight.toString()}px;
										--command-selected-opacity: ${selectedIndicatorVisible ? "1" : "0"};
										--command-glow-left: ${selectedGlowLeft.toString()}px;
										--command-glow-top: ${selectedGlowTop.toString()}px;
										--command-glow-height: ${selectedGlowHeight.toString()}px;
										--command-glow-opacity: ${selectedGlowVisible ? "1" : "0"};
									`}
              >
                {#each results as result, i (`${result.slug}${result.anchor ?? ""}${i.toString()}`)}
                  {@const isChild =
                    result.matchType === "heading" ||
                    result.matchType === "content"}
                  {@const isSelected = i === selectedIndex}
                  <button
                    class={cn(
                      "group relative z-10 flex w-full flex-col items-start gap-1 rounded-sm px-3 py-2 text-sm font-medium tracking-normal transition-colors duration-150 ease-out",
                      isChild && "pl-8",
                      isSelected
                        ? "text-foreground"
                        : "text-foreground hover:text-foreground",
                    )}
                    onclick={() => {
                      selectResult(result);
                    }}
                    onmouseenter={() => (selectedIndex = i)}
                    use:registerResult={{
                      selected: isSelected,
                      child: isChild,
                    }}
                  >
                    {#if isChild}
                      <div
                        class={cn(
                          "absolute top-0 bottom-0 left-3 w-px bg-border",
                        )}
                      ></div>
                    {/if}

                    <div class="flex w-full flex-col items-start gap-0.5">
                      {#if result.matchType !== "content"}
                        <div
                          class="flex items-center gap-2 font-medium tracking-normal"
                        >
                          {#if result.matchType === "heading"}
                            <span class="opacity-70">#</span>
                          {/if}
                          <span>
                            {#each highlight(result.heading ?? result.title, query) as part, index (index)}
                              {#if part.highlight}
                                <span class="text-accent">{part.text}</span>
                              {:else}
                                {part.text}
                              {/if}
                            {/each}
                          </span>
                        </div>
                      {/if}
                      {#if result.snippet}
                        <div
                          class="line-clamp-1 text-left text-xs font-medium tracking-normal text-foreground-muted"
                        >
                          {#each highlight(result.snippet, query) as part, index (index)}
                            {#if part.highlight}
                              <span class="text-accent">{part.text}</span>
                            {:else}
                              {part.text}
                            {/if}
                          {/each}
                        </div>
                      {/if}
                    </div>
                  </button>
                {/each}
              </div>
            </ScrollArea>
          {:else if query}
            <div
              class="py-6 text-center text-sm tracking-normal text-foreground-muted/70"
            >
              {searchConfig.noResultsLabel}
            </div>
          {/if}
        </div>
      </div>
      <div
        class="relative flex w-full flex-row items-center justify-start gap-2 rounded-b-lg bg-background p-2 after:absolute after:inset-x-0 after:top-0 after:h-px after:bg-border after:shadow-2xs after:shadow-white after:content-[''] dark:after:bg-background-inset dark:after:shadow-border"
      >
        <kbd
          class="pointer-events-none inset-shadow relative hidden h-5 items-center gap-1 rounded-[calc(var(--radius-base)*1.5)] bg-background-inset px-1.5 font-mono text-[10px] font-medium text-foreground-muted/70 select-none sm:flex"
        >
          <Return class="size-3" />
        </kbd>
        <span
          class="text-xs font-medium tracking-normal text-foreground-muted/70"
        >
          {searchConfig.submitHintLabel}
        </span>
      </div>
    </div>
  </Overlay>
{/if}

<style>
  .command-palette-input:focus,
  .command-palette-input:focus-visible {
    outline: none !important;
    outline-color: transparent !important;
    outline-offset: 0 !important;
    box-shadow: none !important;
  }

  .command-results::before {
    content: "";
    position: absolute;
    inset-inline: 0px;
    top: 0;
    height: var(--command-selected-height);
    border-radius: var(--radius-sm);
    background: var(--color-background-muted);
    opacity: var(--command-selected-opacity);
    pointer-events: none;
    transform: translateY(var(--command-selected-top));
    transition:
      transform 150ms ease-out,
      height 150ms ease-out,
      opacity 150ms ease-out;
    will-change: transform, height, opacity;
    z-index: 0;
  }

  .command-results::after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 1px;
    height: var(--command-glow-height);
    border-radius: 9999px;
    background-image: linear-gradient(
      to bottom,
      transparent,
      oklch(from var(--color-accent) l c h / 0.68) 18%,
      var(--color-accent) 50%,
      oklch(from var(--color-accent) l c h / 0.68) 82%,
      transparent
    );
    filter: drop-shadow(0 0 6px oklch(from var(--color-accent) l c h / 0.38));
    opacity: var(--command-glow-opacity);
    pointer-events: none;
    transform: translate(var(--command-glow-left), var(--command-glow-top));
    transition:
      transform 150ms ease-out,
      height 150ms ease-out,
      opacity 150ms ease-out;
    will-change: transform, height, opacity;
    z-index: 20;
  }

  @media (max-width: 1023.98px) {
    .command-results::before {
      display: none;
    }
  }
</style>

Особенности

  • Горячая клавиша: открытие по Ctrl+K или Cmd+K
  • Клавиатурная навигация: / для выбора, Enter для перехода, Esc для закрытия
  • Выделение совпадений: подсветка найденного текста акцентным цветом
  • Группировка результатов: заголовки страниц + заголовки секций + контент
  • Active-индикатор: вертикальная линия с glow на выбранном элементе
  • Плавные анимации: scale + fade при появлении/скрытии

Составные части

Компонент Назначение
SearchTrigger Кнопка-триггер с placeholder и горячей клавишей
CommandPalette Модальное окно с поиском и результатами
Overlay Затемнение фона и закрытие по клику
ScrollArea Прокрутка результатов с mask-градиентом

Пропсы CommandPalette

Пропс Тип По умолчанию Описание
searchConfig SectionUiConfig['search'] contentUiDefaults.search Конфигурация поиска

Пропсы SearchTrigger

Пропс Тип По умолчанию Описание
class string '' Дополнительные CSS-классы
searchConfig SectionUiConfig['search'] contentUiDefaults.search Конфигурация поиска

Использование

<script>
	import CommandPalette from '$lib/components/content/search/CommandPalette.svelte';
	import SearchTrigger from '$lib/components/content/search/SearchTrigger.svelte';
</script>

<!-- Триггер в сайдбаре -->
<SearchTrigger />

<!-- Модальное окно (рендерится один раз в layout) -->
<CommandPalette />
<script>
	import CommandPalette from '$lib/components/content/search/CommandPalette.svelte';
	import SearchTrigger from '$lib/components/content/search/SearchTrigger.svelte';
</script>

<!-- Триггер в сайдбаре -->
<SearchTrigger />

<!-- Модальное окно (рендерится один раз в layout) -->
<CommandPalette />

Конфигурация поиска

Поиск настраивается через content-ui.ts:

search: {
  enabled: true,
  triggerPlaceholder: 'Поиск...',
  dialogPlaceholder: 'Найти документацию...',
  hotkey: {
    enabled: true,
    key: 'k',
    metaOrCtrl: true,
    label: '⌘K'
  },
  maxGroups: 5,
  maxChildrenPerGroup: 3,
  submitHintLabel: 'перейти',
  noResultsLabel: 'Ничего не найдено'
}
search: {
  enabled: true,
  triggerPlaceholder: 'Поиск...',
  dialogPlaceholder: 'Найти документацию...',
  hotkey: {
    enabled: true,
    key: 'k',
    metaOrCtrl: true,
    label: '⌘K'
  },
  maxGroups: 5,
  maxChildrenPerGroup: 3,
  submitHintLabel: 'перейти',
  noResultsLabel: 'Ничего не найдено'
}

Состояние поиска

Глобальное состояние управляется через searchState из $lib/stores/search.svelte:

import { searchState } from '$lib/stores/search.svelte';

searchState.open();   // открыть поиск
searchState.close();  // закрыть поиск
searchState.toggle(); // переключить
searchState.isOpen;   // текущее состояние
import { searchState } from '$lib/stores/search.svelte';

searchState.open();   // открыть поиск
searchState.close();  // закрыть поиск
searchState.toggle(); // переключить
searchState.isOpen;   // текущее состояние

Анимации

Появление модального окна

transition:scale={{
  duration: 300,
  start: 0.95,
  easing: cubicOut
}}
transition:scale={{
  duration: 300,
  start: 0.95,
  easing: cubicOut
}}

Hover-индикатор на результате

Плавающий фон с плавной сменой позиции:

.command-results::before {
  transform: translateY(var(--command-selected-top));
  transition: transform 150ms ease-out;
}
.command-results::before {
  transform: translateY(var(--command-selected-top));
  transition: transform 150ms ease-out;
}

Glow-индикатор на вложенном результате

Для результатов-заголовков и контента показывается вертикальная линия:

.command-results::after {
  background-image: linear-gradient(to bottom, transparent, var(--color-accent), transparent);
  filter: drop-shadow(0 0 6px oklch(...));
}
.command-results::after {
  background-image: linear-gradient(to bottom, transparent, var(--color-accent), transparent);
  filter: drop-shadow(0 0 6px oklch(...));
}

Highlighting

Найденные совпадения подсвечиваются акцентным цветом:

function highlight(text: string, search: string) {
  const regex = new RegExp(`(${search})`, 'gi');
  return text.split(regex).map(part => ({
    text: part,
    highlight: part.toLowerCase() === search.toLowerCase()
  }));
}
function highlight(text: string, search: string) {
  const regex = new RegExp(`(${search})`, 'gi');
  return text.split(regex).map(part => ({
    text: part,
    highlight: part.toLowerCase() === search.toLowerCase()
  }));
}

Зависимости

  • Overlay — модальный фон
  • ScrollArea — скролл контейнер
  • carbon-icons-svelte — Search, Return
  • $lib/utils/searchsearchContent()
  • $lib/stores/search.sveltesearchState