← All stories

COMPONENT · ui-status-pill

ui-status-pill

Component Tier 1 (primitive) Used wherever a status (guest, job, mailing, sync, transfer) is rendered as a chip

A small colored chip that names a state. Looks trivial; carries the whole "what does this status mean to me" UX. Color cannot be the only differentiator (a11y), text is required, and the registered status set is closed — branches don't invent new statuses inline, they extend the registry.

Component contract

Renders a status as a colored chip with text. Looks up presentation from a central registry rather than letting callers pass arbitrary colors.

  • kind: StatusKind — closed enum: see registry below.
  • label?: string — overrides the registry's default label (rare).
  • tone?: "default" | "subtle" | "outline" — visual variant; default fills the pill, subtle uses a light tint, outline is borderless background.
  • icon?: ReactNode — optional leading icon. The registry supplies a default for some statuses (e.g. checkmark for "checked-in").
  • aria-describedby?: string — id of an external longer description (used in jobs lists where "failed" expands to a reason).

Status registry

The closed set of statuses Voyage admin recognizes. Each has a semantic category that drives color, an icon, and a default label. Branches that need a status not in this list extend the registry — they don't pass freeform color or label inline.

kindcategorydefault labeltypical use
draftneutralDraftUnpublished events, designs, mailings
scheduledinfoScheduledEvents, mailings yet to send
activepositiveActiveEvents live, integrations connected
archivedneutralArchivedPast events
invitedinfoInvitedGuest list rows
registeredpositiveRegisteredGuest list rows, accepted invites
declinedneutralDeclinedGuest list rows
waitlistedwarningWaitlistedGuest list, waitlist report
checked-inpositiveChecked inDay-of, check-in console
cancelleddangerCancelledRegistrations, jobs, scheduled mailings
queuedneutralQueuedReport jobs, mailings
runninginfoRunningReport jobs in progress; pulses subtly
readypositiveReadyReport job complete with downloadable artifact
faileddangerFailedJobs, mailings, sync runs
expireddangerExpiredSigned URLs, invitations, calendar links
deliveredpositiveDeliveredEmail events
openedinfoOpenedEmail events
clickedinfoClickedEmail events
bounceddangerBouncedEmail events; usually paired with a reason
suppressedwarningSuppressedEmail events; recipient on suppression list
connectedpositiveConnectedIntegrations (SF, Zoom)
disconnecteddangerDisconnectedIntegrations
syncinginfoSyncingIntegrations mid-flight
partialwarningPartialImports/exports with some failures

Failure modes

Color is the only differentiator

Trigger: registered, waitlisted, and declined render with different colors but the same icon (or no icon) and the user is colorblind.

Each status renders text always. Optional icon when registry supplies one. Text alone disambiguates; color reinforces. Harness: render each status with browser-level grayscale filter, screenshot, OCR-extract text, assert each status's text is the registry's default label.

Custom label silently dropped

Trigger: parent passes label="Re-invited" on a kind=invited pill but rendering shows the registry default "Invited".

When label prop is provided, it replaces the registry default. Harness: render kind=invited, label="Re-invited", assert visible text is "Re-invited".

Unknown status crashes the table

Trigger: API returns a status the registry doesn't know (e.g., new EFx status).

Component renders a fallback "Unknown" pill in neutral category and emits a dev-mode warning. It does NOT throw or render empty. Harness: render kind="not-a-real-status", assert visible text "Unknown" AND console warning logged.

Pulsing animation on "running" doesn't pause for prefers-reduced-motion

Trigger: subtle pulse animation runs even when the user has motion-reduction enabled.

Pulse animation respects @media (prefers-reduced-motion: reduce) and switches to a static visual indicator (e.g., a fixed dot) instead. Harness: emulate prefers-reduced-motion, render kind=running, assert no animation property in computed style.

Color contrast fails on subtle tone

Trigger: the "subtle" tone uses a 10% tint background with the same text color as default tone; contrast drops below 4.5:1.

Each tone × category combination must independently pass 4.5:1. Harness sweeps every status × every tone, asserts each passes contrast.

Long custom label breaks layout

Trigger: a parent passes a 50-character label and the pill expands across the entire row.

Pill caps at max-width: 16ch with text-overflow ellipsis. Full label is exposed via title attribute or a screen-reader-only span. Harness: render with 50-char label, assert pill computed-width ≤ 16ch + padding, assert title attr contains full label.

Pill is announced with verbose noise by screen readers

Trigger: each pill has aria-label="status pill registered icon checkmark" — screen reader reads the chrome.

Pill has role="status" only when it's a live update (e.g., job running → ready transition). Static pills are just text. Icon has aria-hidden="true". Harness: render static pill, assert SR text equals the visible label.

Accessibility

  • Each pill renders the label as visible text. Color is not the only differentiator.
  • Icons are decorative: aria-hidden="true".
  • Pills used as live updates (running → ready transition) get role="status" and aria-live="polite".
  • Color contrast: every status × every tone meets 4.5:1.
  • Animation respects prefers-reduced-motion.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-status-pillOuter spanComponent identity; data-status attr carries the kind
ui-status-pill-iconLeading iconPresent when registry supplies an icon
ui-status-pill-labelText contentAlways present

Agent test plan

Standalone probes against /admin-test/ui-status-pill-fixture with variants: every registered status × every tone, plus unknown, custom-label, long-custom-label, prefers-reduced-motion.

Probe list
- text-always-rendered: every status kind, label visible text
- registry-defaults: kind=registered, no label prop → text "Registered"
- custom-label-overrides: kind=invited, label="Re-invited" → text "Re-invited"
- unknown-fallback: kind="bogus" → text "Unknown" AND console warning
- data-status-attr: kind=ready → outer span has data-status="ready"
- icon-aria-hidden: pills with icons → icon has aria-hidden=true
- live-update-role-status: render kind=running, transition to kind=ready, assert role=status during transition
- color-contrast-default-every-status: ≥ 4.5:1 for all 24 statuses
- color-contrast-subtle-every-status: ≥ 4.5:1 for all 24 statuses in subtle tone
- color-contrast-outline-every-status: ≥ 4.5:1 for all 24 statuses in outline tone
- prefers-reduced-motion: kind=running, motion-reduce active, no animation in computed style
- long-label-truncates: 50-char label, computed width ≤ 16ch + padding
- long-label-title-attr: 50-char label, title attr contains full label
- text-disambiguates-without-color: render with grayscale filter, OCR-extract, assert text equals expected
- axe-clean-serious: no serious violations