v1.0

OptionPill

Preview

Basic

Loading…

Preview

Code

ts
import OptionPill from '@/components/data/OptionPill';

src/components/data/OptionPill.tsx

AI prompt

text
Build a select-option pill (tinted tag chip, optionally removable) component in React + TypeScript + Tailwind CSS. It draws one user-defined option (a status, tag or priority) the same way in a grid cell, a filter list and a form.

## Look
- `inline-flex max-w-full items-center gap-1 rounded-full py-1 text-[11px] font-medium leading-none ring-1 ring-inset align-middle`, padded `px-2.5`, or `pl-2.5 pr-1` when removable. The label truncates. `leading-none` and `align-middle` stop it inheriting a table cell's line-height.
- Seventeen tones, in this palette order: sky, amber, slate, emerald, zinc, rose, violet, indigo, orange, yellow, lime, teal, cyan, blue, purple, fuchsia, pink. Each is `bg-<hue>-50 text-<hue>-700 ring-<hue>-200`, with dark `bg-<hue>-950/40 text-<hue>-300 ring-<hue>-900`. The exceptions: slate is dark `bg-slate-800/60` with a slate-700 ring; zinc is `bg-zinc-100 text-zinc-600`, dark `bg-zinc-800/60 text-zinc-400 ring-zinc-700`. Write every class string out in full. Tailwind cannot see interpolated names like `bg-${hue}-50`, so they compile to nothing.
- An unknown tone draws slate.
- Removable chip: a 16×16 round × button inside the pill (10px X icon) at 60% opacity, 100% on hover, with a hover background of `black/10` / dark `white/10`.

## Behaviour
- The × button has `aria-label="Remove <label>"` and `tabIndex={-1}`. It calls `preventDefault` and `stopPropagation` on mousedown, so an editor's input keeps focus. Otherwise the click would remove the chip AND blur the field, closing the editor in one gesture. Click stops propagation and calls `onRemove`.
- `title` defaults to the label, so a truncated pill can be read on hover.
- Also export `optionTone(option, index)`: the option's own tone when it has a valid one, else the tone at `index % 17` in palette order. Index against the FULL option list, not a filtered one, or removing an option recolours every option after it.
- Also export `OptionPills({ value, options, wrap? })`, which renders a stored value (a string or an array) as pills. Each value is looked up in `options` for its label and tone. A value no option matches shows the raw value in slate, so a wiring mistake stays visible. Empty or null renders nothing. `wrap` is `flex flex-wrap gap-1` for forms; otherwise one line, `overflow-hidden whitespace-nowrap`, clipped at the right for grid cells.

## API
`OptionPill`: `label: string`, `tone?: string` (slate), `onRemove?: () => void`, `className?`, `title?`. `OptionPills`: `value: unknown`, `options: { value: string; label: string; tone?: string }[]`, `wrap?: boolean`, `className?`.

## Demo
Statuses with stored tones (To do slate, In progress sky, In review amber, Done emerald). Tags coloured by position (Bug, Feature, Docs, Infra, Customer). Three removable chips, with a Reset button once all are gone.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { TONE_CLASSES, isTone, toneAt } from '@/lib/tones';

/* Origin: ticket-management (96S2) `tickets/OptionPill.tsx`. */

/**
 * ONE SELECT OPTION, as a tinted pill in the option's own tone — the shape a
 * grid cell, a filter checklist and a record form all draw a choice with, so
 * a value reads identically everywhere.
 *
 * `Badge` is its sibling for a STATUS in one of five semantic tones. This is
 * for a user-defined option, which needs the full seventeen-tone palette so
 * two options in one field never share a colour until the palette wraps.
 *
 * `onRemove` turns the pill into a CHIP: the same pill with a small × inside
 * it. The × is a real button with a label, and its `mousedown` is swallowed so
 * an editor's input keeps focus — a chip removed by a click that also blurred
 * the field would commit the removal and close the editor in one gesture.
 */
export default function OptionPill({
  label,
  tone = 'slate',
  onRemove,
  className,
  title,
}: {
  label: string;
  /** A tone name from `lib/tones`. An unknown one draws slate. */
  tone?: string;
  onRemove?: () => void;
  className?: string;
  title?: string;
}) {
  return (
    <span
      title={title ?? label}
      className={cn(
        // `leading-none` and `align-middle`: the density rules put a fixed
        // line-height on every table cell and an inline-flex child inherits it.
        'inline-flex max-w-full items-center gap-1 rounded-full py-1 text-[11px] font-medium leading-none ring-1 ring-inset align-middle',
        onRemove ? 'pl-2.5 pr-1' : 'px-2.5',
        TONE_CLASSES[isTone(tone) ? tone : 'slate'],
        className,
      )}
    >
      <span className="truncate">{label}</span>
      {onRemove ? (
        <button
          type="button"
          aria-label={`Remove ${label}`}
          tabIndex={-1}
          onMouseDown={(e) => {
            e.preventDefault();
            e.stopPropagation();
          }}
          onClick={(e) => {
            e.stopPropagation();
            onRemove();
          }}
          className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
        >
          <X className="h-2.5 w-2.5" aria-hidden />
        </button>
      ) : null}
    </span>
  );
}

/**
 * The tone an option draws in: its own when it carries one, else a colour by
 * POSITION in the full option list. Index against the unfiltered list, or a
 * removed option recolours every one after it.
 */
export function optionTone(option: { tone?: string } | undefined, index: number): string {
  return option?.tone && isTone(option.tone) ? option.tone : toneAt(index);
}

/**
 * A STORED VALUE of a select or multi-select, as pills. A value no option
 * matches shows the RAW value in slate rather than nothing: hiding it would
 * hide a wiring mistake. `wrap` is for a form, where chips may flow onto
 * several lines; a grid cell keeps them on one line and clips at the right.
 */
export function OptionPills({
  value,
  options,
  wrap = false,
  className,
}: {
  value: unknown;
  options: readonly { value: string; label: string; tone?: string }[];
  wrap?: boolean;
  className?: string;
}) {
  const picked = Array.isArray(value)
    ? value.map((v) => String(v))
    : value == null || value === ''
      ? []
      : [String(value)];
  if (picked.length === 0) return null;

  return (
    <span
      className={cn(
        wrap ? 'flex flex-wrap gap-1' : 'inline-flex max-w-full items-center gap-1 overflow-hidden whitespace-nowrap align-middle',
        className,
      )}
    >
      {picked.map((v) => {
        const at = options.findIndex((o) => o.value === v);
        const option = at >= 0 ? options[at] : undefined;
        return <OptionPill key={v} label={option?.label ?? v} tone={option ? optionTone(option, at) : 'slate'} className="shrink-0" />;
      })}
    </span>
  );
}

Props

PropTypeDefaultDescription
label*string—
tonestring'slate'A tone name from `lib/tones`. An unknown one draws slate.
onRemove() => void—
classNamestring—
titlestring—