Skip to content
Merged
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
2 changes: 2 additions & 0 deletions frontend/src/ts/components/common/Setting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type SettingProps = {
inputs?: JSXElement;
fullWidthInputs?: JSXElement;
breakpoints?: "none" | "normal" | "narrow";
class?: string;
} & ParentProps &
(
| {
Expand Down Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/ts/components/pages/settings/QuickNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div class={props.class}>
<div
class={cn(
"mx-auto rounded bg-sub-alt text-em-xs",
Expand Down
137 changes: 137 additions & 0 deletions frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Config, ConfigKey, ConfigSchema } from "@monkeytype/schemas/configs";
import { createForm } from "@tanstack/solid-form";
import { For, JSXElement } from "solid-js";
import { z } from "zod";

import {
configMetadata,
getOptionLabel,
getOptionSearchKeywords,
getVisibleOptions,
} from "../../../config/metadata";
import { setConfig } from "../../../config/setters";
import { getConfig } from "../../../config/store";
import { useSavedIndicator } from "../../../hooks/useSavedIndicator";
import { cn } from "../../../utils/cn";
import { Button } from "../../common/Button";
import { InputField } from "../../ui/form/InputField";
import { fromSchema } from "../../ui/form/utils";
import { SearchableSetting } from "./SearchableSetting";

export function SearchableAutoSetting<T extends ConfigKey>(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 (
<div class="grid w-full gap-2">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field
//@ts-expect-error -- i think because props.key is a key of config, which is a zod schema, the typechecker gives up (too complex to infer or something)
name={props.key}
validators={{
onChange: ({ value }) => {
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) => (
<div class="relative">
<InputField
field={field}
schema={ConfigSchema.shape[props.key]}
placeholder={
configMetadata[props.key].displayString ?? props.key
}
type="number"
resetToDefaultIfEmptyOnBlur
/>
<savedIndicator.component />
</div>
)}
/>
</form>
</div>
);
}

const options = getVisibleOptions(props.key);

if (options !== undefined) {
return (
<div
class={cn(
"grid grid-cols-[repeat(auto-fit,minmax(4.5rem,1fr))] gap-2",
props.wide && "grid-cols-[repeat(auto-fit,minmax(13.5rem,1fr))]",
)}
>
<For each={options}>
{(option) => (
<Button
active={getConfig[props.key] === option}
onClick={() => {
if (getConfig[props.key] === option) return;
props.onOptionClick?.(option);
setConfig(props.key, option);
}}
>
{getOptionLabel(props.key, option)}
</Button>
)}
</For>
</div>
);
}
return undefined;
};

return (
<SearchableSetting
key={props.key}
title={configMetadata[props.key].displayString ?? props.key}
fa={configMetadata[props.key].fa}
description={configMetadata[props.key].description}
extraSearchKeywords={getOptionSearchKeywords(props.key)}
inputs={!props.wide ? autoInputs() : props.inputs}
fullWidthInputs={
props.wide ? (autoInputs() ?? props.inputs) : props.inputs
}
/>
);
}
47 changes: 47 additions & 0 deletions frontend/src/ts/components/pages/settings/SearchableSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Setting
{...props}
class={cn(props.class, !settingMatchesSearch(haystack()) && "hidden")}
/>
);
}
Loading
Loading