Skip to main content

TicketSelector

Implemented

Two modes, one component. Default (`mode` absent) — the inline ticket-quantity stepper (− / number / +) for ticket, pass and merchandise lines, with a translated ariaLabel, polite aria-live output and a 44×44 tap target in every size preset. `mode='card'` — the commerce card the buyer event page renders per ticket / pass / package: bordered rounded surface, name + kind pill, muted caption, clamped description beside price+fees, stepper beside the action button. `mode='row'` (2026-11-02, Ticketpro price-list reference) — one divided row per ticket / pass / package: name + kind pill + badges, caption and a 2-line clamped description on the left, the price (teal) with its fees note, then the stepper on the right; NO action button — the stepper IS the action and starts at 0 (the caller binds `value` to the buyer's selection); a non-buyable row prints `statusMessage` where the stepper would be. Replaces the deprecated `TicketTier` molecule for per-tier quantity input, and replaces the consumer-layer ticket row markup that used to live in storefront/EventTicketList (R8).

Categoryorganisms
Sourcecomponents/organisms/TicketSelector/TicketSelector.tsx
AI HintTwo modes, one component. Stepper mode (omit `mode`) is the inline quantity stepper for ticket, pass and merchandise lines — pass a translated ariaLabel, never hardcoded EN; at min/max the buttons disable rather than hide. Card mode is the four-row commerce card for a buyable ticket / pass / package: row 1 name + right-aligned kind pill + badges, row 2 one muted caption, row 3 muted clamped description beside price + fees, row 4 stepper beside the action. Use it INSTEAD of hand-rolling a ticket row in a consumer file (that duplication was the R8 finding this mode removes). The caller owns the stack and its gap-ep-* spacing, the pick-N checkbox slot, the translation of every label, and the sale verdict via `status`. Row mode is the price list (event page since 2026-11-02): bind `value` to what the buyer has SELECTED (0 to start) and update the selection in `onChange` — there is no per-row Add; a separate panel (CartSummary variant='selection') commits the selection.

Inline ticket-quantity stepper (− / number / +), and the ticket / pass / package card used by the buyer event page. Disabled at min/max so the affordance stays discoverable (WCAG 2.5.5 — 44×44 hit area on every preset).

TicketSelector — stepper

Commerce organism (Family 7)

Clamped quantity stepper with translated ariaLabel and polite aria-live output.

2
1
3

TicketSelector — ticket card

Commerce organism (Family 7)

A plain ticket. No kind pill; the caption carries the per-order limit, the description sits beside the price.

Roulotte / VR / camper

Max 1 per order

Roulotte ou VR avec camping inclus sur le site du festival. Arrivez avant l'ouverture des gates le jeudi en fin d'après-midi.
$120.12incl. $5.12 fees
2

TicketSelector — package card

Commerce organism (Family 7)

A package. The solid pill is right-aligned on the name line and the caption becomes the Includes line. The per-order limit is a second muted line, not a third “included” member.

Shazam

Package

Includes: Général · Balcon

Max 2 per order

Un billet pour chaque soirée, avec accès à la zone partenaire entre les deux concerts.
$120.12incl. $5.12 fees
1

TicketSelector — pass card

Commerce organism (Family 7)

A pass. Same card, the pill reads Pass. The pill renders only when kindLabel is set.

Abonnement saison

Pass

Includes: Vendredi · Samedi · Dimanche

Accès aux trois journées, transférable entre participants jusqu'à 48 h avant le festival.
$239.08incl. $9.08 fees
1

TicketSelector — not-on-sale card

Commerce organism (Family 7)

A card whose caller verdict is not buyable: muted, the status message lands in row 2, the action is disabled and no stepper renders.

Phase 4

This ticket is not on sale yet.

Ouverture de la vente à une date ultérieure. Le verdict vient de l'appelant, jamais du design system.
$85.00incl. $3.57 fees

TicketSelector — long description

Commerce organism (Family 7)

The description is clamped to three lines and a More/Less toggle appears only when the text genuinely overflows.

Roulotte / VR / camper

Max 1 per order

Roulotte ou VR avec camping inclus sur le site du festival. Arrivez avant l'ouverture des gates le jeudi en fin d'après-midi pour éviter la file d'attente. La station-service la plus proche se trouve à environ deux kilomètres de l'entrée principale et les espaces de stationnement sont très limités sur le chemin de Hal.
$120.12incl. $5.12 fees
1

Usage

import { useTranslations } from 'next-intl'
import { TicketSelector } from '@epasslive/design-system'

// Stepper (cart line)
<TicketSelector
  value={item.quantity}
  min={0}
  max={Math.min(item.max_per_order ?? Infinity, item.available + item.quantity)}
  size="sm"
  ariaLabel={t('quantityFor', { name: item.name })}
  onChange={onQuantityChange}
/>

// Card (event page ticket list) — the gap between cards is the caller's stack
<div className="flex flex-col gap-ep-3">
  {tickets.map((ticket) => (
    <TicketSelector
      key={ticket.id}
      mode="card"
      name={ticket.name}
      caption={t('maxPerOrder', { count: ticket.max_per_order })}
      description={ticket.description}
      descriptionMoreLabel={t('more')}
      descriptionLessLabel={t('less')}
      price={money(ticket.final_price ?? ticket.price)}
      feesNote={t('inclFees', { amount: money(fees) })}
      actionLabel={t('add')}
      ariaLabel={`${ticket.name} — ${t('add')}`}
      value={qty}
      min={minStep}
      max={headroom}
      onChange={setPending}
      onAction={() => add(ticket.id, qty)}
    />
  ))}
</div>

Pass ariaLabel a translated string (next-intl t() or tt()) — the organism announces Decrease/Increase <ariaLabel>on each stepper button and labels the wrapper role="group". The current value is announced politely via aria-live on the <output>. In card mode the More/Less toggle is a DS Button variant="ghost" carrying aria-expanded and aria-controls pointing at the clamped description's id.

Accessibility

  • Stepper wrapper is role="group" named by ariaLabel (WCAG 4.1.2).
  • Each stepper button carries aria-label="Decrease/Increase <ariaLabel>".
  • <output> announces the current value with aria-live="polite" (no misused htmlFor on the wrapper div).
  • Interactive surfaces keep a ≥44×44 CSS pixel hit area via --ep-tap-min in every size preset — WCAG 2.5.5 (DS-CORR-08-10). Tailwind arbitrary value plus inline-style fallback to survive utility purging.
  • At min/max the stepper buttons disable (never hide) so the affordance stays discoverable; disabled:cursor-not-allowed disabled:opacity-50 state visible.
  • The More/Less toggle renders only when the description genuinely overflows (measured after layout and re-measured on resize), carries aria-expanded + aria-controls, and releases the clamp on that one card — it is not an accordion.
  • The kind pill is a non-interactive DS Tag: a label, not a control, so it carries no role and no tap-target expansion.
  • focus-visible:ring-2 focus-visible:ring-ep-teal on every interactive control.

Props

TicketSelector — stepper

Prop
Type
Default
Description
value*
number
—

Current quantity. Clamped into [min, max] before render; non-finite values fall back to min.

onChange*
(value: number) => void
—

Called with the next quantity when − / + is activated.

min
number
0

Minimum allowed quantity. Clamped to ≥ 0 and floored to an integer.

max*
number
—

Maximum allowed quantity. If below min, the selector pins to min.

ariaLabel*
string
—

Translated accessible name for the group (e.g. next-intl `t("cart.quantityFor", { name })`).

size
'sm' | 'md' | 'lg'
md

Visual size preset — drives typography only; every preset keeps the 44×44 hit area.

TicketSelector — ticket card

Prop
Type
Default
Description
mode*
'card'
—

Selects the card layout. Omit it entirely for the original stepper.

name*
ReactNode
—

Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
h3

Heading level — match the surrounding page outline.

kindLabel
string
—

"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.

kindPillVariant
TagProps['variant']
brand

Pill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).

