TicketSelector
ImplementedTwo 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).
components/organisms/TicketSelector/TicketSelector.tsxInline 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.
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
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
Includes: Général · Balcon
Max 2 per order
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
Includes: Vendredi · Samedi · Dimanche
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.
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
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 byariaLabel(WCAG 4.1.2). - Each stepper button carries
aria-label="Decrease/Increase <ariaLabel>". <output>announces the current value witharia-live="polite"(no misusedhtmlForon the wrapper div).- Interactive surfaces keep a ≥44×44 CSS pixel hit area via
--ep-tap-minin 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-50state 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-tealon every interactive control.
Props
TicketSelector — stepper
value*numberCurrent quantity. Clamped into [min, max] before render; non-finite values fall back to min.
onChange*(value: number) => voidCalled with the next quantity when − / + is activated.
minnumber0Minimum allowed quantity. Clamped to ≥ 0 and floored to an integer.
max*numberMaximum allowed quantity. If below min, the selector pins to min.
ariaLabel*stringTranslated accessible name for the group (e.g. next-intl `t("cart.quantityFor", { name })`).
size'sm' | 'md' | 'lg'mdVisual size preset — drives typography only; every preset keeps the 44×44 hit area.
TicketSelector — ticket card
mode*'card'Selects the card layout. Omit it entirely for the original stepper.
name*ReactNodeBold card heading.
nameAs'h2' | 'h3' | 'h4'h3Heading level — match the surrounding page outline.
kindLabelstring"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.
kindPillVariantTagProps['variant']brandPill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).
badgesReactNodeFree / sold-out / low-stock badges — beside the name, never inside the kind pill.
captionReactNodeRow-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.
maxPerOrderNoteReactNodePer-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.
descriptionReactNodeRow-3 description, muted, clamped with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.
descriptionMoreLabelstring"More"Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).
descriptionLessLabelstring"Less"Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).
descriptionIdstringDescription element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.
priceReactNodePrice, bold and large, top-right of row 3.
feesNoteReactNode`incl. $X fees`, right-aligned directly beneath the price.
pickReactNodeMember checkboxes for a pick-N package — their own slot, so the price and the action row never move.
actionLabel*stringAction label — translated ("Add to cart").
onAction() => voidCalled when the action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['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.
statusMessageReactNodeStatus message for a non-buyable card; lands in row 2.
TicketSelector — package card
mode*'card'Selects the card layout. Omit it entirely for the original stepper.
name*ReactNodeBold card heading.
nameAs'h2' | 'h3' | 'h4'h3Heading level — match the surrounding page outline.
kindLabelstring"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.
kindPillVariantTagProps['variant']brandPill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).
badgesReactNodeFree / sold-out / low-stock badges — beside the name, never inside the kind pill.
captionReactNodeRow-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.
maxPerOrderNoteReactNodePer-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.
descriptionReactNodeRow-3 description, muted, clamped with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.
descriptionMoreLabelstring"More"Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).
descriptionLessLabelstring"Less"Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).
descriptionIdstringDescription element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.
priceReactNodePrice, bold and large, top-right of row 3.
feesNoteReactNode`incl. $X fees`, right-aligned directly beneath the price.
pickReactNodeMember checkboxes for a pick-N package — their own slot, so the price and the action row never move.
actionLabel*stringAction label — translated ("Add to cart").
onAction() => voidCalled when the action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['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.
statusMessageReactNodeStatus message for a non-buyable card; lands in row 2.
TicketSelector — pass card
mode*'card'Selects the card layout. Omit it entirely for the original stepper.
name*ReactNodeBold card heading.
nameAs'h2' | 'h3' | 'h4'h3Heading level — match the surrounding page outline.
kindLabelstring"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.
kindPillVariantTagProps['variant']brandPill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).
badgesReactNodeFree / sold-out / low-stock badges — beside the name, never inside the kind pill.
captionReactNodeRow-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.
maxPerOrderNoteReactNodePer-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.
descriptionReactNodeRow-3 description, muted, clamped with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.
descriptionMoreLabelstring"More"Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).
descriptionLessLabelstring"Less"Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).
descriptionIdstringDescription element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.
priceReactNodePrice, bold and large, top-right of row 3.
feesNoteReactNode`incl. $X fees`, right-aligned directly beneath the price.
pickReactNodeMember checkboxes for a pick-N package — their own slot, so the price and the action row never move.
actionLabel*stringAction label — translated ("Add to cart").
onAction() => voidCalled when the action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['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.
statusMessageReactNodeStatus message for a non-buyable card; lands in row 2.
TicketSelector — not-on-sale card
mode*'card'Selects the card layout. Omit it entirely for the original stepper.
name*ReactNodeBold card heading.
nameAs'h2' | 'h3' | 'h4'h3Heading level — match the surrounding page outline.
kindLabelstring"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.
kindPillVariantTagProps['variant']brandPill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).
badgesReactNodeFree / sold-out / low-stock badges — beside the name, never inside the kind pill.
captionReactNodeRow-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.
maxPerOrderNoteReactNodePer-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.
descriptionReactNodeRow-3 description, muted, clamped with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.
descriptionMoreLabelstring"More"Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).
descriptionLessLabelstring"Less"Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).
descriptionIdstringDescription element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.
priceReactNodePrice, bold and large, top-right of row 3.
feesNoteReactNode`incl. $X fees`, right-aligned directly beneath the price.
pickReactNodeMember checkboxes for a pick-N package — their own slot, so the price and the action row never move.
actionLabel*stringAction label — translated ("Add to cart").
onAction() => voidCalled when the action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['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.
statusMessageReactNodeStatus message for a non-buyable card; lands in row 2.
TicketSelector — long description
mode*'card'Selects the card layout. Omit it entirely for the original stepper.
name*ReactNodeBold card heading.
nameAs'h2' | 'h3' | 'h4'h3Heading level — match the surrounding page outline.
kindLabelstring"Pass" / "Package". Renders the kind pill only for a pass or a package — never for a plain ticket.
kindPillVariantTagProps['variant']brandPill variant. `brand` is the only solid, theme-stable fill (5.70:1 on any surface).
badgesReactNodeFree / sold-out / low-stock badges — beside the name, never inside the kind pill.
captionReactNodeRow-2 caption: `Includes: …`, `Max N per order`, or the status message. Replaced by statusMessage when the card is not buyable.
maxPerOrderNoteReactNodePer-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.
descriptionReactNodeRow-3 description, muted, clamped with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp height in lines. Values outside the known set fall back to 3 so no class is missing from the compiled CSS.
descriptionMoreLabelstring"More"Toggle label, collapsed — translated ("More ▾" EN, "Plus ▾" FR).
descriptionLessLabelstring"Less"Toggle label, expanded — translated ("Less ▴" EN, "Moins ▴" FR).
descriptionIdstringDescription element id. Defaults to a stable per-instance id and is wired to the toggle's aria-controls.
priceReactNodePrice, bold and large, top-right of row 3.
feesNoteReactNode`incl. $X fees`, right-aligned directly beneath the price.
pickReactNodeMember checkboxes for a pick-N package — their own slot, so the price and the action row never move.
actionLabel*stringAction label — translated ("Add to cart").
onAction() => voidCalled when the action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['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.
statusMessageReactNodeStatus message for a non-buyable card; lands in row 2.
Edge cases
valueis clamped into[min, max]on every render (out-of-range → nearest bound;NaN/±Infinity→min).- If
max < min, the selector pins tominand both buttons disable. - Non-integer inputs are floored; negative
minis raised to0. - A description that fits inside the clamp renders no toggle and causes no layout shift.
status !== 'buyable'forces the action disabled and the variant tosecondary, 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
TicketSelector
mode'stepper' | 'card' | 'row''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).
disabledbooleanfalseStepper (all modes): disables both − and + regardless of the bounds — e.g. before hydration, or while the selection is being committed. The value stays visible.
busybooleanfalseRow mode: marks the row aria-busy and pauses its stepper while a mutation it drives is in flight.
valuenumber—Current value.
onChange(value: number) => void—Called when the value changes.
minnumber0Minimum allowed value.
maxnumber—Maximum allowed value.
ariaLabelstring—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''md'Visual size preset — drives typography only; every preset keeps the 44×44 hit area (WCAG 2.5.5, IconButton precedent).
nameReactNode—Row 1. Bold card heading.
nameAs'h2' | 'h3' | 'h4''h3'Heading level — match the surrounding page outline.
kindLabelstring—Row 1, right-aligned. 'Pass' / 'Package'. Renders a DS Tag (variant brand, size pill) ONLY when set — a plain ticket never gets a pill.
kindPillVariantTagProps['variant']'brand'Pill variant. 'brand' is the only solid, theme-stable fill (bg-ep-btn-primary + text-ep-text-inverse, 5.70:1).
badgesReactNode—Free / sold-out / low-stock badges, beside the name and visually distinct from the kind pill.
captionReactNode—Row 2. One muted line: 'Includes: …', 'Max N per order', or the status message. Replaced by statusMessage when status !== 'buyable'.
maxPerOrderNoteReactNode—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'.
descriptionReactNode—Row 3, left. Muted (same tone as row 2), clamped, with a More/Less toggle on real overflow.
descriptionClampLinesnumber3Clamp 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.
descriptionMoreLabelstring'More'Toggle label, collapsed — translated ('More ▾' EN, 'Plus ▾' FR). Never hardcode EN.
descriptionLessLabelstring'Less'Toggle label, expanded — translated ('Less ▴' EN, 'Moins ▴' FR). Never hardcode EN.
descriptionIdstringautoRow 3 description element id. Defaults to a stable React.useId() per card and is what the toggle's aria-controls points at.
priceReactNode—Row 3, right. Bold and large, top-right.
feesNoteReactNode—Row 3, right-aligned directly beneath the price ('incl. $X fees').
pickReactNode—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.
actionLabelstring—Row 4, right. Translated ('Add to cart').
lineTotalReactNode—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.
lineTotalLabelstring'Total'The line-total row's label — pass a translated string.
onAction() => void—Called when the row-4 action button is activated.
actionBusybooleanfalseLoading state on the action button.
actionDisabledbooleanfalseDisables the action. Forced on whenever status !== 'buyable'.
actionVariantButtonProps['variant']'primary'Action variant. Falls back to 'secondary' when status !== 'buyable'.
status'buyable' | 'waiting' | 'sold_out''buyable'Sale verdict, mirroring the caller's own state machine (storefront lib/stage-rows.ts ticketRowView). The DS never re-derives sale state.
statusMessageReactNode—Status message for a non-buyable card; lands in row 2, replacing caption.
<TicketSelector />Accessibility
a11y
4 rulesbehavior
5 rulestap-target
1 ruleback-compat
1 rulevisual
1 ruleKeyboard
1 ruleUsage
Import
import { TicketSelector } from "@epasslive/design-system"Example 1
<TicketSelector value={2} onChange={setQ} max={6} ariaLabel={t('ga')} />Example 2
<TicketSelector value={pending} min={1} max={headroom} size="sm" ariaLabel={`${ticket.name} — ${t('add')}`} onChange={setPending} />Example 3
<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
<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
<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
<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
<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