# Input group

> Text, icons and buttons inside an input's border, such as a currency, a unit, a search icon or a clear or copy button.

Source: https://platform.azmara.io/docs/components/input-group

Group: Inputs · Status: new · Added in @azmr/ui 0.6.0

## Usage

```tsx
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@azmr/ui/input-group";
```

```tsx
import { Field } from "@azmr/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@azmr/ui/input-group";

export default function InputGroupMoney() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Field label="Hourly rate" description="Excluding GST.">
        <InputGroup>
          <InputGroupAddon>NZ$</InputGroupAddon>
          <InputGroupInput name="rate" inputMode="decimal" placeholder="0.00" />
          <InputGroupAddon>per hour</InputGroupAddon>
        </InputGroup>
      </Field>
      <Field label="Parcel weight">
        <InputGroup>
          <InputGroupInput name="weight" inputMode="decimal" />
          <InputGroupAddon>kg</InputGroupAddon>
        </InputGroup>
      </Field>
      <Field label="Website">
        <InputGroup>
          <InputGroupAddon>https://</InputGroupAddon>
          <InputGroupInput name="website" placeholder="example.co.nz" />
        </InputGroup>
      </Field>
    </div>
  );
}
```

`InputGroup` draws one border around an `InputGroupInput` and the addons and buttons beside it. The focus ring wraps the whole group, the height follows the density tokens, and the border turns red when the input is invalid. Put addons and buttons before or after the input, in the order they appear.

Addons are not part of the input's value: the `NZ$` above is shown, not submitted. Pressing an addon focuses the input, as pressing a label would.

## Text and icon addons

A text addon carries meaning, so it is added to the input's accessible description: a screen reader hears "Hourly rate, NZ$, per hour, Excluding GST". It comes ahead of the field's own description and error.

An icon is decoration, so an addon that holds anything other than plain text is hidden from screen readers. Set `describe` to change either default, for example `describe` on an addon that wraps its text in `<abbr>`, or `describe={false}` on text that only repeats the label.

## Buttons

```tsx
"use client";

import { Field } from "@azmr/ui/field";
import { Icon } from "@azmr/ui/icons";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@azmr/ui/input-group";
import { Search, X } from "lucide-react";
import { useRef, useState } from "react";

const CUSTOMERS = ["Aroha Ngata", "Mere Tāwhiri", "James Wilson", "Priya Patel", "Liam O'Connor"];

export default function InputGroupSearch() {
  const [query, setQuery] = useState("");
  const input = useRef<HTMLInputElement>(null);
  const matches = CUSTOMERS.filter((name) => name.toLowerCase().includes(query.toLowerCase()));

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Field label="Search customers">
        <InputGroup>
          <InputGroupAddon>
            <Icon icon={Search} />
          </InputGroupAddon>
          <InputGroupInput
            ref={input}
            type="search"
            value={query}
            onValueChange={setQuery}
            placeholder="Name"
          />
          {query ? (
            <InputGroupButton
              size="icon"
              aria-label="Clear search"
              onClick={() => {
                setQuery("");
                // Back to the input, so the next search starts where the last one did.
                input.current?.focus();
              }}
            >
              <Icon icon={X} />
            </InputGroupButton>
          ) : null}
        </InputGroup>
      </Field>
      <p role="status" className="text-sm text-muted-foreground">
        {matches.length === 1 ? "1 customer" : `${matches.length} customers`}
      </p>
      <ul className="flex flex-col gap-1 text-sm">
        {matches.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </div>
  );
}
```