badges
ReactNode
—

Free / sold-out / low-stock badges — beside the name, never inside the kind pill.

caption
ReactNode
—

Row-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.

maxPerOrderNote
ReactNode
—

Per-order limit for a pass / package, on its OWN muted line beneath the caption — never ` · `-joined onto it, where `Includes: VIP · Max 2 per order` read as though the limit were a member. Suppressed when the card is not buyable.

description
ReactNode
—

Row-3 description, muted, clamped with a More/Less toggle on real overflow.

descriptionClampLines
number
3

Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.

descriptionMoreLabel
string
"More"

Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).

descriptionLessLabel
string
"Less"

Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).

descriptionId
string
—

Description element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.

price
ReactNode
—

Price, bold and large, top-right of row 3.

feesNote
ReactNode
—

`incl. $X fees`, right-aligned directly beneath the price.

pick
ReactNode
—

Member checkboxes for a pick-N package — their own slot, so the price and the action row never move.

actionLabel*
string
—

Action label — translated ("Add to cart").

onAction
() => void
—

Called when the action button is activated.

actionBusy
boolean
false

Loading state on the action button.

actionDisabled
boolean
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
'primary'

Action variant. Falls back to 'secondary' when the card is not buyable.

status
'buyable' | 'waiting' | 'sold_out'
'buyable'

