input · minimal · 灵感来源:Refactoring UI(实底 + 发丝线,不用材质做深度)
Flat Minimal Input · 极简输入框 · 输入 / Minimal
做一个 Flat Minimal Input(极简输入框):
- 标签在框上方,白底 + 发丝边 + 一档软阴影
- focus 只换边框为 accent,不改变底色
- error 态:warning 描边 + aria-invalid + 文案说明
- disabled 态降透明度且不可聚焦
用这些 CSS 变量:--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --shadow-sm --text-2xs --text-base --text-xs
灵感参考:Refactoring UI(实底 + 发丝线,不用材质做深度)--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --shadow-sm --text-2xs --text-base --text-xs
焦点 / 禁用 / 错误
(尚未接进整页场景)
/r/input--minimal.json
免外壳的 markdown 版:/llms/components/input--minimal.md--color-divider 发丝边 + --shadow-sm:没有半透明、没有 inset 内阴影aria-invalid对比玻璃输入框:它不随背景变化而改变可读性,适合落在实底页面里;代价是没有玻璃那种"凹陷"的触觉暗示。
import { useRef, useState, forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { Eye, EyeOff, X } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { Field } from '~/components/Field';
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
label?: string;
hint?: string;
/** 验证中:标签右侧转圈 */
loading?: boolean;
/** 错误态:描边转语义色 + aria-invalid(状态矩阵见 docs/standards/states.md) */
error?: boolean;
/** 错误文案:渲染在下方并接入 aria-describedby + role="alert" */
errorMessage?: string;
required?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
/** 有值时显示清除按钮 */
clearable?: boolean;
/** password 显隐切换 */
reveal?: boolean;
/** 字数计数:配合 maxLength 显示 n / max */
showCount?: boolean;
}
/** 清空需要绕过 React 的 value 追踪:直接写原生 setter 再派发 input */
const setNativeValue = (el: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
export const MinimalInput = forwardRef<HTMLInputElement, Props>(
(
{
label,
hint,
loading = false,
error = false,
errorMessage,
required = false,
prefix,
suffix,
clearable = false,
reveal = false,
showCount = false,
className = '',
id,
value,
defaultValue,
onChange,
type = 'text',
...rest
},
ref,
) => {
const localRef = useRef<HTMLInputElement>(null);
const setRef = (node: HTMLInputElement | null) => {
localRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
};
const [inner, setInner] = useState(String(defaultValue ?? ''));
const [revealed, setRevealed] = useState(false);
const text = value !== undefined ? String(value) : inner;
return (
<Field
id={id}
label={label}
hint={hint}
error={error}
errorMessage={errorMessage}
required={required}
loading={loading}
counter={showCount ? `${text.length}${rest.maxLength ? ` / ${rest.maxLength}` : ''}` : undefined}
className={className}
>
{(field) => (
<div
className={`
flex items-center
rounded-md
bg-[var(--color-surface)] border
shadow-[var(--shadow-sm)]
transition-[border-color,box-shadow] duration-[var(--dur-fast)]
${field.invalid
? 'border-[var(--color-warning-fg)]'
: 'border-[var(--color-divider)] focus-within:border-[var(--color-accent)]'}
${rest.disabled ? 'opacity-50' : ''}
`}
>
{prefix && (
<span className="pl-3 shrink-0 text-[var(--color-text-muted)] text-sm">{prefix}</span>
)}
<input
{...rest}
ref={setRef}
id={field.id}
type={reveal && type === 'password' && revealed ? 'text' : type}
value={value}
defaultValue={defaultValue}
required={required}
aria-required={field.required || undefined}
aria-invalid={field.invalid || undefined}
aria-describedby={field.describedBy}
onChange={(e) => {
setInner(e.target.value);
onChange?.(e);
}}
className={`
w-full bg-transparent py-2.5
${prefix ? 'pl-2' : 'pl-4'} ${suffix || clearable || reveal ? 'pr-1' : 'pr-4'}
text-[var(--color-text)]
placeholder:text-[var(--color-text-subtle)]
focus:outline-none
disabled:cursor-not-allowed
`}
/>
{clearable && text !== '' && (
<button
type="button"
onClick={() => localRef.current && setNativeValue(localRef.current, '')}
aria-label="清除输入"
className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
>
<Icon icon={X} size={13} />
</button>
)}
{reveal && type === 'password' && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? '隐藏密码' : '显示密码'}
aria-pressed={revealed}
className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
>
<Icon icon={revealed ? EyeOff : Eye} size={14} />
</button>
)}
{suffix && (
<span className="pr-3 shrink-0 text-[var(--color-text-muted)] text-sm">{suffix}</span>
)}
</div>
)}
</Field>
);
},
);
MinimalInput.displayName = 'MinimalInput';
src/components/input/minimal/