`InputGroupButton` is a [Button](https://platform.azmara.io/docs/components/button.md) in the ghost style, small enough to fit inside the border. It is `type="button"`, so it never submits the form, and it comes after the input in the tab order. An icon-only button needs an `aria-label`, such as "Clear search".

After a clear button empties the input, move focus back to the input, as the demo does, so keyboard users don't lose their place.

```tsx
"use client";

import { Field } from "@azmr/ui/field";
import { Icon } from "@azmr/ui/icons";
import { InputGroup, InputGroupButton, InputGroupInput } from "@azmr/ui/input-group";
import { Check, Copy } from "lucide-react";
import { useState } from "react";

const KEY = "pk_live_7f3a9c21e4b8";

export default function InputGroupButtons() {
  const [copied, setCopied] = useState(false);
  const [code, setCode] = useState("");
  const [applied, setApplied] = useState<string>();

  async function copy() {
    await navigator.clipboard?.writeText(KEY).catch(() => undefined);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Field label="Publishable key" description="Safe to use in the browser.">
        <InputGroup>
          <InputGroupInput readOnly value={KEY} className="font-mono" />
          <InputGroupButton size="icon" aria-label={copied ? "Copied" : "Copy key"} onClick={copy}>
            <Icon icon={copied ? Check : Copy} />
          </InputGroupButton>
        </InputGroup>
      </Field>
      <Field label="Discount code">
        <InputGroup>
          <InputGroupInput value={code} onValueChange={setCode} autoComplete="off" />
          <InputGroupButton
            variant="secondary"
            disabled={code.trim() === ""}
            onClick={() => setApplied(code.trim().toUpperCase())}
          >
            Apply
          </InputGroupButton>
        </InputGroup>
      </Field>
      <p role="status" className="text-sm text-muted-foreground">
        {applied ? `${applied} applied.` : ""}
      </p>
    </div>
  );
}
```

## In a Field and with react-hook-form

Put the `InputGroup` inside a [Field](https://platform.azmara.io/docs/components/field.md). The label, description and error are wired to the input as they are for a bare `Input`, and the `aria-required` and `aria-errormessage` that `Field` sets reach the input, not the group. `InputGroupInput`'s `ref` is the real `<input>`, so `register()` works without a `Controller`:

```tsx
<Field label="Amount" required error={errors.amount?.message}>
  <InputGroup>
    <InputGroupAddon>NZ$</InputGroupAddon>
    <InputGroupInput
      inputMode="decimal"
      {...register("amount", { required: "Enter an amount" })}
    />
  </InputGroup>
</Field>
```

For amounts, the formatted `MoneyField` (coming in a later release) will take the same addons.

## Props

### InputGroupAddon

| Prop        | Type                   | Default | Required | Description                                                                                                                                                                                                                                                      |
| ----------- | ---------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `children`  | `ReactNode`            |         | No       |                                                                                                                                                                                                                                                                  |
| `className` | `string`               |         | No       |                                                                                                                                                                                                                                                                  |
| `describe`  | `boolean \| undefined` |         | No       | Adds the addon's text to the input's accessible description, for text that carries meaning, such as "NZD" or "kg". When `false`, the addon is hidden from screen readers, as an icon should be. Defaults to `true` for plain text and `false` for anything else. |

`InputGroupInput` takes every [Input](https://platform.azmara.io/docs/components/input.md) prop, and `InputGroupButton` takes every [Button](https://platform.azmara.io/docs/components/button.md) prop, with `variant="ghost"`, `size="sm"` and `type="button"` as defaults. `InputGroup` takes `className` and the props of a `<div>`.

## Accessibility

- The input keeps its label, description and error wiring; text addons join its description, and icon addons are hidden from screen readers.
- Buttons are real `<button type="button">` elements in the tab order after the input; an icon-only button needs an `aria-label`.
- The focus ring wraps the whole group when the input has focus, and each button shows its own ring.
- Invalid state is shown by the border colour and by the field's error text, never by colour alone.

| Key              | Action                                                      |
| ---------------- | ----------------------------------------------------------- |
| `Tab`            | Moves focus to the input, then to each button in the group. |
| `Shift` or `Tab` | Moves focus back through the buttons to the input.          |
| `Enter`          | On a button, activates it without submitting the form.      |