Sale verdict, mirroring the caller's own state machine (the storefront's ticketRowView). The DS never re-derives it.

statusMessage
ReactNode
—

Status message for a non-buyable card; lands in row 2.

TicketSelector — package card

Prop
Type
Default
Description
mode*
'card'
—

Selects the card layout. Omit it entirely for the original stepper.

name*
ReactNode
—

Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
h3

Heading level — match the surrounding page outline.

kindLabel
string
—

"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.

kindPillVariant
TagProps['variant']
brand

Pill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).

badges
ReactNode
—

Free / sold-out / low-stock badges — beside the name, never inside the kind pill.

caption
ReactNode
—

Row-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.

maxPerOrderNote
ReactNode
—

Per-order limit for a pass / package, on its OWN muted line beneath the caption — never ` · `-joined onto it, where `Includes: VIP · Max 2 per order` read as though the limit were a member. Suppressed when the card is not buyable.

description
ReactNode
—

Row-3 description, muted, clamped with a More/Less toggle on real overflow.

descriptionClampLines
number
3

Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.

descriptionMoreLabel
string
"More"

Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).

descriptionLessLabel
string
"Less"

Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).

descriptionId
string
—

Description element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.

price
ReactNode
—

Price, bold and large, top-right of row 3.

feesNote
ReactNode
—

`incl. $X fees`, right-aligned directly beneath the price.

pick
ReactNode
—

Member checkboxes for a pick-N package — their own slot, so the price and the action row never move.

actionLabel*
string
—

Action label — translated ("Add to cart").

onAction
() => void
—

Called when the action button is activated.

actionBusy
boolean
false

Loading state on the action button.

actionDisabled
boolean
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
'primary'

Action variant. Falls back to 'secondary' when the card is not buyable.

status
'buyable' | 'waiting' | 'sold_out'
'buyable'

Sale verdict, mirroring the caller's own state machine (the storefront's ticketRowView). The DS never re-derives it.

statusMessage
ReactNode
—

Status message for a non-buyable card; lands in row 2.

TicketSelector — pass card

Prop
Type
Default
Description
mode*
'card'
—

Selects the card layout. Omit it entirely for the original stepper.

name*
ReactNode
—

Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
h3

Heading level — match the surrounding page outline.

kindLabel
string
—

"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.

kindPillVariant
TagProps['variant']
brand

Pill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).

badges
ReactNode
—

Free / sold-out / low-stock badges — beside the name, never inside the kind pill.

