Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/lib/components/HighlightText.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script lang="ts">
import type {MatchRange} from "$lib/utils/search";

const {text, ranges}: {text: string; ranges: MatchRange[]} = $props();

const parts = $derived.by(() => {
if (!ranges.length) return [{content: text, highlighted: false}];

const result: Array<{content: string; highlighted: boolean}> = [];
let lastIndex = 0;

for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
if (range.start > lastIndex) {
result.push({content: text.slice(lastIndex, range.start), highlighted: false});
}
result.push({content: text.slice(range.start, range.end), highlighted: true});
lastIndex = range.end;
}

if (lastIndex < text.length) {
result.push({content: text.slice(lastIndex), highlighted: false});
}

return result;
});
</script>

<span>
{#each parts as part, i (i)}
{#if part.highlighted}
<span class="highlight">{part.content}</span>
{:else}
{part.content}
{/if}
{/each}
</span>

<style>
.highlight {
color: var(--color-selected, #4a9eff);
font-weight: 600;
}
</style>
145 changes: 145 additions & 0 deletions src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<script lang="ts">
import search, {clearSearch} from "$lib/stores/search.svelte";
import {onMount} from "svelte";

let inputRef: HTMLInputElement | undefined = $state();
let isMac = $state(false);

onMount(() => {
isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
});

function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
inputRef?.focus();
}
}
Comment on lines +14 to +19
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Cmd/Ctrl+K shortcut check uses e.key === "k", which can fail with different keyboard layouts or when Shift is held (key may be "K"). Prefer e.code === "KeyK" or normalizing with e.key.toLowerCase() === "k" for robustness.

Copilot uses AI. Check for mistakes.

function handleInputKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
clearSearch();
inputRef?.blur();
}
}
Comment on lines +21 to +27
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape handling is currently contradictory: this input handler clears search and blurs the field, but the window-level handler in +layout.svelte also handles Escape while the search input is active and then refocuses it. Consolidate Escape handling in one place (or make the window handler ignore Escape when the active element is the search input) to avoid flicker/inconsistent focus behavior.

Copilot uses AI. Check for mistakes.

function clearInput() {
clearSearch();
inputRef?.focus();
}
</script>

<div class="search-container">
<div class="search-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
height="16"
viewBox="0 -960 960 960"
width="16"
fill="currentColor"
>
<path
d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"
/>
</svg>
</div>
<input
type="text"
bind:this={inputRef}
bind:value={search.query}
placeholder="Search..."
class="search-input"
onkeydown={handleInputKeydown}
/>
{#if search.query}
<button class="clear-button" onclick={clearInput} type="button" aria-label="Clear search">
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"
/>
</svg>
</button>
{:else}
<kbd
class="shortcut-hint"
aria-label="Press {isMac ? 'Command' : 'Control'} plus K to focus search"
>
{isMac ? "⌘K" : "Ctrl+K"}
</kbd>
{/if}
</div>

<style>
.search-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 0 10px 0px 5px;
background: var(--bg-level-2, rgba(0, 0, 0, 0.2));
border-radius: var(--radius-level-4, 6px);
border: 1px solid var(--border-level-1, transparent);
}

