Палитра поиска
Модальное окно поиска с командной палитрой. Поддерживает горячие клавиши (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/search—searchContent()$lib/stores/search.svelte—searchState