caption
ReactNode
—

Row-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.

maxPerOrderNote
ReactNode
—

Per-order limit for a pass / package, on its OWN muted line beneath the caption — never ` · `-joined onto it, where `Includes: VIP · Max 2 per order` read as though the limit were a member. Suppressed when the card is not buyable.

description
ReactNode
—

Row-3 description, muted, clamped with a More/Less toggle on real overflow.

descriptionClampLines
number
3

Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.

descriptionMoreLabel
string
"More"

Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).

descriptionLessLabel
string
"Less"

Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).

descriptionId
string
—

Description element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.

price
ReactNode
—

Price, bold and large, top-right of row 3.

feesNote
ReactNode
—

`incl. $X fees`, right-aligned directly beneath the price.

pick
ReactNode
—

Member checkboxes for a pick-N package — their own slot, so the price and the action row never move.

actionLabel*
string
—

Action label — translated ("Add to cart").

onAction
() => void
—

Called when the action button is activated.

actionBusy
boolean
false

Loading state on the action button.

actionDisabled
boolean
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
'primary'

Action variant. Falls back to 'secondary' when the card is not buyable.

status
'buyable' | 'waiting' | 'sold_out'
'buyable'

Sale verdict, mirroring the caller's own state machine (the storefront's ticketRowView). The DS never re-derives it.

statusMessage
ReactNode
—

Status message for a non-buyable card; lands in row 2.

TicketSelector — not-on-sale card

Prop
Type
Default
Description
mode*
'card'
—

Selects the card layout. Omit it entirely for the original stepper.

name*
ReactNode
—

Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
h3

Heading level — match the surrounding page outline.

kindLabel
string
—

"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.

kindPillVariant
TagProps['variant']
brand

Pill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).

badges
ReactNode
—

Free / sold-out / low-stock badges — beside the name, never inside the kind pill.

caption
ReactNode
—

Row-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.

maxPerOrderNote
ReactNode
—

Per-order limit for a pass / package, on its OWN muted line beneath the caption — never ` · `-joined onto it, where `Includes: VIP · Max 2 per order` read as though the limit were a member. Suppressed when the card is not buyable.

description
ReactNode
—

Row-3 description, muted, clamped with a More/Less toggle on real overflow.

descriptionClampLines
number
3

Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.

descriptionMoreLabel
string
"More"

Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).

descriptionLessLabel
string
"Less"

Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).

descriptionId
string
—

Description element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.

price
ReactNode
—

Price, bold and large, top-right of row 3.

feesNote
ReactNode
—

`incl. $X fees`, right-aligned directly beneath the price.

pick
ReactNode
—

Member checkboxes for a pick-N package — their own slot, so the price and the action row never move.

actionLabel*
string
—

Action label — translated ("Add to cart").

onAction
() => void
—

Called when the action button is activated.

actionBusy
boolean
false

Loading state on the action button.

actionDisabled
boolean
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
'primary'

Action variant. Falls back to 'secondary' when the card is not buyable.

status
'buyable' | 'waiting' | 'sold_out'
'buyable'

Sale verdict, mirroring the caller's own state machine (the storefront's ticketRowView). The DS never re-derives it.

statusMessage
ReactNode
—

Status message for a non-buyable card; lands in row 2.

TicketSelector — long description

Prop
Type
Default
Description
mode*
'card'
—

Selects the card layout. Omit it entirely for the original stepper.

name*
ReactNode
—

Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
h3

Heading level — match the surrounding page outline.

kindLabel
string
—

"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.

kindPillVariant
TagProps['variant']
brand

Pill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).

badges
ReactNode
—

Free / sold-out / low-stock badges — beside the name, never inside the kind pill.

caption
ReactNode
—

Row-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.

maxPerOrderNote
ReactNode
—

Per-order limit for a pass / package, on its OWN muted line beneath the caption — never ` · `-joined onto it, where `Includes: VIP · Max 2 per order` read as though the limit were a member. Suppressed when the card is not buyable.

