-
Notifications
You must be signed in to change notification settings - Fork 58
Add search functionality to sidebar #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| 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(); | ||
| } | ||
| } | ||
|
|
||
| function handleInputKeydown(e: KeyboardEvent) { | ||
| if (e.key === "Escape") { | ||
| e.preventDefault(); | ||
| clearSearch(); | ||
| inputRef?.blur(); | ||
| } | ||
| } | ||
|
Comment on lines
+21
to
+27
|
||
|
|
||
| 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> | ||
| 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> |
| 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; |
There was a problem hiding this comment.
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"). Prefere.code === "KeyK"or normalizing withe.key.toLowerCase() === "k"for robustness.