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; +}