import { useRef, useState } from "react";
import { InputGroup, Loader } from "@cloudflare/kumo";
import { CheckCircleIcon } from "@phosphor-icons/react";

export function InputGroupDemo() {
  const [status, setStatus] = useState<"idle" | "loading" | "success">(
    "success",
  );
  const [value, setValue] = useState("kumo");
  const timerRef = useRef<ReturnType<typeof setTimeout>>(null);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;

    setValue(next);

    if (timerRef.current) clearTimeout(timerRef.current);

    if (next.length > 0) {
      setStatus("loading");

      timerRef.current = setTimeout(() => setStatus("success"), 1500);
    } else {
      setStatus("idle");
    }
  };

  return (
    <div className="w-full max-w-2xs">
      <InputGroup>
        <InputGroup.Input
          maxLength={20}
          onChange={handleChange}
          value={value}
        />
        <InputGroup.Suffix>.workers.dev</InputGroup.Suffix>
        {status !== "idle" && (
          <InputGroup.Addon align="end">
            {status === "loading" ? (
              <Loader />
            ) : (
              <CheckCircleIcon weight="duotone" className="text-kumo-success" />
            )}
          </InputGroup.Addon>
        )}
      </InputGroup>
    </div>
  );
}

安装

桶式导入

import { InputGroup } from "@cloudflare/kumo";

子路径导入

import { InputGroup } from "@cloudflare/kumo/components/input-group";

用法

使用内置 Field(推荐)

为 InputGroup 传入 label 属性以启用内置 Field 包装器,支持标签、描述和错误提示。

import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";

export default function Example() {
  return (
    <InputGroup label="Search" description="Find pages, components, and more">
      <InputGroup.Addon>
        <MagnifyingGlassIcon />
      </InputGroup.Addon>
      <InputGroup.Input placeholder="Search..." />
    </InputGroup>
  );
}

裸 InputGroup(自定义布局)

对于自定义表单布局,可在不带 label 的情况下使用 InputGroup,但必须在 InputGroup.Input 上提供 aria-label 以保证无障碍访问。

import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";

export default function Example() {
  return (
    <InputGroup>
      <InputGroup.Addon>
        <MagnifyingGlassIcon />
      </InputGroup.Addon>
      <InputGroup.Input placeholder="Search..." aria-label="Search" />
    </InputGroup>
  );
}

示例

图标

使用 Addon 在输入框开头放置图标,作为视觉标识。

import { InputGroup } from "@cloudflare/kumo";
import { LinkIcon } from "@phosphor-icons/react";

export function InputGroupIconsDemo() {
  return (
    <InputGroup className="w-full max-w-3xs">
      <InputGroup.Addon>
        <LinkIcon />
      </InputGroup.Addon>
      <InputGroup.Input placeholder="Paste a link..." aria-label="Link" />
    </InputGroup>
  );
}

文本

使用 Addon 在输入框旁边放置文本前缀或后缀。

import { InputGroup } from "@cloudflare/kumo";

export function InputGroupTextDemo() {
  return (
    <div className="flex flex-col gap-4">
      <InputGroup className="w-full max-w-3xs">
        <InputGroup.Addon>@</InputGroup.Addon>
        <InputGroup.Input placeholder="username" aria-label="Username" />
      </InputGroup>

      <InputGroup className="w-full max-w-3xs">
        <InputGroup.Input placeholder="email" aria-label="Email" />
        <InputGroup.Addon align="end">@example.com</InputGroup.Addon>
      </InputGroup>

      <InputGroup className="w-full max-w-3xs">
        <InputGroup.Addon>/api/</InputGroup.Addon>
        <InputGroup.Input placeholder="endpoint" aria-label="API path" />
        <InputGroup.Addon align="end">.json</InputGroup.Addon>
      </InputGroup>
    </div>
  );
}

按钮

InputGroup.Button 放在 Addon 中,用于直接作用于输入值的操作,例如显示/隐藏或清空。

import { useState } from "react";
import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon, EyeIcon, EyeSlashIcon, XIcon } from "@phosphor-icons/react";

export function InputGroupButtonsDemo() {
  const [show, setShow] = useState(false);
  const [searchValue, setSearchValue] = useState("search");

  return (
    <div className="flex flex-col gap-4">
      <InputGroup className="w-full max-w-3xs">
        <InputGroup.Input
          type={show ? "text" : "password"}
          defaultValue="password"
          aria-label="Password"
        />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            shape="square"
            className="text-kumo-subtle"
            icon={show ? EyeSlashIcon : EyeIcon}
            aria-label={show ? "Hide password" : "Show password"}
            onClick={() => setShow(!show)}
          />
        </InputGroup.Addon>
      </InputGroup>

      <InputGroup className="w-full max-w-3xs">
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input
          value={searchValue}
          placeholder="Search"
          aria-label="Search"
          onChange={(e) => setSearchValue(e.target.value)}
        />
        {searchValue && (
          <InputGroup.Addon align="end" className="pr-1">
            <InputGroup.Button
              shape="square"
              icon={XIcon}
              aria-label="Clear search"
              onClick={() => setSearchValue("")}
            />
          </InputGroup.Addon>
        )}
        <InputGroup.Button variant="secondary" onClick={() => {}}>
          Search
        </InputGroup.Button>
      </InputGroup>
    </div>
  );
}

