From 4b9211e3bf3e8dbad0e4f3c26809cec25f454a92 Mon Sep 17 00:00:00 2001 From: Om Tiwari Pandey <115196863+OmTiwariPandey@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:15:33 +0530 Subject: [PATCH] feat: add settings page search (@OmTiwariPandey) (#8135) ### Description The settings page has a lot of options cluttered, and finding a setting would mean scrolling through everything. you can press escape to search, yes but still it just makes you quickly change a setting, not searching it and its neighboring settings. This PR adds a search box at the top of the settings page. As you type, matching settings show up in a dropdown, and picking one scrolls to that setting and briefly flashes it so it's easy to spot. You can also use arrow keys and enter to do the same. A few notes on the approach: - The list it searches is built from the settings actually rendered on the page, so options that are currently hidden (e.g. the keymap settings when keymap mode is off) don't appear, and every result is guaranteed to scroll to something that exists. - The "scroll to and flash" behavior reuses the highlight that the per-setting copy-link buttons and ?highlight= URLs already use. I moved that logic into a small shared helper so the search and the existing URL handler use the same code instead of duplicating it. - Matching is a simple case-insensitive match on the setting name, capped at 8 results. Escape clears the search and clicking outside closes the dropdown. ### Checks - [x] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info) - [x] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title. Closes # --------- Co-authored-by: Miodec --- frontend/src/ts/components/common/Setting.tsx | 2 + .../ts/components/pages/settings/QuickNav.tsx | 4 +- .../pages/settings/SearchableAutoSetting.tsx | 137 ++++++++ .../pages/settings/SearchableSetting.tsx | 47 +++ .../pages/settings/SettingsPage.tsx | 319 ++++++------------ .../pages/settings/SettingsSearch.tsx | 44 +++ .../custom-setting/AnimationFpsLimit.tsx | 4 +- .../custom-setting/AutoSwitchTheme.tsx | 4 +- .../custom-setting/CustomBackground.tsx | 10 +- .../CustomBackgroundFilters.tsx | 4 +- .../custom-setting/CustomLayoutfluid.tsx | 4 +- .../custom-setting/CustomPolyglot.tsx | 4 +- .../settings/custom-setting/FontFamily.tsx | 10 +- .../pages/settings/custom-setting/Funbox.tsx | 4 +- .../settings/custom-setting/ImportExport.tsx | 4 +- .../settings/custom-setting/KeymapLayout.tsx | 4 +- .../settings/custom-setting/KeymapSize.tsx | 4 +- .../settings/custom-setting/Language.tsx | 4 +- .../pages/settings/custom-setting/Layout.tsx | 4 +- .../settings/custom-setting/MaxLineWidth.tsx | 4 +- .../pages/settings/custom-setting/MinAcc.tsx | 4 +- .../settings/custom-setting/MinBurst.tsx | 4 +- .../settings/custom-setting/MinSpeed.tsx | 4 +- .../settings/custom-setting/PaceCaret.tsx | 10 +- .../pages/settings/custom-setting/Presets.tsx | 4 +- .../settings/custom-setting/SoundVolume.tsx | 4 +- .../pages/settings/custom-setting/Tags.tsx | 4 +- .../pages/settings/custom-setting/Theme.tsx | 4 +- frontend/src/ts/config/metadata.tsx | 45 +++ frontend/src/ts/states/settings-search.ts | 60 ++++ 30 files changed, 493 insertions(+), 271 deletions(-) create mode 100644 frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx create mode 100644 frontend/src/ts/components/pages/settings/SearchableSetting.tsx create mode 100644 frontend/src/ts/components/pages/settings/SettingsSearch.tsx create mode 100644 frontend/src/ts/states/settings-search.ts diff --git a/frontend/src/ts/components/common/Setting.tsx b/frontend/src/ts/components/common/Setting.tsx index fc5d7e6d8947..a9c4a0727277 100644 --- a/frontend/src/ts/components/common/Setting.tsx +++ b/frontend/src/ts/components/common/Setting.tsx @@ -18,6 +18,7 @@ export type SettingProps = { inputs?: JSXElement; fullWidthInputs?: JSXElement; breakpoints?: "none" | "normal" | "narrow"; + class?: string; } & ParentProps & ( | { @@ -51,6 +52,7 @@ export function Setting(props: SettingProps): JSXElement { "group grid gap-2", "-m-4 rounded-double p-4", // "animate-[ring-flash_4s_ease-in_forwards]", + props.class, )} {...("key" in props && props.key !== undefined ? { "data-setting-key": props.key } diff --git a/frontend/src/ts/components/pages/settings/QuickNav.tsx b/frontend/src/ts/components/pages/settings/QuickNav.tsx index 64d14d006e38..e03dff2e6150 100644 --- a/frontend/src/ts/components/pages/settings/QuickNav.tsx +++ b/frontend/src/ts/components/pages/settings/QuickNav.tsx @@ -3,10 +3,10 @@ import { JSXElement } from "solid-js"; import { cn } from "../../../utils/cn"; import { Button } from "../../common/Button"; -export function QuickNav(): JSXElement { +export function QuickNav(props: { class?: string }): JSXElement { const buttonClass = "px-3 py-3"; return ( -
+
(props: { + key: T; + inputs?: JSXElement; + wide?: boolean; + onOptionClick?: (value: Config[T]) => void; +}): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + [props.key]: getConfig[props.key], + }, + onSubmit: ({ value }) => { + const val = parseFloat(String(value[props.key])); + if (val === getConfig[props.key]) return; + savedIndicator.flash(); + setConfig(props.key, val as Config[T]); + }, + })); + + const autoInputs = () => { + if ( + ConfigSchema.shape[props.key]._def.typeName === + z.ZodFirstPartyTypeKind.ZodNumber + ) { + return ( +
+
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseFloat(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema( + ConfigSchema.shape[props.key] as z.ZodNumber, + )({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + +
+ ); + } + + const options = getVisibleOptions(props.key); + + if (options !== undefined) { + return ( +
+ + {(option) => ( + + )} + +
+ ); + } + return undefined; + }; + + return ( + + ); +} diff --git a/frontend/src/ts/components/pages/settings/SearchableSetting.tsx b/frontend/src/ts/components/pages/settings/SearchableSetting.tsx new file mode 100644 index 000000000000..927a1707f9fa --- /dev/null +++ b/frontend/src/ts/components/pages/settings/SearchableSetting.tsx @@ -0,0 +1,47 @@ +import { createMemo, JSXElement } from "solid-js"; + +import { + registerSearchable, + settingMatchesSearch, +} from "../../../states/settings-search"; +import { cn } from "../../../utils/cn"; +import { Setting, SettingProps } from "../../common/Setting"; + +export type SearchableSettingProps = SettingProps & { + // extra text (e.g. option labels) the search filter also matches against + extraSearchKeywords?: string; +}; + +// pull plain text out of a (possibly JSX) description so search can match it. +// solid renders JSX to real DOM nodes/arrays, so we can read their textContent. +function textOf(node: string | JSXElement): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textOf).join(" "); + if (node instanceof Node) return node.textContent ?? ""; + return ""; +} + +// a Setting that hides itself when it doesn't match the active settings search. +// hides (via css) instead of unmounting so typing doesn't remount every setting +// on each keypress; the hidden class stays on the Setting root so the section +// auto-collapse selector keeps working. +export function SearchableSetting(props: SearchableSettingProps): JSXElement { + // static per setting — only the query changes as the user types, so build once + const haystack = createMemo(() => + [props.title, textOf(props.description), props.extraSearchKeywords ?? ""] + .join(" ") + .toLowerCase(), + ); + + // scoring is global (a setting shows only if it ties the best match across all + // settings), so register this haystack for the shared best-match computation. + // oxlint-disable-next-line solid/reactivity -- getter stored, called in a tracked memo + registerSearchable(haystack); + + return ( + + ); +} diff --git a/frontend/src/ts/components/pages/settings/SettingsPage.tsx b/frontend/src/ts/components/pages/settings/SettingsPage.tsx index df344727ae74..9d098917df63 100644 --- a/frontend/src/ts/components/pages/settings/SettingsPage.tsx +++ b/frontend/src/ts/components/pages/settings/SettingsPage.tsx @@ -1,11 +1,7 @@ -import { Config, ConfigSchema } from "@monkeytype/schemas/configs"; -import { createForm } from "@tanstack/solid-form"; -import { createResource, createSignal, For, JSXElement, Show } from "solid-js"; +import { createResource, createSignal, JSXElement, Show } from "solid-js"; import { z } from "zod"; import { resetConfig } from "../../../config/lifecycle"; -import { configMetadata, OptionMetadata } from "../../../config/metadata"; -import { setConfig } from "../../../config/setters"; import { getConfig } from "../../../config/store"; import { playTimeWarning, @@ -13,22 +9,18 @@ import { previewError, } from "../../../controllers/sound-controller"; import { useLocalStorage } from "../../../hooks/useLocalStorage"; -import { useSavedIndicator } from "../../../hooks/useSavedIndicator"; import { isAuthenticated } from "../../../states/core"; import { showModal } from "../../../states/modals"; +import { isSettingsSearchActive } from "../../../states/settings-search"; import { showSimpleModal } from "../../../states/simple-modal"; import { cn } from "../../../utils/cn"; import fileStorage from "../../../utils/file-storage"; import { wordsToCamelCase } from "../../../utils/strings"; -import { getOptions } from "../../../utils/zod"; import { Anime, AnimeShow } from "../../common/anime"; import { Button } from "../../common/Button"; import { Fa } from "../../common/Fa"; import { Page } from "../../common/Page"; -import { Setting } from "../../common/Setting"; import { CommandlineHotkey } from "../../hotkeys/CommandlineHotkey"; -import { InputField } from "../../ui/form/InputField"; -import { fromSchema } from "../../ui/form/utils"; import { AnimationFpsLimit } from "./custom-setting/AnimationFpsLimit"; import { AutoSwitchTheme } from "./custom-setting/AutoSwitchTheme"; import { CustomBackground } from "./custom-setting/CustomBackground"; @@ -52,6 +44,9 @@ import { SoundVolume } from "./custom-setting/SoundVolume"; import { Tags } from "./custom-setting/Tags"; import { Theme } from "./custom-setting/Theme"; import { QuickNav } from "./QuickNav"; +import { SearchableAutoSetting } from "./SearchableAutoSetting"; +import { SearchableSetting } from "./SearchableSetting"; +import { SettingsSearch } from "./SettingsSearch"; export function SettingsPage(): JSXElement { const [hasLocalBg] = createResource( @@ -62,54 +57,63 @@ export function SettingsPage(): JSXElement { return (
- + {/* while filtering, only the matching settings stay visible; everything + else is hidden with css so nothing unmounts while typing */} + -
+
tip: You can also change all these settings quickly using the command line
( )
-
+ + {/* while filtering, lay the matching sections out with a uniform gap */} +
- + - - - - - - + + + + + + - +
- - - - - - - - - - + + + + + + + + + + - +
- { @@ -117,7 +121,7 @@ export function SettingsPage(): JSXElement { void previewClick(option); }} /> - { @@ -125,7 +129,7 @@ export function SettingsPage(): JSXElement { void previewError(option); }} /> - { @@ -135,61 +139,61 @@ export function SettingsPage(): JSXElement { />
- - + + - - + +
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + - - - + + +
- - + + - +
- - - - + + + +
- - + - -
+
Account settings have moved. You can now access them by hovering over @@ -287,10 +296,20 @@ function Section(props: { title: string; children: JSXElement }): JSXElement { const [isOpen, setIsOpen] = createSignal(true); return ( -
+ ); } - -function AutoSetting(props: { - key: T; - inputs?: JSXElement; - wide?: boolean; - onOptionClick?: (value: Config[T]) => void; -}): JSXElement { - const savedIndicator = useSavedIndicator(); - - const form = createForm(() => ({ - defaultValues: { - [props.key]: getConfig[props.key], - }, - onSubmit: ({ value }) => { - const val = parseFloat(String(value[props.key])); - if (val === getConfig[props.key]) return; - savedIndicator.flash(); - setConfig(props.key, val as Config[T]); - }, - })); - - const autoInputs = () => { - if ( - ConfigSchema.shape[props.key]._def.typeName === - z.ZodFirstPartyTypeKind.ZodNumber - ) { - return ( -
-
{ - e.preventDefault(); - e.stopPropagation(); - void form.handleSubmit(); - }} - > - { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema( - ConfigSchema.shape[props.key] as z.ZodNumber, - )({ - value: val, - }); - }, - onBlur: () => { - void form.handleSubmit(); - }, - }} - children={(field) => ( -
- - -
- )} - /> - -
- ); - } - - const options = getOptions(ConfigSchema.shape[props.key])?.filter((opt) => { - const optionsMeta = ( - configMetadata[props.key] as { - optionsMetadata?: Record | undefined; - } - ).optionsMetadata; - - const optionMeta = optionsMeta?.[String(opt)]; - - return optionMeta?.visible !== false; - }); - - if (options !== undefined) { - return ( -
- - {(option) => { - const text = () => { - const optionsMeta = configMetadata[props.key] - .optionsMetadata as - | Record - | undefined; - const match = optionsMeta?.[String(option)]; - if (match?.displayString !== undefined) { - return match.displayString; - } - - if (option === true) { - return "on"; - } - if (option === false) { - return "off"; - } - - return (option as string).toString().replace(/_/g, " "); - }; - return ( - - ); - }} - -
- ); - } - return undefined; - }; - - return ( - - ); -} diff --git a/frontend/src/ts/components/pages/settings/SettingsSearch.tsx b/frontend/src/ts/components/pages/settings/SettingsSearch.tsx new file mode 100644 index 000000000000..40c74540da13 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/SettingsSearch.tsx @@ -0,0 +1,44 @@ +import { JSXElement, onCleanup, Show } from "solid-js"; + +import { + getSettingsSearch, + setSettingsSearch, +} from "../../../states/settings-search"; +import { cn } from "../../../utils/cn"; +import { Button } from "../../common/Button"; +import { Fa } from "../../common/Fa"; + +export function SettingsSearch(): JSXElement { + // reset the filter when leaving the settings page + onCleanup(() => setSettingsSearch("")); + + return ( +
+ + setSettingsSearch(e.currentTarget.value)} + /> + +
+ ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx b/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx index 0f17bc74865f..9ff96a46f325 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx @@ -5,9 +5,9 @@ import { fpsLimitSchema, getfpsLimit, setfpsLimit } from "../../../../anim"; import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; import { Button } from "../../../common/Button"; import { Separator } from "../../../common/Separator"; -import { Setting } from "../../../common/Setting"; import { InputField } from "../../../ui/form/InputField"; import { fromSchema } from "../../../ui/form/utils"; +import { SearchableSetting } from "../SearchableSetting"; export function AnimationFpsLimit(): JSXElement { const savedIndicator = useSavedIndicator(); @@ -24,7 +24,7 @@ export function AnimationFpsLimit(): JSXElement { })); return ( - {configMetadata.customBackground.description} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx index 5a01de0fabaa..2f1aca854f97 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx @@ -4,8 +4,8 @@ import { configMetadata } from "../../../../config/metadata"; import { setConfig } from "../../../../config/setters"; import { getConfig } from "../../../../config/store"; import { applyCustomBackgroundFilters } from "../../../../controllers/theme-controller"; -import { Setting } from "../../../common/Setting"; import { Slider } from "../../../common/Slider"; +import { SearchableSetting } from "../SearchableSetting"; export function CustomBackgroundFilters(): JSXElement { let refBlur: HTMLInputElement | undefined = undefined; @@ -26,7 +26,7 @@ export function CustomBackgroundFilters(): JSXElement { }; return ( - @@ -35,10 +38,11 @@ export function FontFamily(): JSXElement { }; return ( - {configMetadata.fontFamily.description} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx index a13ec89540f9..8af71772921a 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx @@ -7,11 +7,11 @@ import { toggleFunbox } from "../../../../config/setters"; import { getConfig } from "../../../../config/store"; import { getActiveFunboxNames } from "../../../../test/funbox/list"; import { Button } from "../../../common/Button"; -import { Setting } from "../../../common/Setting"; +import { SearchableSetting } from "../SearchableSetting"; export function Funbox(): JSXElement { return ( - diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx index 334cbe43029a..314db8695a75 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx @@ -9,13 +9,13 @@ import { showEditPresetModal } from "../../../../states/edit-preset-modal"; import { showModal } from "../../../../states/modals"; import { showSimpleModal } from "../../../../states/simple-modal"; import { Button } from "../../../common/Button"; -import { Setting } from "../../../common/Setting"; +import { SearchableSetting } from "../SearchableSetting"; export function Presets(): JSXElement { const presets = usePresetsLiveQuery(); return ( - { const b1 = hexToHSL(a.bg); @@ -254,7 +254,7 @@ export function Theme(): JSXElement { ); return ( - ( + key: K, + option: ConfigSchemas.Config[K], +): OptionMetadata | undefined { + return ( + configMetadata[key] as { + optionsMetadata?: Record | undefined; + } + ).optionsMetadata?.[String(option)]; +} + +// the selectable options for a config key, excluding those marked visible:false +export function getVisibleOptions( + key: K, +): ConfigSchemas.Config[K][] | undefined { + return getOptions(ConfigSchemas.ConfigSchema.shape[key])?.filter( + (option) => + getOptionMetadata(key, option as ConfigSchemas.Config[K])?.visible !== + false, + ) as ConfigSchemas.Config[K][] | undefined; +} + +// the label shown for a single option (and used to match it while searching) +export function getOptionLabel( + key: K, + option: ConfigSchemas.Config[K], +): string { + const optionMeta = getOptionMetadata(key, option); + if (optionMeta?.displayString !== undefined) return optionMeta.displayString; + if (option === true) return "on"; + if (option === false) return "off"; + return String(option).replace(/_/g, " "); +} + +// all of a setting's visible option labels joined, so search can match on them +export function getOptionSearchKeywords( + key: K, +): string { + return (getVisibleOptions(key) ?? []) + .map((option) => getOptionLabel(key, option)) + .join(" "); +} diff --git a/frontend/src/ts/states/settings-search.ts b/frontend/src/ts/states/settings-search.ts new file mode 100644 index 000000000000..bf23de49a4f2 --- /dev/null +++ b/frontend/src/ts/states/settings-search.ts @@ -0,0 +1,60 @@ +import { createMemo, createSignal, onCleanup } from "solid-js"; + +// the current settings filter query, shared between the search input and the +// settings/sections that hide themselves when they don't match +export const [getSettingsSearch, setSettingsSearch] = createSignal(""); + +export const isSettingsSearchActive = (): boolean => + getSettingsSearch().trim() !== ""; + +// registry of every searchable setting's haystack getter. settings register on +// mount and clean up on unmount, so best-match scoring sees only live settings. +const [getHaystacks, setHaystacks] = createSignal string>>( + new Set(), + { + equals: false, + }, +); + +export function registerSearchable(haystack: () => string): void { + setHaystacks((s) => s.add(haystack)); + onCleanup(() => + setHaystacks((s) => { + s.delete(haystack); + return s; + }), + ); +} + +const queryTokens = createMemo(() => { + const query = getSettingsSearch().trim().toLowerCase(); + return query === "" ? [] : query.split(/\s+/); +}); + +// how many of the query's tokens appear in this haystack +function scoreOf(haystack: string, tokens: string[]): number { + let score = 0; + for (const token of tokens) if (haystack.includes(token)) score++; + return score; +} + +// the highest token-match count any live setting achieves for the current query. +// we only reveal settings that tie this, so a partial match shows only when +// nothing matches better (2/2 beats 1/2; 1/2 shows only if nothing hits 2/2). +const bestScore = createMemo(() => { + const tokens = queryTokens(); + if (tokens.length === 0) return 0; + let best = 0; + for (const haystack of getHaystacks()) { + const score = scoreOf(haystack(), tokens); + if (score > best) best = score; + } + return best; +}); + +export function settingMatchesSearch(haystack: string): boolean { + const tokens = queryTokens(); + if (tokens.length === 0) return true; + const best = bestScore(); + return best > 0 && scoreOf(haystack, tokens) === best; +}