.search-container:focus-within {
border-color: var(--color-selected, #4a9eff);
}

.search-icon {
display: flex;
align-items: center;
color: var(--font-color-muted, #888);
flex-shrink: 0;
}

.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--font-color, #fff);
font-size: 0.9rem;
width: 100%;
}

.search-input::placeholder {
color: var(--font-color-muted, #888);
}

.clear-button {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--font-color-muted, #888);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}

.clear-button:hover {
color: var(--font-color, #fff);
}

.shortcut-hint {
font-family: inherit;
font-size: 0.75rem;
color: var(--font-color-muted, #888);
background: var(--bg-level-3, rgba(0, 0, 0, 0.3));
padding: 2px 6px;
border-radius: var(--radius-level-4, 4px);
border: 1px solid var(--border-level-1, rgba(255, 255, 255, 0.1));
flex-shrink: 0;
user-select: none;
}
</style>
59 changes: 59 additions & 0 deletions src/lib/components/SearchSubItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script lang="ts">
import HighlightText from "$lib/components/HighlightText.svelte";
import {setHighlightedSetting, clearSearch} from "$lib/stores/search.svelte";
import type {MatchedSetting} from "$lib/utils/search";
import {goto} from "$app/navigation";

const {
setting,
categoryRoute,
selected = false
}: {setting: MatchedSetting; categoryRoute: string; selected?: boolean} = $props();

async function handleClick() {
setHighlightedSetting(setting.id);
clearSearch();
// Restore focus to search input before navigation
const searchInput = document.querySelector(".search-input") as HTMLElement | null;
searchInput?.focus();
await goto(categoryRoute);
}
</script>

<button
type="button"
class="sub-item"
class:selected
onclick={handleClick}
role="option"
aria-selected={selected}
>
<HighlightText text={setting.name} ranges={setting.matchRanges} />
</button>

<style>
.sub-item {
display: flex;
align-items: center;
width: 100%;
padding: 6px;
padding-left: 24px;
background: transparent;
border: none;
border-radius: var(--radius-level-4, 6px);
color: var(--font-color-muted, #aaa);
font-size: 0.85rem;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}

.sub-item:hover {
background: var(--color-selected, rgba(255, 255, 255, 0.1));
color: var(--font-color, #fff);
}

.sub-item.selected {
background: var(--color-selected, rgba(255, 255, 255, 0.15));
}
</style>
54 changes: 38 additions & 16 deletions src/lib/components/Tab.svelte
Original file line number Diff line number Diff line change
@@ -1,42 +1,64 @@
<script lang="ts">
import {page} from "$app/state";
import type {Snippet} from "svelte";

import HighlightText from "$lib/components/HighlightText.svelte";
import type {MatchRange} from "$lib/utils/search";

interface Props {
children: Snippet;
icon: Snippet;
route?: string;
highlightRanges?: MatchRange[];
label?: string;
selected?: boolean;
}
const {children, icon, route = ""}: Props = $props();
const {
children,
icon,
route = "",
highlightRanges = [],
label = "",
selected = false
}: Props = $props();
const path = $derived(page.url.pathname);

const isExternal = $derived(route.startsWith("http"));
const target = $derived(isExternal ? "_blank" : "");
const rel = $derived(isExternal ? "noopener noreferrer" : "");

const selected = $derived(path === route);
</script>
const pageSelected = $derived(path === route);
const isSelected = $derived(pageSelected || selected);

const hasHighlight = $derived(highlightRanges.length > 0);
</script>

<!-- Why is eslint like this? -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve, svelte/first-attribute-linebreak -->
<a href={route}
class="nav-tab"
class:selected
{target}
{rel}
>
<a href={route} class="nav-tab" class:isSelected {target} {rel}>
<div class="tab-icon">{@render icon()}</div>
<div class="tab-label">{@render children()}</div>
<div class="tab-label">
{#if hasHighlight && label}
<HighlightText text={label} ranges={highlightRanges} />
{:else}
{@render children()}
{/if}
</div>
{#if isExternal}
<div class="tab-external">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="#e8eaed"
><path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/></svg
>
</div>
{/if}
</a>


<style>
.nav-tab {
display: flex;
Expand All @@ -50,7 +72,7 @@
text-decoration: none;
}

.nav-tab.selected {
.nav-tab.isSelected {
background: var(--color-selected);
}

Expand All @@ -63,7 +85,7 @@
font-size: 1rem;
flex: 1;
justify-content: flex-start;
color: var(--font-color)!important;
color: var(--font-color) !important;
text-decoration: none !important;
}

Expand All @@ -79,4 +101,4 @@
width: 20px;
height: 20px;
}
</style>
</style>
26 changes: 26 additions & 0 deletions src/lib/stores/search.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface SearchState {
query: string;
selectedIndex: number;
highlightedSettingId: string | null;
}

const search: SearchState = $state({
query: "",
selectedIndex: 0,
highlightedSettingId: null
});

export function clearSearch() {
search.query = "";
search.selectedIndex = 0;
}

export function setHighlightedSetting(id: string | null) {
search.highlightedSettingId = id;
}

export function clearHighlightedSetting() {
search.highlightedSettingId = null;
}

export default search;
Loading