npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

| 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 */
  • properties must come first. Tailwind puts its @property fallbacks (*{--tw-shadow:…}) in a properties layer 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.css declares nothing on :root and never reads --color-*: the site's own Tailwind theme is untouched.
  • motion.css exists 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 inside prefers-reduced-motion: no-preference and is tuned by --press-scale (default 0.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"); du and sie overlay only the keys where an address reads naturally. English ignores it. A site sets voice once instead of rewriting strings.
  • copy: a shallow Partial of 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); notice above 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: size cards 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; false renders every card.
  • header: count, sort, filters, chips, each true (its defaults), its own props (as standalone, without storeKey, locale, voice) or false; filters and chips render only with filters. 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") and actions, the site's own ways out (ContactLink[]: an enquiry page, a phone, a mail; no default, every value is somebody's real contact).
  • scope.browseHref is 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