description
ReactNode
—

Row-3 description, muted, clamped with a More/Less toggle on real overflow.

descriptionClampLines
number
3

Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.

descriptionMoreLabel
string
"More"

Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).

descriptionLessLabel
string
"Less"

Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).

descriptionId
string
—

Description element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.

price
ReactNode
—

Price, bold and large, top-right of row 3.

feesNote
ReactNode
—

`incl. $X fees`, right-aligned directly beneath the price.

pick
ReactNode
—

Member checkboxes for a pick-N package — their own slot, so the price and the action row never move.

actionLabel*
string
—

Action label — translated ("Add to cart").

onAction
() => void
—

Called when the action button is activated.

actionBusy
boolean
false

Loading state on the action button.

actionDisabled
boolean
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
'primary'

Action variant. Falls back to 'secondary' when the card is not buyable.

status
'buyable' | 'waiting' | 'sold_out'
'buyable'

Sale verdict, mirroring the caller's own state machine (the storefront's ticketRowView). The DS never re-derives it.

statusMessage
ReactNode
—

Status message for a non-buyable card; lands in row 2.

Edge cases

  • value is clamped into [min, max] on every render (out-of-range → nearest bound; NaN / ±Infinity → min).
  • If max < min, the selector pins to min and both buttons disable.
  • Non-integer inputs are floored; negative min is raised to 0.
  • A description that fits inside the clamp renders no toggle and causes no layout shift.
  • status !== 'buyable' forces the action disabled and the variant to secondary, and suppresses the stepper regardless of the stepper props.

Used By

  • storefront/EventTicketList — card mode, one card per ticket / pass / package in the buyer event page's Tickets panel.
  • storefront/CartContents — stepper mode, cart-line quantity editor with per-item max clamping.

Props

Preview

TicketSelector

Prop
Type
Required
Default
Description
mode
'stepper' | 'card' | 'row'
Optional
'stepper'

Omit it entirely for the original stepper (every existing call site keeps compiling and rendering unchanged). 'card' selects the four-row commerce card; 'row' the price-list row with no action button (the stepper is the action).

disabled
boolean
Optional
false

Stepper (all modes): disables both − and + regardless of the bounds — e.g. before hydration, or while the selection is being committed. The value stays visible.

busy
boolean
Optional
false

Row mode: marks the row aria-busy and pauses its stepper while a mutation it drives is in flight.

value
number
Required
—

Current value.

onChange
(value: number) => void
Required
—

Called when the value changes.

min
number
Optional
0

Minimum allowed value.

max
number
Required
—

Maximum allowed value.

ariaLabel
string
Required
—

Translated accessible name for the group. Pass a next-intl t() / tt() string — never hardcoded EN. Each button announces Decrease/Increase <ariaLabel>; the wrapper is role=group.

size
'sm' | 'md' | 'lg'
Optional
'md'

Visual size preset — drives typography only; every preset keeps the 44×44 hit area (WCAG 2.5.5, IconButton precedent).

name
ReactNode
Optional
—

Row 1. Bold card heading.

nameAs
'h2' | 'h3' | 'h4'
Optional
'h3'

Heading level — match the surrounding page outline.

kindLabel
string
Optional
—

Row 1, right-aligned. 'Pass' / 'Package'. Renders a DS Tag (variant brand, size pill) ONLY when set — a plain ticket never gets a pill.

kindPillVariant
TagProps['variant']
Optional
'brand'

Pill variant. 'brand' is the only solid, theme-stable fill (bg-ep-btn-primary + text-ep-text-inverse, 5.70:1).

badges
ReactNode
Optional
—

Free / sold-out / low-stock badges, beside the name and visually distinct from the kind pill.

caption
ReactNode
Optional
—

Row 2. One muted line: 'Includes: …', 'Max N per order', or the status message. Replaced by statusMessage when status !== 'buyable'.

maxPerOrderNote
ReactNode
Optional
—

