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
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | |
tone | string | 'slate' | A tone name from `lib/tones`. An unknown one draws slate. |
onRemove | () => void | — | |
className | string | — | |
title | string | — |