@zappie/foundations
v0.9.1
Published
UI-less React primitives for Zappie storefronts: behaviour, state and accessibility without styles.
Readme
@zappie/foundations
Unstyled React parts for storefronts. They handle the behaviour, state and accessibility of commerce UI: overlays, item options, quantities, the cart and its totals, fulfilment, addresses, schedules, search, payment methods, one-time codes, images, money and opening hours. You bring the styles.
npm install @zappie/foundations @zappie/sdk react react-domReact 18.2 or 19 is a peer dependency.
Use it
The parts read Zappie data through @zappie/sdk, so wrap them in its ZappieProvider. Each part renders a plain element you can style, or takes a render function:
import type { Branch } from "@zappie/sdk";
import { OpenStatus } from "@zappie/foundations";
export function Hours({ branch }: { branch: Branch }) {
return (
<OpenStatus branch={branch} className="hours">
{(status) => status.label}
</OpenStatus>
);
}The label reads like "Open until 10pm", "Opens 8am tomorrow" or "Closed", and it updates when the branch opens or closes. Parts expose their state as data-* attributes, such as data-state="open", so you can style each state in CSS.
Want ready-made, styled components instead? Copy them into your project from the kit with npx zappie add <name>.
Entry points
| Import | For |
| ------------------------------- | --------------------------------------------------------------- |
| @zappie/foundations | Every part |
| @zappie/foundations/next | Keeping overlays in step with Back and Forward in a Next.js app |
| @zappie/foundations/rich-text | Sanitising and parsing merchant rich text before you render it |
Docs
Status
Foundations is at 0.x. Its API can change in a minor release while it settles. Each change is listed in CHANGELOG.md.
License
MIT
Deferred overlays
Keep a bag, branch finder or search dialog off the first load without React.lazy.
React holds a suspended boundary's first reveal for about 300 ms, so a lazy overlay
opens late. deferred() loads the code on demand and renders on the frame it arrives.
It never suspends.
import { deferred, useIntentPreload } from "@zappie/foundations";
const BagSheet = deferred(() => import("./bag-sheet"));
// Or a named export: deferred(() => import("./finder").then((m) => m.Finder))
function Header() {
const [open, setOpen] = useState(false);
const intent = useIntentPreload(BagSheet);
return (
<>
<button {...intent} onClick={() => setOpen(true)}>
Bag
</button>
<BagSheet.Component open={open} onClose={() => setOpen(false)} />
</>
);
}deferred(load)takes a function that imports a module whose default export is the component, or that resolves to the component. Create it once at module scope.Componentrenders nothing untilopenis first true and the code is in. Whenopenturns true it loads the code and renders as soon as it arrives. After that first open the component stays mounted and receives every prop,openincluded, so it can animate out and return focus when it closes.- Pass
fallbackto hold an inline part's place while its code loads, for example a placeholder label. It shows only whileopenis true and the code is not in yet, and it never suspends. - Pass
failedto show something when the code fails to load, such as a message with a button that callsretry. It shows only whileopenis true. Without it nothing shows, so a shopper who taps and sees nothing never knows to tap again. A laterpreload(), such as the one the next tap on the trigger starts, also retries, and the component renders when it succeeds. preload()starts the load and returns its promise. Calls share one load. A failed load is forgotten, so the nextpreload()or the next open tries again.preloadWhenIdle({ timeout? })loads the code when the browser is idle and returns a function that cancels it. Use it only for small chunks. It does nothing on the server.useIntentPreload(target)returns{ onPointerEnter, onPointerDown, onFocus }for the trigger, so the code is usually in by the click.
Importing the helper never touches window. On the server Component renders
nothing, and it works under StrictMode.
The first overlay you open on a page paints on the tap. Its backdrop and panel,
with the content, show in the engine's markup straight away, and the overlay's
focus handling, scroll lock and dismissal take over on the next frame. Until
then the panel is hidden from assistive technology and takes no keys, presses or
scrolls, apart from Escape and an outside press, which close it. Style the panel
and [data-zappie-backdrop] as usual: both frames match them.
Item form focus
When an add fails validation, ItemForm.Root focuses the first invalid group
with preventScroll, so the page doesn't jump. Pass onInvalid(groups) to
scroll it into view yourself. groups lists the invalid groups in display
order, each with its groupId, its group (undefined for a group the
template renders from its own model), its element and its issues.
<ItemForm.Root
item={item}
onInvalid={(groups) =>
groups[0]?.element?.scrollIntoView({ behavior: "smooth", block: "center" })
}
>Branch map
BranchMap accepts SDK Branch[] data, selectedBranchId, onSelect(branch),
browserKey, and optional shopperLocation: { lat, lng }. Use maxZoom to
limit the zoom after fitting, pinImageUrl to show an image in each branch pin,
mapTypeControl to show the map and satellite switch, and pinLabel to show
the branch name above the selected pin ("selected") or every pin ("all").
It also accepts standard div attributes such as className and style.
import { BranchMap } from "@zappie/foundations";
<BranchMap
branches={branches}
selectedBranchId={selectedBranchId}
onSelect={(branch) => setSelectedBranchId(branch.id)}
browserKey={integrations.googleMaps?.browserKey}
shopperLocation={shopperLocation}
pinImageUrl={store.brand.logoUrl}
pinLabel="selected"
mapTypeControl
onLoadError={(error) => report(error)}
renderLoadError={({ retry }) => (
<div role="status">
<p>Map didn't load</p>
<button type="button" onClick={retry}>
Try again
</button>
</div>
)}
className="branch-map"
/>;Set a height on .branch-map. With a pinImageUrl, such as the business logo,
each branch pin is the logo in a white ring: 32 px, or 40 px for the focused
branch. The focused branch is the selected one or, once a shopperLocation is
known, the nearest. Without a pinImageUrl, pins are dots themed with
--branch-map-pin, --branch-map-pin-selected, --branch-map-pin-border and
--branch-map-pin-size. Pins expose data-selected, data-focused and
aria-pressed. Theme pin labels with --branch-map-label-background,
--branch-map-label-color and --branch-map-label-border. Labels are marked
data-branch-map-pin-label and hidden from assistive technology, because the pin
already carries the name. pinLabel="selected" labels the focused pin.
With a shopperLocation, the map shows a house marker labelled “You” and draws a
line from it to the focused branch. Nearby branches group into numbered
clusters. Clicking a cluster zooms into it, and selecting a branch inside a
cluster zooms in until its pin shows. Colour the clusters, the house marker, the
line and the label borders with --map-accent, and the cluster count and house
icon with --map-accent-foreground. The map reads these when it updates, because
Maps draws clusters and lines without CSS.
The map fits branches with valid coordinates, including across the date line.
Coordinates of (0, 0) count as missing. With a shopperLocation, it fits the
shopper and the focused branch with 88 px of padding, and fits again when the
focused branch changes. Without one, it fits every branch with 64 px of padding
and a new selection pans to that branch. A single point is centred at zoom 14.
compact frames a small map: the route gets 72 px of padding above, 52 px at
the sides and 36 px below, and a single point is centred at zoom 13. The map
fits again only when what it frames or maxZoom changes. Branches without
coordinates remain selectable with “Map location unavailable”. maxZoom
defaults to 15 with a shopper location and 14 without one. mapTypeControl
defaults to false.
The component renders nothing without a key. It loads its map runtime and the
Maps JavaScript API after mount, sharing one script across mounted maps.
Use one browser key per page. A loading failure calls onLoadError(error).
With renderLoadError({ error, retry }) you render the message, and retry
loads the map again. Without it the map shows “Map location unavailable”.
The optional location button pans to coordinates supplied by your app. Your app
requests geolocation permission and supplies those coordinates.
Delivery map
DeliveryMap shows where an order goes: the branch logo marker, the shopper's
house marker and the line between them, fitted to all of it. Use it in a cart or
checkout in place of a static map image. It is a preview, so it has no controls
and ignores gestures. Pass route, such as a decoded driving route, to draw that
path instead of a straight line.
import { DeliveryMap } from "@zappie/foundations";
<DeliveryMap
branch={branch}
shopper={deliveryAddress.coordinates}
browserKey={integrations.googleMaps?.browserKey}
pinImageUrl={store.brand.logoUrl}
renderLoadError={({ retry }) => (
<button type="button" onClick={retry}>
Try again
</button>
)}
className="delivery-map"
/>;Set a height on .delivery-map. The default storefront uses 180 px. The branch
marker is a 36 px logo, the house marker uses --map-accent and the line uses
--delivery-map-route at 60% opacity. Without a pinImageUrl the branch marker
is the themed dot. Like BranchMap, it renders nothing without a key, loads
Maps after mount, calls onLoadError(error) on a failure and shows “Map location
unavailable” when either point has no valid coordinates. It fits again when the
points, the route or its size change.