带提示的按钮

InputGroup.Button 传入 tooltip 属性以在悬停时显示提示。当未显式提供 aria-label 时,按钮会从字符串形式的 tooltip 值自动推导。

import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon, QuestionIcon } from "@phosphor-icons/react";

export function InputGroupTooltipButtonDemo() {
  return (
    <InputGroup className="w-full max-w-2xs">
      <InputGroup.Addon>
        <MagnifyingGlassIcon />
      </InputGroup.Addon>
      <InputGroup.Input
        placeholder="Search with query language..."
        aria-label="Search"
      />
      <InputGroup.Addon align="end">
        <InputGroup.Button
          shape="square"
          className="text-kumo-subtle"
          icon={QuestionIcon}
          aria-label="Query language help"
          tooltip="Query language help"
          onClick={() => {}}
        />
      </InputGroup.Addon>
    </InputGroup>
  );
}

键盘快捷键

在末尾 Addon 中放置键盘快捷键提示。

import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";

export function InputGroupKbdDemo() {
  return (
    <InputGroup className="w-full max-w-3xs">
      <InputGroup.Addon>
        <MagnifyingGlassIcon />
      </InputGroup.Addon>
      <InputGroup.Input placeholder="Search..." aria-label="Search" />
      <InputGroup.Addon align="end">
        <kbd className="border-none! bg-none!">⌘K</kbd>
      </InputGroup.Addon>
    </InputGroup>
  );
}

加载中

在末尾 Addon 中放置 Loader 作为状态指示器,用于输入值校验过程中。

import { InputGroup, Loader } from "@cloudflare/kumo";

export function InputGroupLoadingDemo() {
  return (
    <InputGroup className="w-full max-w-3xs">
      <InputGroup.Input defaultValue="kumo" aria-label="kumo" />
      <InputGroup.Addon align="end">
        <Loader />
      </InputGroup.Addon>
    </InputGroup>
  );
}

内联后缀

Suffix 在输入的文本之后无缝渲染文本,适用于 .workers.dev 之类的域名输入。 可与状态图标 Addon 搭配使用,以显示校验状态。

.workers.dev
.workers.dev
This subdomain is unavailable
import { InputGroup } from "@cloudflare/kumo";
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";

export function InputGroupSuffixDemo() {
  return (
    <div className="flex w-full max-w-2xs flex-col gap-4">
      <InputGroup label="Subdomain">
        <InputGroup.Input
          aria-label="Subdomain"
          defaultValue="kumo"
          maxLength={20}
        />
        <InputGroup.Suffix>.workers.dev</InputGroup.Suffix>
        <InputGroup.Addon align="end">
          <CheckCircleIcon weight="duotone" className="text-kumo-success" />
        </InputGroup.Addon>
      </InputGroup>

      <InputGroup
        label="Subdomain"
        error={{ message: "This subdomain is unavailable", match: true }}
      >
        <InputGroup.Input
          aria-label="Subdomain"
          defaultValue="kumo"
          maxLength={20}
        />
        <InputGroup.Suffix>.workers.dev</InputGroup.Suffix>
        <InputGroup.Addon align="end">
          <XCircleIcon weight="duotone" className="text-kumo-danger" />
        </InputGroup.Addon>
      </InputGroup>
    </div>
  );
}

尺寸

四种尺寸:xssmbase(默认)和 lg。尺寸作用于整个组。

import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon, QuestionIcon } from "@phosphor-icons/react";

export function InputGroupSizesDemo() {
  return (
    <div className="flex w-full max-w-3xs flex-col gap-4">
      <InputGroup size="xs" label="Extra Small">
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input placeholder="Extra small input" />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            className="text-kumo-subtle"
            icon={QuestionIcon}
            shape="square"
            aria-label="Help"
          />
        </InputGroup.Addon>
      </InputGroup>

      <InputGroup size="sm" label="Small">
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input placeholder="Small input" />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            className="text-kumo-subtle"
            icon={QuestionIcon}
            shape="square"
            aria-label="Help"
          />
        </InputGroup.Addon>
      </InputGroup>

      <InputGroup label="Base (default)">
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input placeholder="Base input" />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            className="text-kumo-subtle"
            icon={QuestionIcon}
            shape="square"
            aria-label="Help"
          />
        </InputGroup.Addon>
      </InputGroup>

      <InputGroup size="lg" label="Large">
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input placeholder="Large input" />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            className="text-kumo-subtle"
            icon={QuestionIcon}
            shape="square"
            aria-label="Help"
          />
        </InputGroup.Addon>
      </InputGroup>
    </div>
  );
}

