MioUI

Input

输入框家族:基础输入、前后缀组、搜索、密码、复制字段、多行文本,以及 Label / FieldError。

请妥善保管,不要提交到公开仓库。

安装

components/ui/input.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { Check, Copy, Eye, EyeSlash, MagnifyingGlass, WarningCircle, X } from '@phosphor-icons/react';
import {
  useEffect,
  useRef,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type InputHTMLAttributes,
  type KeyboardEvent,
  type LabelHTMLAttributes,
  type ReactNode,
  type Ref,
  type TextareaHTMLAttributes,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { cva } from 'class-variance-authority';
import { SPRING_BOUNCY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';

/* ==========================================================================
   字段外观:surface-3 底 + 内嵌发丝边(单线框),聚焦时 cds 焦点环
   ========================================================================== */
const fieldShell =
  'bg-surface-3 text-text-primary shadow-[inset_0_0_0_1px_var(--cds-border)] ' +
  'transition-[box-shadow,background-color] duration-[120ms] ease-cds ' +
  'hover:shadow-[inset_0_0_0_1px_var(--cds-border-strong)]';
const fieldInvalid = 'shadow-[inset_0_0_0_1px_var(--cds-danger)] hover:shadow-[inset_0_0_0_1px_var(--cds-danger)]';

export const inputVariants = cva(
  'cds-focus w-full min-w-0 rounded-lg px-2.5 font-normal placeholder:text-text-muted ' +
    'disabled:cursor-not-allowed disabled:opacity-50 read-only:cursor-default ' +
    fieldShell,
  {
    variants: {
      size: {
        sm: 'h-7 text-[13px]',
        md: 'h-8 text-sm',
        lg: 'h-10 px-3 text-[15px]',
      },
    },
    defaultVariants: { size: 'md' },
  },
);

type FieldSize = 'sm' | 'md' | 'lg';

export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
  size?: FieldSize;
  /** 校验失败:红色内框 + aria-invalid */
  invalid?: boolean;
  ref?: Ref<HTMLInputElement>;
};

/** 基础输入框:h-8 / rounded-lg / surface-3 / 内嵌发丝边 / 焦点环 */
export function Input({ className, size, invalid, ...props }: InputProps) {
  return (
    <input
      aria-invalid={invalid || undefined}
      className={twMerge(inputVariants({ size }), invalid && fieldInvalid, className)}
      {...props}
    />
  );
}

/* ==========================================================================
   InputGroup:外壳承担边框与焦点环,内部 input 无边框;prefix / suffix 槽位
   ========================================================================== */
const groupSize: Record<FieldSize, string> = {
  sm: 'h-7 text-[13px] [&_svg]:size-3.5',
  md: 'h-8 text-sm [&_svg]:size-4',
  lg: 'h-10 text-[15px] [&_svg]:size-[18px]',
};

export type InputGroupProps = Omit<InputProps, 'prefix'> & {
  /** 左侧槽位:图标 / 文本(如 https://) */
  prefix?: ReactNode;
  /** 右侧槽位:按钮 / Kbd / 单位 */
  suffix?: ReactNode;
  /** 作用于内部 <input> 的类名(className 作用于外壳) */
  inputClassName?: string;
};

export function InputGroup({ prefix, suffix, className, inputClassName, size = 'md', invalid, disabled, ...props }: InputGroupProps) {
  return (
    <div
      data-disabled={disabled || undefined}
      className={twMerge(
        'group/field flex w-full min-w-0 items-center gap-1.5 rounded-lg px-2.5',
        fieldShell,
        groupSize[size],
        invalid && fieldInvalid,
        'focus-within:shadow-focus! data-disabled:cursor-not-allowed data-disabled:opacity-50',
        className,
      )}
    >
      {prefix != null && <span className="flex shrink-0 items-center text-text-muted">{prefix}</span>}
      <input
        aria-invalid={invalid || undefined}
        disabled={disabled}
        className={twMerge(
          'h-full w-full min-w-0 flex-1 bg-transparent font-normal text-text-primary outline-none placeholder:text-text-muted disabled:cursor-not-allowed',
          inputClassName,
        )}
        {...props}
      />
      {suffix != null && <span className="-mr-1 flex shrink-0 items-center gap-0.5 text-text-muted">{suffix}</span>}
    </div>
  );
}

/** 输入框内的小图标按钮(无边框,单线框纪律) */
function FieldIconButton({ className, ...p }: ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button
      type="button"
      className={twMerge(
        'cds-focus inline-flex size-6 items-center justify-center rounded-md text-text-muted transition-colors duration-[120ms] ease-cds hover:bg-ghost-hover hover:text-text-primary [&_svg]:size-4',
        className,
      )}
      {...p}
    />
  );
}

/* ==========================================================================
   Kbd
   ========================================================================== */
export function Kbd({ className, ...p }: HTMLAttributes<HTMLElement>) {
  return (
    <kbd
      className={twMerge(
        'inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-ghost-selected px-1 font-sans text-[11px] font-medium leading-none text-text-secondary tabular-nums',
        className,
      )}
      {...p}
    />
  );
}

