refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-24 18:43:18 +08:00
committed by GitHub
parent c7465625a4
commit 3f868ac385
50 changed files with 2391 additions and 1190 deletions
+41 -50
View File
@@ -1,18 +1,17 @@
/**
* Text input component: optional label and hint/error text; rounded corners with
* a hover border darken and brand focus-ring transition.
* a hover border darken and brand focus-ring transition. Shares the control look
* and the label/hint/error scaffolding with the other form controls via field.tsx.
*/
import { forwardRef } from "react";
import { forwardRef, useId } from "react";
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { Field, controlBase } from "./field";
// Excludes font size and padding: each of Input/Textarea appends its own (see their size).
// Adds width, placeholder and disabled styling on top of the shared control look; each of Input/Textarea appends its own font size and padding (see their size).
const baseClass =
"w-full rounded-md border border-gray-300 bg-white text-gray-900 " +
"placeholder:text-gray-400 transition-[border-color,box-shadow] duration-200 " +
"hover:border-gray-400 focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
"disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 " +
"dark:hover:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
`w-full ${controlBase} ` +
"placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-60 " +
"dark:placeholder:text-gray-500";
/** Size tier: base (form default) / sm (compact contexts like filter bars, keeps the toolbar from growing taller). */
export type ControlSize = "base" | "sm";
@@ -38,13 +37,14 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
/**
* Error state: red border + light red background (a failed field is visible at a
* glance; the error text sits below the box).
* glance; the error text sits below the box). Shared with Select/OptionMenu so a
* failed dropdown reads exactly like a failed input.
* Forced with `!` — baseClass's border-gray-300 / bg-white are the same kind of
* border/background utility classes, and which one wins depends on the order the
* CSS was generated in, not the order of classes in the string (without `!important`
* this would get overridden).
*/
const errorClass =
export const errorClass =
"!border-red-400 !bg-red-50 hover:!border-red-500 focus:!border-red-500 focus:!ring-red-400/30 " +
"dark:!border-red-800 dark:!bg-red-950/40 dark:hover:!border-red-700 dark:focus:!border-red-600";
@@ -53,39 +53,35 @@ export function Input({
hint,
error,
invalid,
required,
size = "base",
className,
...rest
}: InputProps) {
const bad = Boolean(error) || Boolean(invalid);
const control = (
<input
className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`}
aria-invalid={bad ? true : undefined}
{...rest}
/>
);
if (!label && !hint && !error) return control;
const errorId = useId();
// `required` drives the label's asterisk + aria-required only; the native `required`
// attribute is intentionally not forwarded (the app validates on submit, so browser
// validation bubbles would collide with the inline field errors).
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control}
{error ? (
<span className="mt-1 block text-xs text-red-600 dark:text-red-400">{error}</span>
) : hint ? (
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>
) : null}
</label>
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
<input
className={`${baseClass} ${sizeClass[size]} ${bad ? errorClass : ""} ${className ?? ""}`}
aria-invalid={bad ? true : undefined}
aria-required={required || undefined}
aria-describedby={error ? errorId : undefined}
{...rest}
/>
</Field>
);
}
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
hint?: string;
error?: string;
/** Marks the field red without rendering error text (see Input.invalid). */
invalid?: boolean;
/** Monospace font (for editing Prompts/parameters). */
mono?: boolean;
/** Font size: base (default, matches body text) or sm (smaller, for editing long Prompts). */
@@ -93,26 +89,21 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, hint, mono, size = "base", className, ...rest },
{ label, hint, error, invalid, required, mono, size = "base", className, ...rest },
ref,
) {
const control = (
<textarea
ref={ref}
className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${className ?? ""}`}
{...rest}
/>
);
if (!label && !hint) return control;
const bad = Boolean(error) || Boolean(invalid);
const errorId = useId();
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
)}
{control}
{hint && <span className="mt-1 block text-xs text-gray-500 dark:text-gray-500">{hint}</span>}
</label>
<Field label={label} hint={hint} error={error} errorId={errorId} required={required}>
<textarea
ref={ref}
className={`${baseClass} px-3 py-2 ${size === "sm" ? "text-xs leading-relaxed" : "text-base"} ${mono ? "font-mono" : ""} ${bad ? errorClass : ""} ${className ?? ""}`}
aria-invalid={bad ? true : undefined}
aria-required={required || undefined}
aria-describedby={error ? errorId : undefined}
{...rest}
/>
</Field>
);
});