状态

各种输入状态,包括错误、禁用和带描述。将 labelerrordescription 属性直接传给 InputGroup

@example.com
Please enter a valid email address
$

Must be at least 8 characters

import { useState } from "react";
import { InputGroup } from "@cloudflare/kumo";
import { MagnifyingGlassIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";

export function InputGroupStatesDemo() {
  const [show, setShow] = useState(false);

  return (
    <div className="flex w-full max-w-3xs flex-col gap-4">
      <InputGroup
        label="Error State"
        error={{ message: "Please enter a valid email address", match: true }}
      >
        <InputGroup.Input type="email" defaultValue="invalid-email" />
        <InputGroup.Addon align="end">@example.com</InputGroup.Addon>
      </InputGroup>

      <InputGroup label="Disabled" disabled>
        <InputGroup.Addon>
          <MagnifyingGlassIcon />
        </InputGroup.Addon>
        <InputGroup.Input placeholder="Search..." />
      </InputGroup>

      <InputGroup label="Optional Field" required={false}>
        <InputGroup.Addon>$</InputGroup.Addon>
        <InputGroup.Input placeholder="0.00" />
      </InputGroup>

      <InputGroup
        label="With Description"
        description="Must be at least 8 characters"
        labelTooltip="Your password is stored securely"
      >
        <InputGroup.Input
          type={show ? "text" : "password"}
          placeholder="Password"
        />
        <InputGroup.Addon align="end">
          <InputGroup.Button
            shape="square"
            className="text-kumo-subtle"
            icon={show ? EyeSlashIcon : EyeIcon}
            aria-label={show ? "Hide password" : "Show password"}
            onClick={() => setShow(!show)}
          />
        </InputGroup.Addon>
      </InputGroup>
    </div>
  );
}

API 参考

InputGroup

根容器,为所有子组件提供上下文。接受 Field 属性(labeldescriptionerror),当提供 label 时会将内容包裹在 Field 中。

PropTypeDefaultDescription
labelReactNode-The label content — can be a string or any React node.
descriptionReactNode-Helper text displayed below the control (hidden when `error` is present).
errorobject-Validation error with a message and a browser `ValidityState` match key.
requiredboolean-When explicitly `false`, shows gray "(optional)" text after the label. When `true` or `undefined`, no indicator is shown.
labelTooltipReactNode-Tooltip content displayed next to the label via an info icon.
childrenReactNode--
classNamestring--
idstring--
langstring--
titlestring--
size"xs" | "sm" | "base" | "lg""base"-
disabledboolean--

InputGroup.Input

文本输入元素。从 InputGroup 上下文继承 sizedisablederror。 接受所有标准 input 属性,但与 Field 相关的属性由父级处理。

PropTypeDefault

No component-specific props. Accepts standard HTML attributes.

InputGroup.Addon

放置在输入框开头或末尾的容器,用于承载图标、文本或紧凑按钮。

PropTypeDefaultDescription
align"start" | "end""start"Position relative to the input.
classNamestring-Additional CSS classes.

InputGroup.Button

用于辅助操作的按钮,如切换、复制或帮助。渲染在 Addon 内。 传入 tooltip 属性可在悬停时显示提示。

PropTypeDefaultDescription
tooltipReactNode-When provided, wraps the button in a Tooltip. Automatically sets aria-label from a string value.
tooltipSide"top" | "right" | "bottom" | "left""bottom"Preferred side for the tooltip popup.
variant"primary" | "secondary" | "ghost" | "destructive" | "secondary-destructive" | "outline""ghost"Button visual style. Defaults to ghost.
size"xs" | "sm" | "base" | "lg""sm"Button size.

InputGroup.Suffix

紧接输入文本的内联文本(例如 .workers.dev)。输入框宽度会随用户输入自动调整。

PropTypeDefaultDescription
classNamestring-Additional CSS classes.

校验错误类型

error 以对象形式使用时,match 属性对应 HTML5 ValidityState 值:

匹配描述
valueMissing必填字段为空
typeMismatch值不符合类型(例如邮箱无效)
patternMismatch值不符合 pattern 属性
tooShort值短于 minLength
tooLong值长于 maxLength
rangeUnderflow值小于 min
rangeOverflow值大于 max
true始终显示错误(用于服务端校验)

无障碍

标签要求

InputGroup 需要满足以下任一方式提供可访问名称:

  • 在 InputGroup 上使用 label 属性(渲染可见标签,并带有内置 Field 支持)

  • 对于没有可见标签的输入框,在 InputGroup.Input 上使用 aria-label

  • 在 InputGroup.Input 上使用 aria-labelledby 进行自定义标签关联

开发环境下,缺少可访问名称会触发控制台警告。

组角色

InputGroup 会自动以 role="group" 渲染,从而在语义上为辅助技术 将输入框与其 addons 关联起来。