/* ==========================================================================
   SearchInput:放大镜前缀;有值显示 ×;为空显示快捷键提示;按快捷键聚焦
   ========================================================================== */
function isEditable(t: EventTarget | null) {
  const el = t as HTMLElement | null;
  if (!el) return false;
  return el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
}

export type SearchInputProps = Omit<InputGroupProps, 'prefix' | 'value' | 'defaultValue' | 'onChange' | 'type'> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (v: string) => void;
  /** Enter 提交(已做 IME 组字保护) */
  onSearch?: (v: string) => void;
  /** 全局聚焦快捷键,默认 "/";传 false 关闭 */
  hotkey?: string | false;
  clearLabel?: string;
};

export function SearchInput({
  value: valueProp,
  defaultValue = '',
  onValueChange,
  onSearch,
  hotkey = '/',
  clearLabel = 'Clear',
  suffix,
  onKeyDown,
  ref: refProp,
  ...props
}: SearchInputProps) {
  const [inner, setInner] = useState(defaultValue);
  const value = valueProp ?? inner;
  const innerRef = useRef<HTMLInputElement | null>(null);
  const set = (v: string) => {
    if (valueProp === undefined) setInner(v);
    onValueChange?.(v);
  };

  useEffect(() => {
    if (!hotkey) return;
    const onKey = (e: globalThis.KeyboardEvent) => {
      if (e.key !== hotkey || e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return;
      if (isEditable(e.target)) return;
      e.preventDefault();
      innerRef.current?.focus();
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [hotkey]);

  const setRef = (el: HTMLInputElement | null) => {
    innerRef.current = el;
    if (typeof refProp === 'function') refProp(el);
    else if (refProp) (refProp as { current: HTMLInputElement | null }).current = el;
  };

  return (
    <InputGroup
      {...props}
      ref={setRef}
      type="search"
      role="searchbox"
      value={value}
      onChange={(e) => set(e.target.value)}
      onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
        onKeyDown?.(e);
        if (e.defaultPrevented) return;
        if (e.nativeEvent.isComposing || e.keyCode === 229) return;
        if (e.key === 'Enter') onSearch?.(value);
        if (e.key === 'Escape') {
          if (value) {
            e.preventDefault();
            e.stopPropagation();
            set('');
          } else e.currentTarget.blur();
        }
      }}
      inputClassName={twMerge('[&::-webkit-search-cancel-button]:hidden', props.inputClassName)}
      prefix={<MagnifyingGlass />}
      suffix={
        <>
          {suffix}
          {value ? (
            <FieldIconButton
              aria-label={clearLabel}
              onClick={() => {
                set('');
                innerRef.current?.focus();
              }}
            >
              <X />
            </FieldIconButton>
          ) : hotkey ? (
            <Kbd aria-hidden className="mr-1">
              {hotkey}
            </Kbd>
          ) : null}
        </>
      }
    />
  );
}

/* ==========================================================================
   PasswordInput:眼睛切换显隐
   ========================================================================== */
export type PasswordInputProps = Omit<InputGroupProps, 'type'> & {
  showLabel?: string;
  hideLabel?: string;
};

export function PasswordInput({ showLabel = 'Show password', hideLabel = 'Hide password', suffix, ...props }: PasswordInputProps) {
  const [visible, setVisible] = useState(false);
  return (
    <InputGroup
      autoComplete="current-password"
      {...props}
      type={visible ? 'text' : 'password'}
      suffix={
        <>
          {suffix}
          <FieldIconButton
            aria-label={visible ? hideLabel : showLabel}
            aria-pressed={visible}
            disabled={props.disabled}
            onClick={() => setVisible((v) => !v)}
          >
            {visible ? <EyeSlash /> : <Eye />}
          </FieldIconButton>
        </>
      }
    />
  );
}

/* ==========================================================================
   CopyField:只读等宽值(API Key / 链接),可遮罩;复制后 BOUNCY 弹出对勾 1.5s
   ========================================================================== */
async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand('copy');
    ta.remove();
    return ok;
  }
}

const defaultMask = (v: string) => {
  const keep = Math.min(8, Math.floor(v.length / 3));
  return v.slice(0, keep) + '•'.repeat(Math.max(8, Math.min(24, v.length - keep)));
};

export interface CopyFieldProps {
  value: string;
  /** true 使用默认遮罩(保留前缀 + •),或传自定义遮罩函数;带显隐切换 */
  mask?: boolean | ((v: string) => string);
  size?: FieldSize;
  className?: string;
  id?: string;
  'aria-label'?: string;
  copyLabel?: string;
  copiedLabel?: string;
  revealLabel?: string;
  concealLabel?: string;
  onCopy?: (v: string) => void;
}

