PillsInput

Pills laid out beside a text input, drawn as one field.

import { ... } from '@corndev/ui'
openapigraphql

Enter adds a tag. Backspace in an empty field removes the last one.

invalid
locked

HTML

<div class="corn-pills-input" data-size="md">
  <span class="corn-pill" data-size="md">
    <span class="corn-pill-label">production</span>
    <button type="button" class="corn-pill-remove" aria-label="Remove production"></button>
  </span>
  <input class="corn-pills-input-field" aria-label="Add a tag" />
</div>

Classes

Class
.corn-pills-input
.corn-pills-input-field

Attributes

AttributeValues
data-sizesm|md|lg
data-disabledpresent or absent
data-errorpresent or absent

Vanilla JS

Required for behaviour — adds focuses the input when the field’s empty space is clicked.

import { createPillsInput } from '@corndev/ui';
import '@corndev/ui/theme';

const el = document.querySelector('#target');
const instance = createPillsInput(el, { /* options */ });

Options

OptionTypeDefaultDescription
size'sm'|'md'|'lg''md'Padding and type scale. The pills inside inherit it.
disabledbooleanfalseDims the field and disables the inner input with the native attribute.
errorbooleanfalsePaints the error border and focus ring. The message itself belongs to the field around this, not here.

React

PillsInput, PillsInputField — from @corndev/ui-react.

Takes the pills and the field as children rather than a values array, so the thing holding the list keeps deciding what a value is and what removing one means. PillsInputField is a plain <input> with the chrome removed — value, onChange, onKeyDown and the rest go straight through it.

The props are the same as the options above, plus className.

What it is and is not

PillsInput is the field: the border, the one focus ring, and the layout that keeps pills and a text input looking like a single control.

It is not a value editor. It takes the pills and the input as children rather than a values array, so whatever holds the list keeps deciding what a value is and what removing one means. MultiSelect and TagsInput are the components that own that decision; this is what they are drawn in. Use it directly when you want that control yourself.

openapigraphql
<PillsInput>
  {tags.map(tag => (
    <Pill key={tag} onRemove={value => setTags(tags.filter(t => t !== value))}>
      {tag}
    </Pill>
  ))}
  <PillsInputField
    aria-label="Add a tag"
    value={draft}
    onChange={event => setDraft(event.target.value)}
  />
</PillsInput>

One focus ring

The ring is on the wrapper, from :focus-within, and the inner input has no chrome of its own. Two borders nested inside one another is the thing this component exists to avoid.

:focus-within catches the remove buttons as well as the input, which is right — a keyboard user tabbing onto a pill’s remove control is still inside this field.

Why the wrapper is a div

Worth knowing before you simplify it. A <label> wrapper would give click-anywhere-to-focus with no JavaScript, and it is a trap: a label with no for targets the first labelable descendant in tree order, and <button> is labelable. With pills before the input, clicking the field’s empty space fires the first pill’s remove control and deletes a value. Measured in Chromium, from 25px outside the button’s own box.

So click-to-focus is the one thing here that needs JavaScript. Everything else — the border, the ring, the wrapping layout — is the stylesheet, and the markup works without a script.

States

invalid
<PillsInput error>
  <Pill onRemove={remove}>invalid</Pill>
  <PillsInputField aria-label="A field in error" />
</PillsInput>

error paints the border and ring only. The message itself belongs to the Field around this, which owns the label, the description and the aria-describedby wiring.

disabled dims the field; pass it to the inner control too, since the control is a child here and nothing is reaching in to disable it for you.

Naming the input

The input needs its own accessible name — aria-label, or a Field around the whole thing. The pills beside it are values, not a label, and a screen reader reading “openapi graphql” as the name of a text box helps nobody.