CartDrawer
A docked, expandable cart for collection and event pages — matching React and Vue skins over one shared core.
- Fetches its own summary and details, then groups items by event
- Locale-aware currency and dates, with a live urgency countdown
- Self-contained focus trap, body scroll lock, and motion
Live demo
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 — no buttons — and let
users tap or drag the docked bar to expand it. createDemoCart is a docs-only
stub; real apps pass a cart client.
Installation
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
Tailwind setup
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 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.
Import
// 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 cart client
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:
Event schedules
Each CartEvent formats its own time row from structured fields — supply UTC
timestamps and venue-local day keys, and the shared core renders the rest the
same way in every skin and consumer.
| Fields supplied | Time row |
|---|---|
eventStartAtUtc | 7pm |
+ eventEndAtUtc (same day) | 7pm – 11pm |
+ eventEndDateKey (different day) | 6:30pm – Sun 4 Oct, 9pm |
Pass eventDateDisplay to override the computed schedule with a pre-formatted
string (an escape hatch — prefer the structured fields).
Reserved seating
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, so React, Vue, and every consumer render seating
identically — 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 (Harbourside Jazz, Festival of Lights).
Usage
React
The React skin uses @tanstack/react-query internally, so mount it under a
QueryClientProvider. Routing is yours to own — onNavigate is required.
Vue
The Vue skin also exports useRoadieTheme to mirror the host app's light/dark
mode onto the drawer (which renders to <body>).
Controlling open/close
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" … />
Mount context
context decides what the open-state secondary button ("Browse events") does:
'collection'(default) — the collection page is already behind the drawer, so the button just closes 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.
Forcing a refetch
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.
Expiry
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)— a headless hook (React) / composable (Vue), no UI of its own. It runs the same one-second countdown as the badge and returns{ remaining, expired, showWarning, dismissWarning }.showWarningturns on inside the final urgency band (under two minutes) and is a one-shot perexpiresAtUtc: dismissing hides it until the server extends the hold, which re-arms it.expiredbecomestrueonce the hold reaches zero.CartExpiryDialogs— the presentational layer, built on the Dialog compound. Given that state it renders twoalertdialogs: a dismissible "Still here?" warning (warning intent) with a live mm:ss countdown and Keep browsing / Checkout actions (Checkout stays disabled untilcheckoutUrlis ready), and 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.
Accessibility
- Focus is trapped while the drawer is open — React via
react-focus-lock, Vue viafocus-trap— and returns to the trigger on close. - Body scroll is locked by default while open (
lockBodyScroll). - Escape closes the drawer.
- Motion respects
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.
API reference
CartDrawer
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.