@v-office/website-components
v1.0.2
Published
The imported UI for the six platform surfaces of v-office websites — SearchBar, Showcase, SearchResults, Quote, Booking, ContactForm — plus the shared RentalCard, the rental-detail blocks and the maps (./map), as React components over @v-office/website-he
Downloads
756
Readme
@v-office/website-components
The imported UI of a v-office website: React 19 components over @v-office/website-headless 1.0. A
site renders them as Astro islands (or server-renders them without a client directive where they need
no script). Every business rule, SDK call and piece of state that survives a re-render lives in
headless; this package renders a headless store's state, calls its actions, and carries the copy, the
data-part vocabularies and the CSS.
| Surface | Component | Headless hook | Binding (here) |
| ----------------------------------- | --------------- | --------------------- | ----------------- |
| entry to search | SearchBar | createSearchForm | useSearchForm |
| curated rentals on brand pages | Showcase | buildShowcaseGroups | none |
| the search page's results | SearchResults | createSearchStore | useSearchStore |
| calendar and price on a rental page | Quote | createQuoteFlow | useQuoteFlow |
| the checkout | Booking | createCheckoutFlow | useCheckoutFlow |
| the contact form | ContactForm | createContactForm | useContactForm |
Beside the six surfaces: the one RentalCard every list renders, eleven rental-page blocks
(RentalHeading … RentalDistances), AvailabilityCalendar, BookingReturn, RichText, the
satellites of the results (count, sort, filters, chips, quick filters, campaign row, prerendered
listing), and the maps in the separate entry ./map.
Peers. react >=19, react-dom >=19, @v-office/website-headless >=1.0.0 <2,
@v-office/website-sdk >=2.25.0 <3, and maplibre-gl >=5 <6 (optional: only a site that shows a map
installs it). Sites pin exact versions.
Contents
- Entry points
- Setting up a site: stylesheet, site constants, layout, islands, own UI
- Styling: tokens and axes, restyling parts
- Copy, locale and voice · Events · Conventions
- Search: SearchBar · RentalJump
- Showcase
- Results: SearchResults · SearchCount · SearchSort · SearchFilters · SearchFilterChips · SearchQuickFilters · OfferCampaignFilter · PrerenderedResults · The trailing tile
- RentalCard
- Rental page: common block props · RentalHeading · RentalActions · RentalGallery · RentalFactList · RatingBadge · RentalDescription · RentalAmenities · RentalRooms · RentalReviews · RentalProperty · RentalDistances
- Quote · AvailabilityCalendar
- Booking · BookingReturn
- ContactForm · RichText
- Maps: common map props · SearchResultsMap · SearchMapPill · RentalMap · LocationMap
- Bindings and helpers
- Development
Entry points
| Import | Contents |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| . | the surfaces, the card, the rental blocks, RichText, BookingReturn, the bindings, every copy table and *_PARTS vocabulary |
| ./map | the maps; maplibre-gl is imported on demand, never when the entry loads |
| ./css/layers.css | the cascade-layer order statement, the first line of a site's stylesheet |
| ./css/components.css | every component's rules, compiled into @layer voffice-components; a site needs no Tailwind for them |
| ./css/map.css | MapLibre's own CSS from the site's maplibre-gl, layered; the map components' rules are in components.css |
| ./css/motion.css | optional: press dip, arrow hover, heart bursts and overlay view-transition groups (none of them in components.css) |
| ./css/tokens.css | the shadcn variable set as a neutral fallback, only for a site without a shadcn theme; documents the optional axes |
Nothing from headless is re-exported. A site imports headless values and types (sdkConfigFromEnv,
countryOptions, favoritesStorageKey, SearchContract, QUOTE_EVENT, …) from headless itself.
Setting up a site
The stylesheet
/* The layer order FIRST, `properties` included: the package sits below the site's own components
and utilities, so a site rule on a data-part wins without a specificity fight. */
@layer properties, theme, base, voffice-tokens, voffice-components, components, utilities;
/* (or, as the first line: @import "@v-office/website-components/css/layers.css";) */
@import "tailwindcss";
@import "@v-office/website-components/css/components.css";
@import "@v-office/website-components/css/map.css"; /* where a page shows a map; needs maplibre-gl */
@import "@v-office/website-components/css/motion.css"; /* only where the site wants the motions */
/* @import "@v-office/website-components/css/tokens.css"; only without a shadcn theme */propertiesmust come first. Tailwind puts its@propertyfallbacks (*{--tw-shadow:…}) in apropertieslayer for browsers without@property; declared later, that layer outranks the site's utilities and its resets win in older Safari and Firefox. Every package stylesheet starts with this same statement, but a site statement that comes earlier still decides.components.cssdeclares nothing on:rootand never reads--color-*: the site's own Tailwind theme is untouched.motion.cssexists because several customers ruled hover zoom and press dips out; without it the package has no hover scale, lift or press dip. Everything in it sits insideprefers-reduced-motion: no-preferenceand is tuned by--press-scale(default0.97),--img-arrow-hover-scale(1.05) and--card-fav-hover.
The site constants
Islands cannot receive functions and a component never reads env, so a site writes each decision once as plain data and passes the same constant to every component that needs it. Two surfaces given the same constant cannot disagree.
| Constant | Type | Passed to |
| ----------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
| SDK | SdkSetup ({ config, options? }) | every SDK-using component (sdk) |
| VOICE | Voice | every component (voice) |
| CARD | RentalCardOptions | Showcase, SearchResults, PrerenderedResults, SearchResultsMap |
| SEARCH_CONTRACT | SearchContract (backend, nightRangeSearch, aliases) | SearchBar, SearchResults, OfferCampaignFilter (contract) |
| GUESTS | GuestOptions (limits, child-age band, default adults) | SearchBar, Quote, Booking (guests) |
| DAY_PRICES | DayPrices | Quote, AvailabilityCalendar, Booking (dayPrices) |
| POLICY_CHOICE | PolicyChoiceMode | Quote, Booking (policyChoice; they must agree) |
| BOOKABLE | BookableFrom (from, noticeUntil?, contact?, precision?) | SearchBar dates.bookable, Quote bookable |
| ADDRESS | RentalAddressMode | RentalHeading, RentalMap (address; no default) |
| FAVORITES_KEY | favoritesStorageKey(namespace) | the layout's enhance(), every favoritesKey |
| PAYMENT | BookingPayment | Booking (payment; methods has no default) |
// src/data/surfaces.ts
import type { BookingPayment, RentalCardOptions, Voice } from "@v-office/website-components";
import type {
DayPrices,
GuestOptions,
PolicyChoiceMode,
RentalAddressMode,
SdkSetup,
SearchContract,
} from "@v-office/website-headless";
import { favoritesStorageKey, sdkConfigFromEnv } from "@v-office/website-headless";
// Each env name spelled out (the headless README lists them): Vite inlines only the names it sees.
export const SDK: SdkSetup = {
config: sdkConfigFromEnv({
CMS_BACKEND: import.meta.env.CMS_BACKEND /* , … every SDK_ENV name */,
}),
};
export const SEARCH_CONTRACT: SearchContract = { backend: "v10" };
export const VOICE: Voice = "sie";
export const CARD: RentalCardOptions = { favorite: "inline" };
export const GUESTS: GuestOptions = { limits: { pets: 1 }, defaultAdults: 2 };
export const DAY_PRICES: DayPrices = "rounded";
export const POLICY_CHOICE: PolicyChoiceMode = "rental-page";
export const ADDRESS: RentalAddressMode = "town";
export const PAYMENT: BookingPayment = { methods: ["transfer"] };
// Keep the namespace expression the site used before, its fallback included: a changed key empties
// every guest's Merkliste. An empty namespace throws.
export const FAVORITES_KEY = favoritesStorageKey(import.meta.env.PUBLIC_STORAGE_NS);Facts about the business are required props with no default: routes (action, rentalHref,
checkoutHref, returnPath, legal.*.href), the search contract, payment.methods, supportEmail,
the address decision and the favourites key. Presentation has the fleet majority as its default. A
difference between sites is a prop whose union names what it is (dayPrices is "rounded", "exact"
or "none", never a site's name), set at the composing page and recorded in the site's DECISIONS.md. The SDK setup is
serialized into every island that takes it, so pass only the options the site uses.
The layout
Server-rendered markup (cards, the rental blocks, a showcase without a directive) is bound by headless
enhance(), called once in the layout:
<body data-favorites-key={FAVORITES_KEY}>
<slot />
<script>
import { enhance } from "@v-office/website-headless/enhance";
let dispose: (() => void) | undefined;
document.addEventListener("astro:page-load", () => {
const storageKey = document.body.dataset["favoritesKey"] ?? "";
dispose = enhance(document, { favorites: { storageKey } });
});
document.addEventListener("astro:before-swap", () => dispose?.());
</script>
</body>Without Astro's ClientRouter, call it once after the page loads. It binds photo strips, the gallery,
the description and amenity dialogs, reveals, clamps, share buttons and the Merkliste hearts. An island
binds its own subtree after hydration (useEnhance); binding twice is a no-op. Prerendered search pages
also inline headless resultsQueryFlagScript() in <head>.
Islands and client directives
| Component | Directive | Why |
| ---------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
| SearchBar, RentalJump | client:load (client:idle in a nav) | the submit stays disabled until the form has read the URL |
| Showcase | client:visible, or none | tabs, rotation and favoritesKey need the island |
| SearchResults and its satellites | client:load | the store searches after hydration |
| PrerenderedResults | none, ever | plain HTML for crawlers; the island removes it |
| RentalCard, the rental blocks, RatingBadge, RichText | none | complete static HTML; enhance() binds what moves |
| Quote | client:visible (card), client:media (the bar) | one flow per rental, shared by every placement; the bar's query is barBelow's |
| AvailabilityCalendar | client:visible | joins the rental's quote flow |
| Booking, BookingReturn, ContactForm | client:load | they read the URL or session after mount; the contact submit waits for hydration |
| SearchResultsMap, SearchMapPill | client:load | the fullscreen layer must work at every width, where the inline map is hidden |
| RentalMap, LocationMap | client:visible; client:idle with fullscreen="only" | fullscreen="only" renders no inline map to become visible |
Every prop is serializable except the documented ReactNode slots (SearchResults and
PrerenderedResults trailingTile, Booking header and summaryFooter), which a page fills with an
Astro named slot. An Astro island renders as display: contents, so a grid that should treat an island
as one cell wraps it in an element.
The dev server needs one Vite setting. An island whose component comes from this package loads it
from node_modules, and Vite's dev server pre-bundles only what the site's own files import. Base UI's
store imports the CommonJS use-sync-external-store/shim, which then reaches the browser unconverted:
each such island fails to hydrate in astro dev (and so in the studio preview) with "doesn't provide an
export named: 'useSyncExternalStore'". The build converts it and is unaffected, and a site's own UI over
headless is unaffected (its island is the site's file). Pre-bundle the shim in astro.config.mjs:
vite: {
optimizeDeps: {
include: [
"@v-office/website-components > @base-ui/react > @base-ui/utils > use-sync-external-store/shim",
"@v-office/website-components > @base-ui/react > @base-ui/utils > use-sync-external-store/shim/with-selector",
],
},
},Own UI and the site manifest
A site's build derives dist/site-manifest.json from its imports (@v-office/site-manifest): importing a
surface's component makes that surface imported; importing its headless hook or the binding from here
without the component makes it custom. A site that needs another structure copies a component into its
own code, owns the copy, and its next build reports custom. The rental blocks, the maps, RentalCard
alone, BookingReturn and AvailabilityCalendar are not surfaces and are not reported.
Styling
Tokens and axes
The token contract is the shadcn/ui theme: --background, --foreground, --card,
--card-foreground, --popover, --popover-foreground, --primary, --primary-foreground,
--secondary, --secondary-foreground, --muted, --muted-foreground, --accent,
--accent-foreground, --destructive, --border, --input, --ring, --radius, and
--font-heading (falls back to --font-sans). A shadcn site needs no token work; css/tokens.css
declares a neutral set in @layer voffice-tokens for a site without one.
Optional axes refine roles and shapes. Each is read with its fallback at the point of use and is never
declared on :root by the package (a value there would freeze a fallback such as var(--primary), and
the site's .dark block could no longer reach it); a site sets an axis where it sets its theme.
| Axis | Used for | Fallback |
| -------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------- |
| --selection, --selection-foreground | picked days, active chips and toggles, selected options | --primary, --primary-foreground |
| --primary-hover | the hover step of the one conversion action | --primary at 90 % |
| --offer, --offer-foreground | offer badges (never a price) | a green pair at ≥ 4.5:1 |
| --mark, --mark-foreground | a map pin highlighted from the list | --foreground, --background |
| --accent-text | field labels and accent captions | --muted-foreground |
| --radius-card | containers: cards, panels, dialogs | calc(var(--radius) * 1.4) |
| --radius-pill, --radius-control | labelled one-line controls, icon-only controls | 9999px |
| --radius-label | labels (below) | --radius-pill |
| --radius-badge | rating badges | calc(var(--radius) * 0.8) |
| --shadow-card | card and popup elevation; none removes every package shadow | a soft shadow |
| --shadow-overlay | popovers, select lists, the calendar's bubbles | --shadow-card, then their own |
| --text-caption | the smallest text the package renders | 0.75rem |
| --card-image-aspect | card photos | 5 / 3 |
| --showcase-item-width | a showcase carousel item | min(30rem, 82vw) |
| --results-card-min | the results grid's minimum card width | 18rem |
| --search-hero-label-weight, --search-hero-value-size | the hero search bar's field labels and values | |
| --search-panel-offset-top | the room a picker popover keeps above itself (a sticky nav) | 0px |
| --cal-months-gap, --cal-nav-duration | the calendar's month gap and its month-paging animation | |
--primary is the one conversion action per view; a site whose action colour must stay exclusive sets
--selection for every other active state.
--radius-pill shapes what is pressed: the search submit, filter pills and chips, tabs, the campaign
row. --radius-label shapes what only says something: card pills and photo badges, fact pills, the
quote's offer chip, map price pins, the days and nights badge of both calendars, photo counters and
the filter count. It falls back to --radius-pill, so "buttons square, labels round" is two lines:
--radius-pill: 0.25rem; --radius-label: 9999px; (with --radius-control for the icon buttons).
A no-shadow site sets --shadow-card: none; the overlays fall back to it. --shadow-overlay is for a
site whose floating layers and cards differ (flat cards, lifted popovers).
Restyling with data-part
Every element a site may restyle carries a data-part, and state is an attribute (data-state,
data-open, data-selected, data-kind, …), never a class. A boolean state attribute is present when
on and absent when off ([data-open]). Each area exports its vocabulary as a constant
(SEARCH_BAR_PARTS, QUOTE_PARTS, …, listed per component below); renaming or removing a part is a
major release. The package rules sit in voffice-components, so a rule in the site's components or
utilities layer wins:
@layer components {
[data-part="search-submit"] {
border-radius: 0;
}
[data-part="rental-card"][data-surface="outlined"] [data-part="rental-card-title"] {
font-family: var(--font-heading);
}
[data-part="contact-form"] [data-part="field-label"] {
text-transform: uppercase;
}
}Shared vocabularies: FIELD_PARTS (field, field-label, field-optional, field-control,
field-hint, field-error, scoped by a surface root such as contact-form or checkout),
PRIMITIVE_PARTS (drawer and dialog chrome, select, stepper, popover-positioner, skeleton,
glyph, emphasis), and headless ENHANCE_PARTS (strips, layers, reveals, clamps, share, favourite,
gallery) and CHECKOUT_PARTS (the checkout's legal parts). The kits' behaviour and gate markers
(data-result-card, data-rental-stats, data-fav*, .card-images, data-img-*, …) stay on the same
elements: site scripts and the blueprints gates read them.
Breakpoint props (collapseBelow, gridFrom, mosaicFrom, overlayActions: { below }) take the
package's compiled widths: md 48rem, lg 64rem, xl 80rem. A site whose own breakpoints differ picks
the nearest. There are no container queries; the results grid and the showcase track follow width
tokens.
Copy, locale and voice
Every component that prints words of its own takes (RichText prints only the PMS's text and takes
none; RentalCard takes them for standalone use only):
locale:"de-DE"(default) or"en-US". It decides the default copy, number and date formats, and the language the PMS answers in (every headless call carries it).voice:"neutral"(default),"du"or"sie": how German copy addresses the reader. Neutral German addresses nobody ("Bitte das Alter jedes Kindes angeben");duandsieoverlay only the keys where an address reads naturally. English ignores it. A site setsvoiceonce instead of rewriting strings.copy: a shallowPartialof the component's own strings, applied last.shared(where it renders pickers or cards):SharedCopyOverrides, one level deep per group:dates,guests,region,sheet(the pickers),card(every rental card),rental(distance labels, adjacency statements, "Hunde willkommen", review counts),common(close, back, next, apply, reset, loading, retry,optionalSuffix,newTabHint).
<Quote
client:visible
sdk={SDK}
rental={quoteRental}
checkoutHref={`/buchen/${rental.slug}`}
voice="sie"
copy={{ continueToBooking: "Weiter zur Buchung" }}
shared={{ guests: { pets: "Hunde" } }}
/>Every table is exported as a CopyTable<T> ({ "de-DE": T, "en-US": T, voices: { du, sie } }):
DEFAULT_<COMPONENT>_COPY per component, SHARED_COPY for the shared groups, so a site can seed its
translation namespace from them. resolveCopy(table, locale, voice, overrides) and
resolveShared(locale, voice, overrides) resolve them the way the components do. Values are strings;
{token} templates are filled with fill (headless fillTemplate), and an unknown token stays visible.
Links are always the page's: a bilingual site passes each language's routes.
Typed overrides
copy and shared are typed: an unknown key or a non-string value fails astro check, so the build
is refused and the previous one stays live. A key that takes tokens is typed Template<…> with the
tokens the component fills (moreCount: Template<"count" | "label">), all of them optional. A string
literal fits any key, so the props do not check which tokens a string uses; write the site's overrides
through headless defineCopy, which does:
// src/data/copy.ts
import { defineCopy } from "@v-office/website-headless";
import type { SharedCopyOverrides, ShowcaseCopy } from "@v-office/website-components";
export const SHOWCASE_COPY = defineCopy<ShowcaseCopy>()({ moreCount: "Alle {count} {label}" });
export const SHARED_COPY_OVERRIDES = defineCopy<SharedCopyOverrides>()({
guests: { petMany: "{count} Vierbeiner" },
});
// defineCopy<ShowcaseCopy>()({ moreCount: "Alle {cuont}" })
// ✗ "{cuont} is not a token of this string"Every default table is checked the same way inside the package, and fill / fillParts need a value
for every token a key declares, so a token the code does not fill cannot ship in a default.
Events
Islands take no callbacks, so the surfaces dispatch CustomEvents on window that a site may forward to
its (consented) analytics. The detail's type discriminates; it never carries personal data. The names
and detail types are headless constants.
| Event | Constant (headless) | Dispatched by | detail |
| ------------------- | ------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| voffice:quote | QUOTE_EVENT | Quote, useQuoteFlow | { type: "rate-fault", rentalId, stayKey, code }, once per stay |
| voffice:checkout | CHECKOUT_EVENT | Booking, useCheckoutFlow | { type: "rate-fault", rentalId, code }, { type: "booked", … }, { type: "payment-started", … } |
| voffice:contact | CONTACT_EVENT | ContactForm, useContactForm | { type: "sent", subject } |
| merkliste:changed | FAVORITES_EVENT | the layout's enhance({ favorites }) binder | { id, saved } |
booked carries rentalId, bookingNumber, kind ("booking" or "option") and payable (false when
nothing can be paid online, so a site may send the guest to its return page itself); payment-started
carries bookingNumber and method as the browser leaves for the provider.
The site dispatches one event itself: voffice:consent (CONSENT_EVENT, from here) for maps with
loadOn="consent" (Maps). A site's own UI over a headless flow dispatches the same shapes itself
(headless hands events to onEvent; only the components and bindings dispatch).
import { CHECKOUT_EVENT, type CheckoutEventDetail } from "@v-office/website-headless";
window.addEventListener(CHECKOUT_EVENT, (event) => {
const detail = (event as CustomEvent<CheckoutEventDetail>).detail;
if (detail.type === "booked") track("booking", { kind: detail.kind, payable: detail.payable });
});Conventions
One name per concept across every component (lib/conventions.ts):
| Name | Values | Meaning |
| ------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| headingLevel | 1–6, "none" (HeadingLevel; its element HeadingTag) | the level of a component's own heading; "none" keeps the text in a <p> |
| glyphs (+ glyphMap) | "none", "default", "mapped" | a glyph before each item: none, the block's own, or the map's entry over the block's own |
| …From, collapseBelow, { below } | "md", "lg", "xl" | the compiled breakpoints (48, 64, 80 rem) |
| whenEmpty | "drop", "disable", "show" | what a filter option that matches nothing does |
| loadOn | "near", "click" (default), "consent" (+ consentCategory) | when third-party content (map tiles) may load |
| contract | SearchContract | how the search runs on this backend; one value for bar, results and campaign row |
| guests | GuestOptions | limits.pets: 0 no dogs, 1 a yes/no toggle, more a stepper, everywhere |
| storeKey | string, default "search" (DEFAULT_STORE_KEY) | the page's one search store; every results island with the same key reads it |
Glyphs come from a closed, reviewed vocabulary (headless Glyph), drawn as inline SVG (GlyphIcon,
data-part="glyph", data-glyph="<name>"), always aria-hidden beside a text that says the same. The
package makes no third-party request of its own (no icon font, no hotlinked image); map tiles are the
one exception and wait for loadOn.
SearchBar
The entry to search: a native GET form to the site's search route. Its hidden inputs are headless
createSearchForm().pairs(); it owns the keys of the fields it renders (period, party, region) and
carries every other pair of the URL (sort, filters, property, campaign, site keys). It never calls the
SDK. The submit stays disabled until the form has read the URL after hydration, so a tap before
hydration cannot send an empty query and drop the carried scope.
From collapseBelow up it is a row of fields, each opening a panel; below it one trigger opens a sheet
(mobile="steps" or "scroll"), or the fields stack in place ("inline"). Use variant="hero" on
entry pages and variant="search" on the page whose URL it mirrors.
| Prop | Values | Default | Meaning |
| --------------- | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
| action | string | required | the search route the form lands on; may carry a #fragment |
| contract | SearchContract | required | the site's SEARCH_CONTRACT |
| fields | "region", "dates", "guests", "pets" | ["region", "dates", "guests"] | field order; "region" only with regions; "pets": the dog control as a field |
| regions | RegionOption[] (value, label?, count?, group?) | none | absent or empty: no region field (a ?region= scope still rides along) |
| regionSelect | "multi", "single" | "multi" | multi is OR with a "clear"; single leads with copy.regionAll |
| dates | SearchBarDates | see below | the date picker's options |
| guests | SearchBarGuests | headless defaults | the site's GUESTS plus showDefault and childAges |
| preset | query string | none | a landing page's starting search, applied while the URL owns none of the bar's keys |
| variant | "hero", "search" | "hero" | hero: label over value; search: glyphs, skeletons until the URL is read |
| glyphs | "none", "default" | "default" on search, "none" on hero | leading field glyphs |
| panel | "popover", "dialog", "auto" | "popover" | the desktop panel frame; "auto" is a dialog when the room beside the anchor is too small |
| panelSide | "bottom", "top" | "bottom" | which way a popover opens; it never flips ("top" for a bar low in a tall hero) |
| panelAnchor | "field", "bar" | "field" | what a popover is anchored to and as wide as |
| flow | "fields", "stepped" | "fields" | stepped: a titled panel per field whose footer walks to the next and ends in the submit |
| mobile | "steps", "scroll", "inline" (SearchBarMobile) | "steps" | below collapseBelow: a stepping sheet, one scrolling sheet, or stacked fields |
| collapseBelow | "md", "lg", "xl" | "md" | where the row becomes the mobile form |
| rentalJump | SearchBarRentalJump (RentalJump props but locale, voice) | none | the rental-name combobox after the fields (desktop) |
| filters | SearchFilters props without locale, voice, compact | none | the compact filter trigger of the page's store (desktop) |
| shared | SharedCopyOverrides | none | the pickers' groups dates, guests, region, sheet |
dates (SearchBarDates): flexSteps (± chips in days, default [0, 1, 2, 3, 7, 14]; [] or [0]
hides the row; on v9 only with contract.nightRangeSearch), flexibleStays ("weekend", "week",
"month", default all; [] removes the Flexibel tab; v10 only), nightRangeRow (the Min./Max. Nächte
row, default false; whether the backend prices a window is the contract's nightRangeSearch),
minStayNights (default 1; closer departure days are disabled with the reason), bookable (floors the
picker at from and shows the notice with contact until noticeUntil ?? from; below).
The not-bookable-yet notice is one rendering in both places that take the site's BOOKABLE
(SearchBar above its calendar, Quote above its own): the sentence, then contact as a link on its
own line. A sentence that leads into the link places it with a {contact} token (deferredNotice and
bookableFromNotice, e.g. "… rufen Sie uns bitte an: {contact}."): the link then sits inline there,
and the token is dropped when there is no contact. {date} in the sentence names from as precision says: "day" (default, "1. März 2027"),
"month" ("März 2027") or "year" ("2027"), so { from: "2027-01-01", precision: "year" } reads
"Online buchbar ab 2027." without a copy override. The floor stays the day.
guests (SearchBarGuests, extends GuestOptions): limits (headless DEFAULT_GUEST_LIMITS: 16
adults, 6 children, 5 babies, 4 dogs), childAge (default 2–17), defaultAdults (default 2),
showDefault (the closed field shows "2 Gäste" instead of the prompt, default false), childAges
("required" default: a child without an age stops the submit and opens the guest panel; "optional").
fields with "pets" while limits.pets is 0 is a spec error and throws.
The pickers are internal and render inside the bar's panels or sheet steps; every rule is headless's
(DateSearch, GuestSearch). The date panel has the Datum/Flexibel tabs, a range calendar (two months
side by side, one in a narrow dialog, a scrolling year in the sheet), the ± chips and night-range row, the
flexible stays and month strip, and the not-bookable-yet notice. The guest panel has rows for adults,
children (an age select each), babies and dogs, each only when offered. The region panel is a group of
aria-pressed buttons grouped by group with optional counts. The mobile sheet has one stable
Weiter/Suchen button (a submit swapping in under the finger submitted taps meant for "Weiter").
Parts SEARCH_BAR_PARTS: root search-bar (data-variant, data-resolved, data-collapse-below,
data-mobile), search-form (data-layout="row|sheet|inline"), search-field (data-field,
data-open, data-empty) with search-field-label, search-field-value (data-skeleton and the gate
marker data-skeleton-field while the URL is unread), search-field-icon; search-submit
(data-pending); search-panel (data-panel, data-field), search-panel-header,
search-panel-footer, search-panel-action (data-kind="back|clear|cancel|next|save|apply|reset");
calendar, calendar-day (data-selection, data-past, data-disabled, data-in-preview),
calendar-nights-badge; date-deferred-notice, date-mode-tabs, date-flex-steps, date-flex-step,
date-night-range, date-flexible-stays, date-month-strip, date-month (chips carry data-active);
guest-row (data-guest), guest-stepper, child-age, ages-missing; region-option
(data-selected), region-group, region-clear; search-sheet-trigger, search-sheet,
search-sheet-step (data-step), search-sheet-footer.
Copy DEFAULT_SEARCH_BAR_COPY (SearchBarCopy): regionLabel / datesLabel / guestsLabel
("Wohin", "Wann", "Wer"), the placeholders (prompts, never an invented place or count), submit
("Suchen"), sheetTrigger ("Unterkunft suchen"), deferredNotice ("Online buchbar ab {date}.", {date}
at bookable.precision),
agesMissing (voiced), and the stepped flow's clearDates, cancel, save, datesTitle,
guestsTitle. Shared groups: dates (DatesCopy), guests (GuestsCopy; dogs are "Hunde", never
"Haustiere"), region, sheet.
Events none.
---
import { SearchBar } from "@v-office/website-components";
import { BOOKABLE, GUESTS, SEARCH_CONTRACT, VOICE } from "../data/surfaces";
---
<SearchBar
client:load
action="/suchen"
contract={SEARCH_CONTRACT}
voice={VOICE}
regions={[{ value: "Altstadt", count: 12 }, { value: "Hafen", group: "Am Wasser" }]}
guests={{ ...GUESTS, showDefault: true }}
dates={{ bookable: BOOKABLE, nightRangeRow: true }}
panelSide="top"
/>RentalJump
The "Name der Unterkunft" combobox (APG pattern): type a name, a place or an object number; arrows move
through the hits; Enter or a click opens the rental's own page. Free text never submits anything (the
live search has no name filter). Standalone in a nav, or inside the bar (SearchBar rentalJump).
| Prop | Values | Default | Meaning |
| -------- | ------------------------------------------------------ | -------- | --------------------------------------------------------------------------------- |
| source | { rentals: RentalIndexEntry[] } or { url: string } | required | inline (a bar on a search page), or a static index fetched on first focus (a nav) |
| limit | number | 8 | hits shown |
Entries come from headless rentalIndexEntries(rentals, href) at build, with the site's own hrefs.
Parts RENTAL_JUMP_PARTS: rental-jump (root with label and input; data-state, data-open),
rental-jump-list (the popup), rental-jump-option (data-active).
Copy DEFAULT_RENTAL_JUMP_COPY: label, placeholder ("Name oder Objektnummer"), empty, more
({count} not shown), loading, failed (the next focus retries).
<RentalJump client:idle source={{ url: "/rental-index.json" }} voice={VOICE} />Showcase
Curated rentals on brand pages: one or more groups (tabs) over a carousel or a grid of RentalCards.
Selection happens at build in headless (buildShowcaseGroups, selectRentals, selectRelated); the
component renders, switches groups, steps the row and runs the rotation timer. Every card is in the HTML,
rotated-out tiles included (hidden). Without a client directive the layout's enhance() makes one
group fully work; tabs, rotation and favoritesKey need the island.
| Prop | Values | Default | Meaning |
| -------------- | --------------------------------- | -------------------------- | --------------------------------------------------------------------------------------- |
| groups | ShowcaseGroup[] | required | from buildShowcaseGroups at build; one group: no tabs; none: renders nothing |
| layout | "carousel", "grid" | "carousel" | the row |
| gridFrom | "md", "lg", "xl" | none (grid at every width) | with "grid": the carousel below this width, the grid from it |
| columns | 2, 3, 4 | 3 | grid columns at the widest step |
| arrows | "top", "bottom", "none" | "top" | carousel arrows |
| loop | boolean | false | the carousel wraps at both ends |
| moreTile | boolean | false | a tile to the group's href (else moreHref) with its total |
| moreHref | string | none | where an unscoped row's tile links |
| moreLabels | Record<string, string> | none | per group id, the tile's own text ({count}, {label}) instead of the copy's |
| rotate | { visible, minutes } or false | false | fair rotation: visible tiles per row, a new window every minutes (needs the island) |
| card | RentalCardOptions | none | the site's CARD |
| imageSizes | string | the card's default | must follow --showcase-item-width or the grid column (below) |
| favoritesKey | string | none | binds the hearts once hydrated; absent: the layout's binder paints them |
| shared | SharedCopyOverrides | none | the cards' card and rental groups |
A group's blurb renders under the tabs while that group shows. Rotation renders window 0 of the seeded
order on the server and in the hydrating render, then the window of the visitor's clock. imageSizes
defaults to the card's "(min-width: 37rem) 30rem, 82vw", true for the default carousel item; a grid or
a changed --showcase-item-width passes its own.
Parts SHOWCASE_PARTS: showcase (data-layout, data-grid-from, data-columns, data-arrows),
showcase-tabs, showcase-tab (aria-pressed), showcase-blurb, showcase-item, showcase-more. The
row carries headless strip parts (strip, strip-track, strip-prev, strip-next), the cards
RENTAL_CARD_PARTS.
Copy DEFAULT_SHOWCASE_COPY: groupsLabel, prev, next, moreAll ({label}), moreCount
({count}, {label}: the row's total and label); moreLabels replaces either for one group.
<Showcase client:visible groups={groups} card={CARD} voice={VOICE} layout="grid" gridFrom="lg" moreTile />SearchResults
The search page's live results. It owns the page's one search store (the satellites and the map attach
by storeKey), reads the search from the URL after hydration, and renders the header, the alternatives
notice or section, the grid with its render window and trailing tile, and the outcome states (error,
empty with ways out, favourites and offers empties). Props are read once at hydration: a new store
would be a new rate-limited search. Filters, sort and chips refine in place through the store; the
SearchBar navigates.
| Prop | Values | Default | Meaning |
| -------------- | ------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
| sdk | SdkSetup | required | the page shares one instance per setup and mode |
| contract | SearchContract | required | the site's SEARCH_CONTRACT |
| rentalHref | route template ({slug}, {id}) | required | for rentals the facts give no href |
| filters | ResolvedFilters | none | resolveFilters at build; absent: no filter UI, only period and party reach the SDK |
| facts | RentalFactsMap or { url } | none | build-time facts by rental id (rentalFactsMap), inline or as a JSON file |
| requireFacts | boolean | false | never list a rental the facts do not know |
| scope | SearchScope & { browseHref? } | none | the page's narrowing: ids, favorites or offers |
| favoritesKey | string | none | binds the hearts and the favourites scope (the site's FAVORITES_KEY) |
| preset | query string | none | applied when the URL carries none (a landing page) |
| sortOptions | SortKey[] | ["recommended", "price-asc", "price-desc"] | orderings offered; any known URL value is honoured |
| card | RentalCardOptions | none | the site's CARD |
| imageSizes | string | "(min-width: 48rem) 50vw, 100vw" | sizes of the card photos, from the page's geometry |
| alternatives | { periods?, placement?, notice? } | "chooser", "inline", true | how non-exact results of an exact-date search present (below) |
| paging | { size?, more? } or false | { size: 24, more: "scroll" } | the render window; every result is in memory either way |
| reveal | "complete", "first-page" | "complete" | first-page shows the first page at once; count and outcome wait |
| header | HeaderOptions or false | every part on | the built-in parts above the list (below) |
| empty | { relaxations?, actions? } | { relaxations: true } | the empty state's ways out (below) |
| applyMode | "in-place", "navigate" | "in-place" | navigate: filters, chips and ways out load the new URL (pages whose static parts read it) |
| prerendered | boolean | false | the page server-rendered PrerenderedResults beside the island |
| sdkMode | "default", "all-rentals-at-once" | "default" | the mode the page's SDK runs in (at most 100 rentals priced in one call) |
| storeKey | string | "search" | the store's name on the page |
| trailingTile | slot (ReactNode) | none | the trailing tile |
| trailing | TrailingTileOptions | { showOnEmpty: true } | the tile's partner link and whether it shows on the empty outcome |
| shared | SharedCopyOverrides | none | the cards' words |
alternatives:periods"chooser"(a card with several windows opens one dialog listing each with its price) or"first";placement"inline"(in the grid with the card's badge) or"section"(a divided section after the exact matches);noticeabove the list when every result is an alternative. Only an exact start and end search has alternatives: on a window or flexible search they are the answer.paging:sizecards per step (24, a multiple of 1 to 4 columns);more: "scroll"renders the next step a viewport ahead with a visible "load more" fallback,"button"the button only;falserenders every card.header:count,sort,filters,chips, eachtrue(its defaults), its own props (as standalone, withoutstoreKey,locale,voice) orfalse;filtersandchipsrender only withfilters. A page that places a part elsewhere turns it off here and mounts the satellite itself.empty:relaxations(one link per cause: "Ohne Filter suchen", "Ohne Hund suchen", "Ohne Reisedaten suchen", "Für 2 Gäste suchen") andactions, the site's own ways out (ContactLink[]: an enquiry page, a phone, a mail; no default, every value is somebody's real contact).scope.browseHrefis the search the favourites and offers empties link to.
The favourites page is the same component with scope={{ favorites: true, browseHref }} and
favoritesKey; an un-hearted card leaves with data-state="leaving" (no animation under reduced
motion). The offers page passes scope={{ offers: { window: { startOffsetDays, nights }, campaign? } }}
(the window numbers are portfolio facts the site measured).
Parts SEARCH_RESULTS_PARTS (the satellites included): root search-results (data-search-scope,
data-state), search-results-header, search-alternatives-notice, search-alternatives-section,
search-alternatives-heading, search-alternatives-intro, search-results-grid (data-busy),
search-skeleton, search-load-more, search-results-trailing, search-viewport-empty,
search-outcome (data-search-outcome, data-outcome-scope) with search-outcome-title, -body,
-relaxations, -link (data-kind="relaxation|reset|browse"), -actions, -action, -retry; the
periods dialog search-periods-dialog, search-periods-intro, search-period-row, search-period-price,
search-period-choose, search-periods-other. Cards carry RENTAL_CARD_PARTS and
data-result-card={id}.
Copy DEFAULT_SEARCH_RESULTS_COPY (SearchResultsCopy): emptyTitle, emptyFor ({parts} from
partGuestOne … partFilterMany), emptyHint, the relaxations relaxFilters, relaxPets,
relaxDates, relaxGuests, errorTitle/errorBody, favoritesEmptyTitle, favoritesNoneMatch,
offersEmptyTitle, idsEmpty, viewportEmpty, loadMore, periodsChoose ("Auswählen": it opens the
rental and books nothing). The header parts take their copy in header.
Events none (the map reads the store).
<SearchResults
client:load
sdk={SDK}
contract={SEARCH_CONTRACT}
rentalHref="/unterkuenfte/{slug}"
filters={filters}
facts={{ url: "/rental-facts.json" }}
card={CARD}
favoritesKey={FAVORITES_KEY}
voice={VOICE}
header={{ count: { as: "h1" }, filters: { panel: "list" } }}
alternatives={{ placement: "section" }}
empty={{ actions: [{ label: "Anfrage senden", href: "/anfrage" }] }}
/>SearchCount
The result count of the page's search, anywhere on the page. It follows the map's viewport, shows a skeleton until the set is complete, and renders nothing on an error or empty outcome (a "0" beside a failed search counts nothing). A live region; the suffix follows the store's scope.
| Prop | Values | Default | Meaning |
| ---------- | ------------------------------- | ---------- | ------------------------------------------------------------------------------------------- |
| as | "p", "h1", "h2", "span" | "p" | "h1"/"h2" when the count is the page heading; "span" inside a heading the page writes |
| storeKey | string | "search" | the page's store |
Parts search-count. Copy DEFAULT_SEARCH_COUNT_COPY: countOne, countMany
(countMany: "{count} Ferienwohnungen" on a landing page), favoritesSuffix, offersSuffix, loading.
SearchSort
The ordering control: a native select below md, the styled picker from md. It re-sorts the loaded set in
place and keeps ?sort= for reloads. Hidden while no option but the default can be chosen (an undated
search offers no price order) and on the server.
| Prop | Values | Default | Meaning |
| ------------- | ----------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
| sortOptions | SortKey[] | ["recommended", "price-asc", "price-desc"] | the same list as SearchResults sortOptions; a URL sort outside it still shows |
SortKey: recommended, price-asc, price-desc, rating, area-desc, area-asc, guests,
bedrooms, random (seeded per page view). The URL values stay German (empfohlen, preis-auf, …).
Parts search-sort. Copy DEFAULT_SEARCH_SORT_COPY: sortTrigger (the label while the default
order is on), sortLabel, one sort<Key> per key.
SearchFilters
The filter trigger with its count badge and the panel: a dialog from md, a drawer below. The groups and
entries are the page's resolved curation (SearchResults filters); the working selection is re-read from
the URL on every open, so closing without "apply" changes nothing. Hidden entries render no control and
stay applied. The badge counts the applied curated filters, hidden ones included, but not an implicit
?region= or ?property= scope: the panel neither offers nor resets it (its chip does). Renders
nothing while the page offers no filter control.
| Prop | Values | Default | Meaning |
| ----------- | ------------------------------- | ----------- | ------------------------------------------------------------------------ |
| panel | "pills", "list" | "pills" | toggle pills and steppers, or check rows and selects ("Beliebig / ab N") |
| counts | boolean | false | live counts beside every option the build's facts can count |
| whenEmpty | "disable", "drop", "show" | "disable" | an option whose live count is 0; a selected option always shows |
| compact | boolean | false | a square icon trigger whose label shows from md (beside a search bar) |
Parts search-filters-trigger, search-filters-badge, search-filters-panel, search-filters-group,
search-filters-group-title, search-filters-option (data-kind, data-selected),
search-filters-minimum, search-filters-reset, search-filters-apply.
Copy DEFAULT_SEARCH_FILTERS_COPY: trigger, title, reset, apply, applyCount ({count}),
any, minimum, petsOne (dog-specific), optionCount, optionEmpty.
SearchFilterChips
The applied filters as removable chips plus "clear all". Every applied filter is visible and removable,
hidden ones included, and so is an implicit ?region= or ?property= scope the curation has no facet
for (the search bar writes ?region=, an Anlage link ?property=): headless leads activeFilters
with it, and the chip reads "Region: Hafen" or "Anlage: 4711", the URL value verbatim. To chip a
property's name instead of its id, curate a hidden property facet whose options carry the names
(facetOptions(facts, "property", { labels })); a curated facet replaces the implicit chip with its own
labels. Nothing renders while no filter is applied.
Props: only storeKey, locale, voice, copy and className.
Parts search-filter-chips, search-filter-chip (data-kind), search-filter-chips-clear.
Copy DEFAULT_SEARCH_FILTER_CHIPS_COPY: facetChip ({label}, {value}), regionChip and
propertyChip ({value}: "Region: {value}", "Anlage: {value}"), minimumChip, petsOne, petsMany,
remove, clearAll, chipsLabel.
SearchQuickFilters
A one-tap filter strip over the page's store. Chips sit in fixed positions (on = filled, off =
outlined) so the strip never moves under the thumb; applied filters without a slot are prepended (an
implicit region or property scope in the chips' words), so no filter is active and invisible. It needs
the page's filters. Re-curate keys per portfolio: a chip earns a slot only if it splits it.
| Prop | Values | Default | Meaning |
| --------------- | ---------------------- | ---------- | ----------------------------------------------------------------------------------- |
| keys | string[] | required | the shortlist in strip order; booleans of the page's resolved filters only |
| at | "mobile", "always" | "mobile" | below md only, or at every width (the strip is the only filter UI) |
| prependActive | boolean | true | false only where a chip row on the same screen already shows every applied filter |
| pets | boolean | false | offer the dogs chip as the first slot (an active dog search always shows) |
Parts search-quick-filters, search-quick-filter (aria-pressed). Copy the
SearchFilterChipsCopy table (copy takes its keys).
OfferCampaignFilter
The offers page's one row of campaign chips. A campaign at home in the window on screen is a toggle
(aria-pressed) that narrows the loaded results in place; one at home elsewhere is a link that carries its
period in its label, so a change of dates is visible before the click. No counts on chips; labels are
the PMS's, verbatim. Renders nothing while loading or without a campaign; a single chip renders (a lone
campaign at home in another window is the only way to its dates).
| Prop | Values | Default | Meaning |
| ---------- | ---------------- | -------- | --------------------------------------------------------------------------------- |
| windows | OfferWindow[] | required | findOfferWindows at build; [] leaves only the campaigns of the loaded results |
| action | string | required | the offers page route the links carry |
| contract | SearchContract | required | the site's SEARCH_CONTRACT |
Parts offer-campaigns, offer-campaigns-legend, offer-campaign.
Copy DEFAULT_OFFER_CAMPAIGN_FILTER_COPY: legend ("Angebot:"), periodTemplate ({campaign},
{period}), announceSelected, announceCleared, ariaLabel.
<OfferCampaignFilter client:load windows={OFFER_WINDOWS} action="/angebote" contract={SEARCH_CONTRACT} />
<SearchResults client:load … scope={{ offers: { window: { startOffsetDays: 21, nights: 7 } } }} />PrerenderedResults
The undated listing a crawler must see, server-rendered: the same grid and card markup as the island,
mounted without a client directive (plain HTML, no props serialized). It renders its own
[data-prerendered-results] wrapper; SearchResults prerendered keeps it while searching, keeps its
order on the bare URL, and removes it in the frame the results paint.
| Prop | Values | Default | Meaning |
| --------------------------------------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------- |
| items | SearchResultItem[] | required | catalogueResultItem(…) over the catalogue, in compareUndatedSearchOrder or the page's order |
| card, facts, imageSizes, shared | as on the island | none | pass the same values as the island, or the swap moves cards |
| trailingTile | slot | none | the same tile as the island
