A docked, expandable cart for collection and event pages. It has matching React and Vue skins over one shared core.
The drawer is position: fixed over the whole page, so only the controls
sit in the example below. Use the React / Vue tabs to compare the two
skins driven by the same controls and the same mock cart. Show cart
docks the summary bar. Expand cart opens the full cart. It runs on a
canned cart (no network), so emptying and reopening starts fresh.
Cart context
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'// Wrap once in a @tanstack/react-query QueryClientProvider, then:<CartDrawer cart={cart} collectionId={id} onNavigate={go}locale="en-AU" currency="AUD" />
In production you render CartDrawer once on the page, with no buttons. Users
tap or drag the docked bar to expand it. createDemoCart is a docs-only
stub; real apps pass a cart client.
pnpm add @oztix/roadie-widgets
Install the peer dependencies for the skin you use.
React
pnpm add react react-dom @oztix/roadie-components @tanstack/react-query motion \@number-flow/react @phosphor-icons/react react-focus-lock
Vue
pnpm add vue motion @number-flow/vue @phosphor-icons/vue focus-trap
The drawer's classes live in the package's compiled output, so Tailwind needs to scan it. Otherwise the styles never make it into your CSS. Add the CSS imports for the skin you use to your global stylesheet.
React. The React skin renders @oztix/roadie-components, so include its CSS
as well:
/* app/globals.css */@import '@oztix/roadie-core/css';@import '@oztix/roadie-components/css';@import '@oztix/roadie-widgets/css';
Vue. The Vue skin uses core classes only, so it needs no components CSS:
/* app/globals.css */@import '@oztix/roadie-core/css';@import '@oztix/roadie-widgets/css';
Each package's CSS ships its own @source, so Tailwind scans the compiled
classes without you ever writing a node_modules path.
// Reactimport { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
// Vueimport { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'
The framework-agnostic core is imported separately:
import { createCartClient } from '@oztix/roadie-widgets/cart'
The drawer never talks to the network directly. You inject a CartClient. Use
createCartClient to build one from a host plus an optional fetch
implementation. It owns the cart endpoints and sends credentials: 'include'.
A CartClient exposes four methods, so you can supply your own if you need a
custom transport:
The drawer checks two fields from the cart API. extrasUrl must be a
same-origin path with one leading slash, or checkoutUrl returns null and
checkout is unavailable. imageUrl must be an absolute http or https URL, or
the event image is dropped.
Each CartEvent formats its own time row from structured fields. Supply UTC
timestamps and venue-local day keys. The shared core renders the rest the same
way in every skin and consumer.
| Fields supplied | Time row |
|---|---|
eventStartAtUtc | 7pm |
+ eventEndAtUtc (same day) | 7pm to 11pm |
+ eventEndDateKey (different day) | 6:30pm to Sun 4 Oct, 9pm |
Pass eventDateDisplay to override the computed schedule with a pre-formatted
string. It's an escape hatch, so prefer the structured fields.
Events are ordered by eventStartAtUtc and grouped into days by
eventDateKey (YYYY-MM-DD at the venue). Times show in eventTimeZone,
which must be an IANA id such as Australia/Brisbane. Without it, times show
in the viewer's time zone, so supply it whenever venue time matters.
Ticket lines carry their seats as structured data. The drawer groups them
by section and row and collapses consecutive seats into a range via the shared
formatSeatRange helper. React, Vue, and every consumer render seating
identically, with no app-side formatting to drift.
| Seats | Badge |
|---|---|
| Stalls B11, B12 | Stalls B11–12 |
| Stalls B11, B12, B14 | Stalls B11–12, 14 |
| Booth 4 (no row) | Booth 4 |
| Mezzanine M3 + Circle C5 | Mezzanine M3 · Circle C5 |
Omit seats for general-admission tickets. The seat badge only renders when
seats are present. Add an event in the live demo to see the
reserved-seating examples (Tuxedo Possum Cabaret, Velvet Dingo Fest).
The React skin uses @tanstack/react-query internally, so mount it under a
QueryClientProvider. Routing is yours to own, so onNavigate is required.
The Vue skin also exports useRoadieTheme to mirror the host app's light/dark
mode onto the drawer (which renders to <body>).
By default the drawer is uncontrolled. Users tap or drag the docked bar, and it
owns its state (seed it with initialState). To open or close it from elsewhere
in your UI (e.g. an inline "View cart" button), control it with open +
onOpenChange: changing open animates the drawer with the same spring as a
tap, and tap/drag report back through onOpenChange so you keep them in sync.
// Reactconst [open, setOpen] = useState(false);<button onClick={() => setOpen(true)}>View cart</button>;<CartDrawer open={open} onOpenChange={setOpen} cart={cart} … />
<!-- Vue: v-model:open --><button @click="open = true">View cart</button><CartDrawer v-model:open="open" :cart="cart" … />
The docked drawer writes its height to --cart-drawer-height on <html>, and
onHeightChange reports the same height in pixels. The drawer also floats
above the bottom edge (bottom-3, or bottom-4 from sm), so pad the bottom
of your page by both or the last of your content sits under the drawer.
context decides what the open-state secondary button ("Browse events") does:
'collection' (default) closes the drawer. The collection page is already
behind it.'event' navigates to the collection page to browse more events.The browse target is always built inside the package from the
server-trusted collectionId and routed through onNavigate. Consumers never
supply the browse URL, which removes the open-redirect surface. Pass
browseHref only to override the default collection-cart route; unsafe values
fall back to the safe default.
The drawer caches its summary and details. Bump refreshKey after a mutation
elsewhere in the app (for example, adding tickets on the event page) to force a
fresh fetch.
The drawer itself only runs the urgency countdown and fires onExpire when the
hold reaches zero. It does not render any expiry UI. The full
"hold expiring / hold ended" experience is opt-in, shipped as two exports per
skin that you compose yourself:
useCartExpiry(expiresAtUtc) is a headless hook (React) / composable
(Vue), with no UI of its own. It runs the same one-second countdown as the badge and
returns { remaining, expired, showWarning, dismissWarning }. showWarning
turns on inside the final urgency band (under two minutes) and is a one-shot
per expiresAtUtc: dismissing hides it until the server extends the hold,
which re-arms it. expired becomes true once the hold reaches zero.CartExpiryDialogs is the presentational layer, built on the
Dialog compound. Given that state it renders two
alertdialogs. The first is a dismissible "Still here?" warning (warning
intent) with a live mm:ss countdown and Keep browsing / Checkout actions.
Checkout stays disabled until checkoutUrl is ready. The second is a blocking
"Your hold has ended" dialog (danger intent, no light-dismiss) whose only
exit is Browse events.Trigger each dialog to see it. Switch the React / Vue tabs to compare skins.
The warning is light-dismissible (close button, backdrop, or Escape); the
expired dialog is blocking, so Browse events is the only way out.
import { CartExpiryDialogs, useCartExpiry } from '@oztix/roadie-widgets/cart-drawer/react'const { remaining, expired, showWarning, dismissWarning } = useCartExpiry(expiresAtUtc)<CartExpiryDialogs showWarning={showWarning} expired={expired} remaining={remaining}onDismissWarning={dismissWarning} checkoutUrl={url} browseHref="/events" onNavigate={go} />
Wire them together once, alongside the drawer. In production you derive the
props from useCartExpiry (the Vue composable takes a getter so it tracks prop
changes):
The Vue skin exposes the same CartExpiryDialogs component and useCartExpiry
composable from @oztix/roadie-widgets/cart-drawer/vue, with identical props.
CartExpiryModals remains as a deprecated alias and will be removed in v3.0.0.
CartExpiryDialogs' props are documented in the API reference
below.
react-focus-lock and Vue uses focus-trap.lockBodyScroll).prefers-reduced-motion: the open/close and drag
animations are reduced when the user has requested it.The Vue skin accepts the same props as the React skin documented below.
Core cart client (host + fetch injected by the consuming app).
REQUIRED — routing is the consumer's job.
Empty-state "Browse" target. Defaults to a safe collectionId-derived path; unsafe values fall back to that default.
Mount context for the open-state secondary button. Browse target is package-built from collectionId — never a consumer URL — to avoid open-redirect.
Defaults to collection.
Locale for currency/date formatting.
ISO 4217 currency code.
Bump to force a refetch of summary + details.
Lock body scroll while open. Default true.
Defaults to true.
Controlled open state. When provided, the drawer animates to match it — drive it from your own state so any UI (e.g. a "View cart" button) can open/close it, echoing `onOpenChange` back into it. Omit for uncontrolled (tap/drag) behaviour seeded by `initialState`.
Uncontrolled initial state when `open` is omitted. Default 'closed'.
Defaults to closed.
Fires on every open/close intent (tap, drag, Escape, backdrop, or `open`).
Fires when the urgency countdown hits expiry.
Reports the docked (closed) drawer height in px.
The hold's expiry dialogs, given `useCartExpiry`'s state: a dismissible "Still here?" warning with a countdown, then a blocking "Your hold has ended" dialog whose only exit is browsing events.