Files
yaak-mountain-loop/packages/ui/src/components/settings/SettingRow.tsx
T
2026-11-06 10:37:42 -07:00

458 lines
11 KiB
TypeScript

import classNames from "classnames";
import type { ReactNode } from "react";
import { Checkbox } from "../Checkbox";
import { CopyIconButton as BaseCopyIconButton, type CopyIconButtonProps } from "../CopyIconButton";
import { useEditorHost } from "../EditorHost";
import { Icon } from "../Icon";
import { IconButton, type IconButtonProps } from "../IconButton";
import { PlainInput } from "../PlainInput";
import type { RadioDropdownItem } from "../RadioDropdown";
import { Select } from "../Select";
/** Copies through the host, quietly, as the rows' values do */
function CopyIconButton(props: Omit<CopyIconButtonProps, "copy">) {
const host = useEditorHost();
return <BaseCopyIconButton {...props} copy={host.copy} />;
}
export type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
/**
* A command or config to copy, set as a block: its title, an optional line
* saying which file it goes in, then the snippet at full width with the copy
* button on it. One-line shell commands and multi-line JSON get the same block.
*/
export function SettingCommand({
className,
description,
disabled,
done,
language,
snippet,
title,
}: {
description?: ReactNode;
disabled?: boolean;
/** A short word shown with a check beside the title once the step is detected as done */
done?: ReactNode;
language: SettingSnippetLanguage;
snippet: string;
title: ReactNode;
} & Pick<SettingRowBaseProps, "className">) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div className="flex min-w-0 items-center gap-2 text text-text">
<span>{title}</span>
{done != null && done !== false && (
<span className="inline-flex items-center gap-1 text-sm text-success">
<Icon icon="check" size="sm" />
{done}
</span>
)}
</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
</div>
);
}
export type SettingSnippetLanguage = "shell" | "json" | "text";
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
export function SettingSnippet({
className,
language,
snippet,
}: {
className?: string;
language: SettingSnippetLanguage;
snippet: string;
}) {
const prompt = language === "shell";
return (
<div
className={classNames(
className,
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
)}
>
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
{prompt && (
<span aria-hidden className="mr-2 select-none text-text-subtlest">
$
</span>
)}
{snippet}
</span>
</div>
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
</div>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "w-48!",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "w-80!",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}