Row 2, second line. Per-order limit for a pass / package, rendered as its OWN muted caption beneath `caption` — never ' · '-joined onto it, where 'Includes: VIP · Max 2 per order' read as though the limit were another included member. Suppressed when status !== 'buyable'.

description
ReactNode
Optional
—

Row 3, left. Muted (same tone as row 2), clamped, with a More/Less toggle on real overflow.

descriptionClampLines
number
Optional
3

Clamp height in lines. Resolved against a static class map (1/2/3) so no class is missing from the compiled CSS; unknown values fall back to 3.

descriptionMoreLabel
string
Optional
'More'

Toggle label, collapsed — translated ('More ▾' EN, 'Plus ▾' FR). Never hardcode EN.

descriptionLessLabel
string
Optional
'Less'

Toggle label, expanded — translated ('Less ▴' EN, 'Moins ▴' FR). Never hardcode EN.

descriptionId
string
Optional
auto

Row 3 description element id. Defaults to a stable React.useId() per card and is what the toggle's aria-controls points at.

price
ReactNode
Optional
—

Row 3, right. Bold and large, top-right.

feesNote
ReactNode
Optional
—

Row 3, right-aligned directly beneath the price ('incl. $X fees').

pick
ReactNode
Optional
—

Member checkboxes for a pick-N package. Own slot between rows 2 and 4, so ticking a member never moves the price or the action row.

actionLabel
string
Optional
—

Row 4, right. Translated ('Add to cart').

lineTotal
ReactNode
Optional
—

UAT-02 — qty × unit price for the card's line, rendered as its own row under the stepper/action row ONLY when provided (a qty-0 row renders none). The caller computes it from the SAME unit price the `price` prop shows — one formula, page and cart agree.

lineTotalLabel
string
Optional
'Total'

The line-total row's label — pass a translated string.

onAction
() => void
Optional
—

Called when the row-4 action button is activated.

actionBusy
boolean
Optional
false

Loading state on the action button.

actionDisabled
boolean
Optional
false

Disables the action. Forced on whenever status !== 'buyable'.

actionVariant
ButtonProps['variant']
Optional
'primary'

Action variant. Falls back to 'secondary' when status !== 'buyable'.

status
'buyable' | 'waiting' | 'sold_out'
Optional
'buyable'

Sale verdict, mirroring the caller's own state machine (storefront lib/stage-rows.ts ticketRowView). The DS never re-derives sale state.

statusMessage
ReactNode
Optional
—

Status message for a non-buyable card; lands in row 2, replacing caption.

Quick use:<TicketSelector />

Accessibility

ARIA rolegroup

a11y

4 rules

WCAG 4.1.2

Wrapper renders role=group with the passed ariaLabel

WCAG 4.1.2

Each interactive button carries Decrease/Increase <ariaLabel>

axe aria-prohibited-attr

<output> announces the current value with aria-live=polite (no misused htmlFor)

WCAG 4.1.2

The description More/Less toggle is a DS Button (ghost) carrying aria-expanded and aria-controls, and aria-controls resolves to the clamped description element's id (React.useId default)

behavior

5 rules

edge-case tests

value is clamped into [min, max] before render; NaN/±Infinity falls back to min

edge-case tests

If max < min, the selector pins to min and both buttons disable

AC-12 / overflow-gated tests

The toggle renders ONLY when the description actually overflows (scrollHeight > clientHeight after layout), re-measured via ResizeObserver on the description element so it appears/disappears as the container resizes

AC-14

Expanding releases the clamp on that card only — no sibling card changes, and it is NOT an accordion (no aria-expanded on a parent region)

AC-16 / stage-rows.ts ticketRowView

status !== 'buyable' mutes the card, replaces caption with statusMessage, forces the action disabled + secondary, and suppresses the stepper

tap-target

1 rule

WCAG 2.5.5

All size presets keep a ≥44×44 hit area via --ep-tap-min

back-compat

1 rule

