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

// React
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
// Vue
import { 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'.

import { createCartClient } from '@oztix/roadie-widgets/cart'
const cart = createCartClient({
host: '', // empty string for same-origin
// fetch, // optional — defaults to globalThis.fetch
})

A CartClient exposes four methods, so you can supply your own if you need a custom transport:

interface CartClient {
getSummary(collectionId: string): Promise<CartSummary | null>
getDetails(collectionId: string): Promise<CartDetails | null>
checkoutUrl(details: Pick<CartDetails, 'extrasUrl'>): string | null
removeItem(cartId: string, eventId: string): Promise<void>
}

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 suppliedTime row
eventStartAtUtc7pm
+ 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.

interface CartSeat {
section?: string // "Stalls", "Mezzanine", "Booth"
row?: string // "B"
seat: string // "12"
}
// on a ticket line:
{ name: 'Reserved Seat', quantity: 2, priceEach: 110, seats: [
{ section: 'Stalls', row: 'B', seat: '11' },
{ section: 'Stalls', row: 'B', seat: '12' },
]}
SeatsBadge
Stalls B11, B12Stalls B11–12
Stalls B11, B12, B14Stalls B11–12, 14
Booth 4 (no row)Booth 4
Mezzanine M3 + Circle C5Mezzanine 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.

'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createCartClient } from '@oztix/roadie-widgets/cart'
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
const queryClient = new QueryClient()
const cart = createCartClient({ host: '' })
export function CollectionCart({ collectionId }: { collectionId: string }) {
const router = useRouter()
return (
<QueryClientProvider client={queryClient}>
<CartDrawer
cart={cart}
collectionId={collectionId}
onNavigate={(href) => router.push(href)}
locale='en-AU'
currency='AUD'
/>
</QueryClientProvider>
)
}

Vue

<script setup lang="ts">
import { createCartClient } from '@oztix/roadie-widgets/cart'
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'
const cart = createCartClient({ host: '' })
function onNavigate(href: string): void {
// routing is the consumer's job — no silent no-op fallback
window.location.assign(href)
}
</script>
<template>
<CartDrawer
:cart="cart"
collection-id="abc123"
:on-navigate="onNavigate"
locale="en-AU"
currency="AUD"
/>
</template>

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.

// React
const [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 }. 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 — the presentational layer, built on the Dialog compound. Given that state it renders two alertdialogs: 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), 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):

import {
CartExpiryDialogs,
useCartExpiry
} from '@oztix/roadie-widgets/cart-drawer/react'
function CartExpiryLayer({ expiresAtUtc, checkoutUrl, browseHref, onNavigate }) {
const { remaining, expired, showWarning, dismissWarning } =
useCartExpiry(expiresAtUtc)
return (
<CartExpiryDialogs
showWarning={showWarning}
expired={expired}
remaining={remaining}
onDismissWarning={dismissWarning}
checkoutUrl={checkoutUrl}
browseHref={browseHref}
onNavigate={onNavigate}
/>
)
}

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 via focus-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

cartCartClient
Required

Core cart client (host + fetch injected by the consuming app).

collectionIdstring
Required
onNavigate(href: string) => void
Required

REQUIRED — routing is the consumer's job.

browseHrefstring

Empty-state "Browse" target. Defaults to a safe collectionId-derived path; unsafe values fall back to that default.

context"collection" | "event"

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.

localestring
Required

Locale for currency/date formatting.

currencystring
Required

ISO 4217 currency code.

refreshKeynumber

Bump to force a refetch of summary + details.

lockBodyScrollboolean

Lock body scroll while open. Default true.

Defaults to true.

openboolean

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`.

initialState"closed" | "open"

Uncontrolled initial state when `open` is omitted. Default 'closed'.

Defaults to closed.

onOpenChange((open: boolean) => void)

Fires on every open/close intent (tap, drag, Escape, backdrop, or `open`).

onExpire(() => void)

Fires when the urgency countdown hits expiry.

onHeightChange((px: number) => void)

Reports the docked (closed) drawer height in px.

CartExpiryDialogs

showWarningboolean
Required
expiredboolean
Required
remainingnumber | null
Required
onDismissWarning() => void
Required
checkoutUrlstring | null
Required
browseHrefstring
Required
onNavigate(href: string) => void
Required