# 拟态多行输入（Autosizing Textarea）

- 类型 / 材质：`textarea` / `neumorphic`（新拟态）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Neumorphism（同色凹槽，延续拟态输入框）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/textarea/neumorphic` ｜ 条目：`/r/textarea--neumorphic.json`
- 必缺状态：焦点 / 禁用 / 错误
- 整页场景：尚未接入

## 规格

- 同色凹槽：左上暗、右下亮两条 inset 阴影，没有边框
- focus 在凹槽外叠 2px accent 环
- autosize 同样按 maxRows 截断，凹槽不无限长
- error 态用 1px warning 描边，尺寸不变

## 参数（CSS 变量）

`--color-accent` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-fast` `--glass-border` `--glass-recess` `--neuro-bg` `--neuro-dark` `--neuro-light` `--text-2xs` `--text-base` `--text-xs`

## Props 契约

继承原生属性接口：`TextareaHTMLAttributes<HTMLTextAreaElement>`（原生属性都接受，自有字段如下）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `string` | 可选 | — |
| `hint` | `string` | 可选 | — |
| `loading` | `boolean` | 可选 | — |
| `error` | `boolean` | 可选 | — |
| `errorMessage` | `string` | 可选 | — |
| `required` | `boolean` | 可选 | — |
| `showCount` | `boolean` | 可选 | — |
| `autosize` | `boolean` | 可选 | 随内容长高；超过 maxRows 转为内部滚动 |
| `maxRows` | `number` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「拟态多行输入」— Autosizing Textarea（多行 / Neumorphic）
灵感来源：Neumorphism（同色凹槽，延续拟态输入框）
规格：
- 同色凹槽：左上暗、右下亮两条 inset 阴影，没有边框
- focus 在凹槽外叠 2px accent 环
- autosize 同样按 maxRows 截断，凹槽不无限长
- error 态用 1px warning 描边，尺寸不变
参数（CSS 变量）：--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
必缺状态：焦点 / 禁用 / 错误
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/textarea/neumorphic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/textarea/neumorphic#notes
```

## 调研笔记

import { useRef, useState, useEffect, forwardRef, type TextareaHTMLAttributes } from 'react';
import { Field } from '@/components/ui-echo/Field';

interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
  label?: string;
  hint?: string;
  loading?: boolean;
  error?: boolean;
  errorMessage?: string;
  required?: boolean;
  showCount?: boolean;
  /** 随内容长高；超过 maxRows 转为内部滚动 */
  autosize?: boolean;
  maxRows?: number;
}

export const NeumorphicTextarea = forwardRef<HTMLTextAreaElement, Props>(
  (
    {
      label,
      hint,
      loading = false,
      error = false,
      errorMessage,
      required = false,
      showCount = false,
      autosize = false,
      maxRows = 8,
      className = '',
      id,
      value,
      defaultValue,
      onChange,
      rows,
      ...rest
    },
    ref,
  ) => {
    const localRef = useRef<HTMLTextAreaElement>(null);
    const setRef = (node: HTMLTextAreaElement | null) => {
      localRef.current = node;
      if (typeof ref === 'function') ref(node);
      else if (ref) ref.current = node;
    };

    const [inner, setInner] = useState(String(defaultValue ?? ''));
    const rowCount = rows ?? (autosize ? 1 : 3);
    const text = value !== undefined ? String(value) : inner;

    useEffect(() => {
      const el = localRef.current;
      if (!el || !autosize) return;
      const cs = getComputedStyle(el);
      const line = parseFloat(cs.lineHeight) || 20;
      const chrome =
        parseFloat(cs.paddingTop) +
        parseFloat(cs.paddingBottom) +
        parseFloat(cs.borderTopWidth) +
        parseFloat(cs.borderBottomWidth);
      const max = Math.max(maxRows, rowCount) * line + chrome;
      el.style.height = 'auto';
      el.style.height = `${Math.min(el.scrollHeight, max)}px`;
      el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
    }, [text, autosize, maxRows, rowCount]);

    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={`
              rounded-lg
              bg-[var(--neuro-bg)] border border-transparent
              shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
              transition-[border-color,box-shadow] duration-[var(--dur-fast)]
              ${field.invalid
                ? 'border-[var(--color-warning-fg)]'
                : 'focus-within:shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light),0_0_0_2px_var(--color-accent)]'}
              ${rest.disabled ? 'opacity-50' : ''}
            `}
          >
            <textarea
              {...rest}
              ref={setRef}
              id={field.id}
              rows={rowCount}
              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={`
                block w-full bg-transparent px-4 py-2.5
                text-[var(--color-text)] leading-relaxed
                placeholder:text-[var(--color-text-subtle)]
                focus:outline-none
                disabled:cursor-not-allowed
                ${autosize ? 'resize-none' : 'resize-y'}
              `}
            />
          </div>
        )}
      </Field>
    );
  },
);
NeumorphicTextarea.displayName = 'NeumorphicTextarea';