AC-25 / TicketSelector.test.tsx

mode absent renders BYTE-IDENTICAL markup to the pre-card component — same DOM, same classes; the 8 baseline tests pass unmodified

visual

1 rule

R5 / R9

Card chrome uses token utilities only (border-ep-border / rounded-ep-md / bg-ep-surface-card / p-ep-3 / gap-ep-*); the inter-card gap belongs to the CALLER's stack, never a fixed pixel offset

Keyboard

1 rule

R15 / AC-33

Tab order is description toggle → stepper − → + → action; Enter and Space operate the toggle; each stop shows focus-visible:ring-2

Usage

Import

tsxSnippet
import { TicketSelector } from "@epasslive/design-system"

Example 1

tsxSnippet
<TicketSelector value={2} onChange={setQ} max={6} ariaLabel={t('ga')} />

Example 2

tsxSnippet
<TicketSelector value={pending} min={1} max={headroom} size="sm" ariaLabel={`${ticket.name} — ${t('add')}`} onChange={setPending} />

Example 3

tsxSnippet
<TicketSelector mode="card" name={ticket.name} caption={t('maxPerOrder', {count: ticket.max_per_order})} description={ticket.description} price={money(total)} feesNote={t('inclFees', {amount})} actionLabel={t('add')} ariaLabel={label} value={qty} min={minStep} max={headroom} onChange={setPending} onAction={() => add(ticket.id, qty)} />

Example 4

tsxSnippet
<TicketSelector mode="card" name={pkg.name} kindLabel={pkg.kind === 'pass' ? t('passBadge') : t('packageBadge')} caption={`${t('includes')}: ${members.join(' · ')}`} maxPerOrderNote={t('maxPerOrder', { count: pkg.max_per_transaction })} price={money(total)} actionLabel={t('add')} ariaLabel={label} value={pending} min={1} max={headroom} onChange={setPending} onAction={() => addPass(pkg.id, pending)} />

Example 5

tsxSnippet
<TicketSelector mode="card" name={ticket.name} status={row.state} statusMessage={t('notOnSaleYet')} price={money(total)} actionLabel={t('notOnSaleYetShort')} ariaLabel={label} value={qty} max={headroom} onChange={setPending} />

Example 6

tsxSnippet
<TicketSelector mode="row" name={ticket.name} price={money(unit)} feesNote={t('feesIncluded')} status={row.state} statusMessage={t('soldOut')} ariaLabel={ticket.name} value={selected} min={0} max={addable} disabled={!hydrated} onChange={onStep} />

Example 7

tsxSnippet
<TicketSelector mode="card" name={ticket.name} description={ticket.description} descriptionClampLines={3} descriptionMoreLabel={t('more')} descriptionLessLabel={t('less')} price={money(total)} actionLabel={t('add')} ariaLabel={label} value={qty} min={minStep} max={headroom} onChange={setPending} onAction={() => add(ticket.id, qty)} />

Anti-patterns

— Do not hardcode the ariaLabel in English — pass a next-intl t() / tt() string

— Do not hardcode descriptionMoreLabel / descriptionLessLabel — they are translated ('More ▾'/'Less ▴' EN, 'Plus ▾'/'Moins ▴' FR)

— Do not hide the − / + buttons at the floor / ceiling — disable them so the affordance stays discoverable

— Do not re-derive sale state inside the DS — pass `status` from your own state machine

— Do not rebuild a ticket row (name + price + stepper + button) in a consumer file — use mode='card'; that duplication is the R8 finding

— Do not put the inter-card gap inside the card — it belongs to the caller's stack (gap-ep-*)

— Do not pass a raw pixel width for the price or the action; no fixed offsets

— Do not add an Add button beside a row-mode stepper — in row mode the stepper is the action and a separate panel commits

— Do not deep-import — barrel only (@epasslive/design-system)

— Do not wrap the wrapper in a for= target — the <output>'s for must cite form controls

Design System v4.1.0 | ePassLive