export function CopyField({
  value,
  mask,
  size = 'md',
  className,
  id,
  'aria-label': ariaLabel,
  copyLabel = 'Copy',
  copiedLabel = 'Copied',
  revealLabel = 'Reveal',
  concealLabel = 'Hide',
  onCopy,
}: CopyFieldProps) {
  const [copied, setCopied] = useState(false);
  const [revealed, setRevealed] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const reduce = useReducedMotionPref();
  useEffect(() => () => clearTimeout(timer.current), []);

  const masked = mask && !revealed;
  const display = masked ? (typeof mask === 'function' ? mask(value) : defaultMask(value)) : value;

  const doCopy = async () => {
    if (!(await copyText(value))) return;
    onCopy?.(value);
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1500);
  };

  const iconMotion = reduce
    ? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
    : { initial: { scale: 0.4, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.4, opacity: 0 }, transition: SPRING_BOUNCY };

  return (
    <InputGroup
      id={id}
      aria-label={ariaLabel}
      size={size}
      readOnly
      value={display}
      onFocus={(e) => e.currentTarget.select()}
      className={className}
      inputClassName="font-mono text-[0.92em] tracking-tight text-text-secondary"
      suffix={
        <>
          {mask && (
            <FieldIconButton aria-label={revealed ? concealLabel : revealLabel} aria-pressed={revealed} onClick={() => setRevealed((r) => !r)}>
              {revealed ? <EyeSlash /> : <Eye />}
            </FieldIconButton>
          )}
          <FieldIconButton
            aria-label={copied ? copiedLabel : copyLabel}
            onClick={doCopy}
            className={copied ? 'text-success hover:text-success' : undefined}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span key={copied ? 'ok' : 'copy'} className="inline-flex" {...iconMotion}>
                {copied ? <Check weight="bold" /> : <Copy />}
              </motion.span>
            </AnimatePresence>
          </FieldIconButton>
          <span aria-live="polite" className="sr-only">
            {copied ? copiedLabel : ''}
          </span>
        </>
      }
    />
  );
}

/* ==========================================================================
   Textarea
   ========================================================================== */
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
  invalid?: boolean;
  /** 随内容自动增高(field-sizing: content) */
  autoResize?: boolean;
  ref?: Ref<HTMLTextAreaElement>;
};

export function Textarea({ className, invalid, autoResize, ...props }: TextareaProps) {
  return (
    <textarea
      aria-invalid={invalid || undefined}
      className={twMerge(
        'cds-focus block min-h-20 w-full min-w-0 resize-y rounded-lg px-2.5 py-2 text-sm leading-relaxed font-normal placeholder:text-text-muted disabled:cursor-not-allowed disabled:opacity-50',
        fieldShell,
        invalid && fieldInvalid,
        autoResize && 'resize-none [field-sizing:content] max-h-60',
        className,
      )}
      {...props}
    />
  );
}

/* ==========================================================================
   Label / Field / FieldDescription / FieldError
   ========================================================================== */
export function Label({ className, required, children, ...p }: LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }) {
  return (
    <label className={twMerge('inline-flex items-center gap-1 text-[13px] font-medium text-text-primary select-none', className)} {...p}>
      {children}
      {required && (
        <span aria-hidden className="text-danger">
          *
        </span>
      )}
    </label>
  );
}

/** 纵向字段容器:Label + 控件 + 说明/错误 */
export function Field({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
  return <div className={twMerge('flex flex-col gap-1.5', className)} {...p} />;
}

export function FieldDescription({ className, ...p }: HTMLAttributes<HTMLParagraphElement>) {
  return <p className={twMerge('text-[12px] leading-snug text-text-muted', className)} {...p} />;
}

/** 校验错误:无内容时不渲染;role="alert" 读屏即时播报 */
export function FieldError({ className, children, ...p }: HTMLAttributes<HTMLParagraphElement>) {
  if (children == null || children === false || children === '') return null;
  return (
    <p role="alert" className={twMerge('flex items-center gap-1 text-[12px] leading-snug text-danger [&_svg]:size-3.5 [&_svg]:shrink-0', className)} {...p}>
      <WarningCircle weight="fill" />
      <span>{children}</span>
    </p>
  );
}

用法

import { Field, Label, Input, InputGroup, SearchInput, PasswordInput, CopyField, Textarea, FieldError, Kbd } from "@/components/ui/input";

<SearchInput value={q} onValueChange={setQ} placeholder="搜索…" />   // 按 / 聚焦
<Field>
  <Label htmlFor="pwd" required>密码</Label>
  <PasswordInput id="pwd" invalid={!!err} />
  <FieldError>{err}</FieldError>
</Field>
<CopyField value="mio_sk_live_…" mask copyLabel="复制" copiedLabel="已复制" />
<InputGroup prefix="https://" suffix={<Kbd>⌘K</Kbd>} />
属性类型默认说明
Input sizesm md lgmd28 / 32 / 40px
invalidboolean—红色内框 + aria-invalid
InputGroup prefix / suffixReactNode—前后缀槽位
SearchInput onValueChange / onSearch(v) => void—值变化 / 回车提交(含 IME 保护)
hotkeystring | false"/"全局聚焦快捷键
CopyField maskboolean | (v) => string—遮罩并提供显隐切换
Textarea autoResizebooleanfalse随内容增高

本页目录