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

Интерфейс

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

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


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

Модальное окно поиска с командной палитрой. Поддерживает горячие клавиши (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 } 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';

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

	let query = $state('');
	let results = $derived(searchContent(query, searchConfig));
	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) {
			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}
				/>
				<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 } 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';

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

	let query = $state('');
	let results = $derived(searchContent(query, searchConfig));
	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) {
			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}
				/>
				<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