Inkbox

Component

Input Group

The console's field with addons inside it: glyphs, text, a lilac suffix segment, buttons, or the handle check's state glyph.

npx -y github:anwarlemu/inkbox-brand-cli#release add input-group
Preview
@inboxmail.com
Handle check

Will be displayed as @acme-support

Will be displayed as @acme-support(is not available)

Addons
⌘K
https://
@inboxmail.com
With textarea
0/280

Usage

Use to attach context to a field: a fixed suffix (@inboxmail.com as a segment), a search glyph, a show or copy button, a character count under a textarea, or the live state of a check (spinner, green check-circle, red x-circle). Put InputGroupInput or InputGroupTextarea inside, then InputGroupAddon at inline-start, inline-end, block-start or block-end.

Avoid

  • Putting a separate Input inside the group
  • More than one action at each end
  • A suffix that people can edit

Exports: InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea. This component is a client component.

Dependencies: button, class-variance-authority.

Example

examples/input-group.tsx
"use client";​import * as React from "react";import { CheckCircleIcon, CopyIcon, EyeIcon, EyeSlashIcon, MagnifyingGlassIcon, SpinnerIcon, XCircleIcon } from "@phosphor-icons/react/ssr";​import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea } from "@/components/ui/input-group";import { Kbd } from "@/components/ui/kbd";import { Label } from "@/components/ui/label";​export function Preview() {  const [handle, setHandle] = React.useState("acme-support");  return (    <div className="grid w-full max-w-114.5 gap-1">      <Label htmlFor="mailbox">Mailbox</Label>      <InputGroup>        <InputGroupInput id="mailbox" value={handle} onChange={(e) => setHandle(e.target.value)} />        <InputGroupAddon align="inline-end" variant="segment">          @inboxmail.com        </InputGroupAddon>      </InputGroup>    </div>  );}​export function HandleCheck() {  return (    <div className="grid w-full max-w-100.5 gap-3">      <InputGroup>        <InputGroupInput aria-label="Checking handle" defaultValue="acme-support" />        <InputGroupAddon align="inline-end">          <SpinnerIcon className="animate-spin-steady text-fg-faint" aria-label="Checking" />        </InputGroupAddon>      </InputGroup>      <div className="grid gap-1">        <InputGroup>          <InputGroupInput aria-label="Available handle" defaultValue="acme-support" />          <InputGroupAddon align="inline-end">            <CheckCircleIcon weight="fill" className="text-success" aria-label="Available" />          </InputGroupAddon>        </InputGroup>        <p className="flex items-center gap-2 text-caption text-fg-secondary">          Will be displayed as <span className="rounded-sm bg-subtle px-2 py-1 text-fg">@acme-support</span>        </p>      </div>      <div className="grid gap-1">        <InputGroup>          <InputGroupInput aria-label="Taken handle" defaultValue="acme-support" aria-invalid aria-describedby="taken" />          <InputGroupAddon align="inline-end">            <XCircleIcon weight="fill" className="text-danger-strong" aria-hidden />          </InputGroupAddon>        </InputGroup>        <p id="taken" className="flex items-center gap-2 text-caption text-fg-secondary">          Will be displayed as <span className="rounded-sm bg-subtle px-2 py-1 text-fg">@acme-support</span>          <span className="text-danger">(is not available)</span>        </p>      </div>    </div>  );}​export function Addons() {  const [shown, setShown] = React.useState(false);  return (    <div className="grid w-full max-w-100.5 gap-3">      <InputGroup>        <InputGroupAddon>          <MagnifyingGlassIcon />        </InputGroupAddon>        <InputGroupInput aria-label="Search mailboxes" placeholder="Search mailboxes" />        <InputGroupAddon align="inline-end">          <Kbd>⌘K</Kbd>        </InputGroupAddon>      </InputGroup>      <InputGroup>        <InputGroupInput aria-label="Vault key" type={shown ? "text" : "password"} defaultValue="correct-horse-battery" />        <InputGroupAddon align="inline-end">          <InputGroupButton size="icon-xs" aria-label={shown ? "Hide the key" : "Show the key"} onClick={() => setShown((s) => !s)} className="text-fg-brand">            {shown ? <EyeSlashIcon weight="fill" /> : <EyeIcon weight="fill" />}          </InputGroupButton>        </InputGroupAddon>      </InputGroup>      <InputGroup>        <InputGroupAddon>          <InputGroupText>https://</InputGroupText>        </InputGroupAddon>        <InputGroupInput aria-label="Webhook URL" placeholder="api.example.com/inkbox" className="pl-1" />        <InputGroupAddon align="inline-end">          <InputGroupButton size="sm" variant="secondary">            <CopyIcon weight="duotone" /> Copy          </InputGroupButton>        </InputGroupAddon>      </InputGroup>      <InputGroup>        <InputGroupInput aria-label="Read-only mailbox" readOnly defaultValue="acme-support" />        <InputGroupAddon align="inline-end" variant="segment">          @inboxmail.com        </InputGroupAddon>      </InputGroup>    </div>  );}​export function WithTextarea() {  const [text, setText] = React.useState("");  return (    <InputGroup className="max-w-100.5">      <InputGroupTextarea aria-label="Reply" placeholder="Write a reply to Ray Liao" value={text} onChange={(e) => setText(e.target.value.slice(0, 280))} />      <InputGroupAddon align="block-end" className="justify-between">        <InputGroupText className="text-caption">{text.length}/280</InputGroupText>        <InputGroupButton size="sm" variant="default" className="rounded-pill" disabled={!text}>          Send        </InputGroupButton>      </InputGroupAddon>    </InputGroup>  );}

Source

components/ui/input-group.tsx
"use client";​import * as React from "react";import { cva, type VariantProps } from "class-variance-authority";​import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";​// The console's field with things inside it (Figma 427:2715, Create Agent Identity): the field's 40px, 8px corners and// lilac-gray edge around a borderless control, with addons at either end: a muted glyph or text, the trailing state// glyph of the handle check (a spinner while checking, a green check-circle when free, a red x-circle when taken), or a// `segment`, the lilac suffix with a divider ("@inboxmail.com" in the Email card). Focus, invalid and read-only states// belong to the whole group. Addons can also sit above or below a textarea (block-start, block-end).function InputGroup({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="input-group"      role="group"      className={cn(        "group/input-group relative flex h-10 w-full min-w-0 items-center overflow-hidden rounded-control border border-line-field bg-surface transition-[border-color,box-shadow] duration-150 ease-out motion-reduce:transition-none",        "has-[[data-slot=input-group-control]:focus-visible]:border-line-brand has-[[data-slot=input-group-control]:focus-visible]:shadow-focus",        "has-[[data-slot][aria-invalid=true]]:border-danger has-[[data-slot][aria-invalid=true]]:shadow-none",        "has-[[data-slot=input-group-control]:read-only]:border-line-field/50 has-[[data-slot=input-group-control]:read-only]:bg-subtle has-[[data-slot=input-group-control]:disabled]:border-line-field/50 has-[[data-slot=input-group-control]:disabled]:bg-subtle has-[[data-slot=input-group-control]:disabled]:opacity-70",        "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto",        "has-[>[data-align=inline-start]]:[&>input]:pl-2 has-[>[data-align=inline-end]]:[&>input]:pr-2 has-[>[data-align=block-start]]:[&>textarea]:pt-1 has-[>[data-align=block-end]]:[&>textarea]:pb-1",        className,      )}      {...props}    />  );}​const inputGroupAddonVariants = cva(  "flex h-auto shrink-0 cursor-text items-center justify-center gap-2 font-sans text-body leading-tight text-fg-muted select-none group-has-[[data-slot=input-group-control]:disabled]/input-group:cursor-not-allowed [&>kbd]:rounded-xs [&>svg:not([class*='size-'])]:size-5",  {    variants: {      align: {        "inline-start": "order-first pl-3.5 has-[>button]:pl-1",        "inline-end": "order-last pr-3 has-[>button]:pr-1",        "block-start": "order-first w-full justify-start px-3.5 pt-2.5",        "block-end": "order-last w-full justify-start px-3.5 pb-2.5",      },      variant: {        plain: "",        segment: "self-stretch border-line-field bg-subtle px-3.5 group-has-[[data-slot=input-group-control]:read-only]/input-group:border-line-field/50 group-has-[[data-slot=input-group-control]:disabled]/input-group:border-line-field/50",      },    },    compoundVariants: [      { variant: "segment", align: "inline-end", className: "border-l pr-3.5" },      { variant: "segment", align: "inline-start", className: "border-r pl-3.5" },    ],    defaultVariants: { align: "inline-start", variant: "plain" },  },);​function InputGroupAddon({ className, align = "inline-start", variant = "plain", ...props }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {  return (    <div      role="group"      data-slot="input-group-addon"      data-align={align}      data-variant={variant}      className={cn(inputGroupAddonVariants({ align, variant }), className)}      onClick={(e) => {        // A click on the addon (not on a button in it) focuses the control, as a click inside a field would.        if ((e.target as HTMLElement).closest("button")) return;        e.currentTarget.parentElement?.querySelector<HTMLElement>("input, textarea")?.focus();      }}      {...props}    />  );}​const inputGroupButtonVariants = cva("shadow-none", {  variants: {    size: {      xs: "h-7 gap-1 rounded-sm px-2 text-body-sm [&>svg:not([class*='size-'])]:size-4",      sm: "h-8 gap-1.5 rounded-control px-2.5",      "icon-xs": "size-7 rounded-sm p-0 [&>svg:not([class*='size-'])]:size-4",      "icon-sm": "size-8 rounded-control p-0",    },  },  defaultVariants: { size: "xs" },});​function InputGroupButton({ className, type = "button", variant = "ghost", size = "xs", ...props }: Omit<React.ComponentProps<typeof Button>, "size"> & VariantProps<typeof inputGroupButtonVariants>) {  return <Button type={type} data-size={size} variant={variant} className={cn(inputGroupButtonVariants({ size }), className)} {...props} />;}​function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {  return <span className={cn("flex items-center gap-2 font-sans text-body leading-tight text-fg-muted [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-5.5", className)} {...props} />;}​function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {  return (    <input      data-slot="input-group-control"      className={cn(        "h-full min-w-0 flex-1 bg-transparent px-3.5 font-sans text-body leading-tight text-fg outline-none placeholder:text-fg-subtle selection:bg-ring-tint read-only:text-fg-muted disabled:cursor-not-allowed disabled:text-fg-muted",        className,      )}      {...props}    />  );}​function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {  return (    <textarea      data-slot="input-group-control"      className={cn(        "field-sizing-content flex min-h-16 w-full min-w-0 flex-1 resize-none bg-transparent px-3.5 py-2.5 font-sans text-body leading-5 text-fg outline-none placeholder:text-fg-subtle selection:bg-ring-tint read-only:text-fg-muted disabled:cursor-not-allowed disabled:text-fg-muted",        className,      )}      {...props}    />  );}​export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };

Reference: Figma 427:2715: the Email card's handle field with @inboxmail.com and the handle check states (checking, Available, Not Available) in Modal States.