@vamos-ui/react 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blocks/block-image.js +1 -1
- package/dist/blocks/block-link.js +1 -1
- package/dist/blocks/index.d.ts +2 -2
- package/dist/blocks/index.js +2 -2
- package/dist/blocks/site/_kit-1.d.ts +1 -0
- package/dist/blocks/site/_kit-1.js +3 -1
- package/dist/blocks/site/announcement-bar.js +1 -1
- package/dist/blocks/site/article-layout.d.ts +1 -0
- package/dist/blocks/site/article-layout.js +2 -1
- package/dist/blocks/site/blog-list.d.ts +1 -0
- package/dist/blocks/site/blog-list.js +3 -2
- package/dist/blocks/site/contact-dock.js +3 -1
- package/dist/blocks/site/contact-form.js +1 -1
- package/dist/blocks/site/cta.d.ts +2 -2
- package/dist/blocks/site/cta.js +8 -5
- package/dist/blocks/site/faq.js +2 -1
- package/dist/blocks/site/feature-grid.d.ts +1 -0
- package/dist/blocks/site/feature-grid.js +7 -5
- package/dist/blocks/site/footer.d.ts +1 -1
- package/dist/blocks/site/footer.js +8 -7
- package/dist/blocks/site/gallery-section.d.ts +6 -1
- package/dist/blocks/site/gallery-section.js +6 -4
- package/dist/blocks/site/hero.d.ts +3 -2
- package/dist/blocks/site/hero.js +14 -6
- package/dist/blocks/site/logo-strip.d.ts +6 -3
- package/dist/blocks/site/logo-strip.js +8 -7
- package/dist/blocks/site/map-hours.d.ts +2 -2
- package/dist/blocks/site/map-hours.js +4 -2
- package/dist/blocks/site/media-text.d.ts +4 -1
- package/dist/blocks/site/media-text.js +6 -4
- package/dist/blocks/site/menu-list.d.ts +7 -3
- package/dist/blocks/site/menu-list.js +34 -7
- package/dist/blocks/site/navbar.d.ts +1 -0
- package/dist/blocks/site/navbar.js +6 -5
- package/dist/blocks/site/not-found.js +2 -1
- package/dist/blocks/site/page-intro.d.ts +1 -0
- package/dist/blocks/site/page-intro.js +2 -1
- package/dist/blocks/site/pricing-table.js +2 -1
- package/dist/blocks/site/process.d.ts +1 -0
- package/dist/blocks/site/process.js +4 -2
- package/dist/blocks/site/registry-1.d.ts +1 -1
- package/dist/blocks/site/registry-2.d.ts +5 -0
- package/dist/blocks/site/registry-2.js +2 -2
- package/dist/blocks/site/rich-text-section.js +2 -1
- package/dist/blocks/site/shared/heading-lines.d.ts +9 -0
- package/dist/blocks/site/shared/heading-lines.js +32 -0
- package/dist/blocks/site/shared/image-sizes.d.ts +25 -0
- package/dist/blocks/site/shared/image-sizes.js +37 -0
- package/dist/blocks/site/shared/island-keys.d.ts +1 -1
- package/dist/blocks/site/shared/island-keys.js +1 -1
- package/dist/blocks/site/shared/labels-2.d.ts +9 -1
- package/dist/blocks/site/shared/labels-2.js +24 -6
- package/dist/blocks/site/shared/open-now.d.ts +1 -1
- package/dist/blocks/site/shared/open-now.js +1 -1
- package/dist/blocks/site/shared/opening-hours.d.ts +18 -2
- package/dist/blocks/site/shared/opening-hours.js +36 -7
- package/dist/blocks/site/shared/tap.d.ts +9 -0
- package/dist/blocks/site/shared/tap.js +16 -0
- package/dist/blocks/site/site-shell.d.ts +11 -0
- package/dist/blocks/site/site-shell.js +43 -16
- package/dist/blocks/site/stats-band.d.ts +3 -1
- package/dist/blocks/site/stats-band.js +11 -7
- package/dist/blocks/site/team.d.ts +1 -1
- package/dist/blocks/site/team.js +4 -3
- package/dist/blocks/site/testimonials.d.ts +2 -2
- package/dist/blocks/site/testimonials.js +5 -4
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.js +2 -0
- package/dist/business/signer-status.d.ts +53 -0
- package/dist/business/signer-status.js +76 -0
- package/dist/charts/bar-chart.js +6 -2
- package/dist/charts/chart-shell.d.ts +9 -2
- package/dist/charts/chart-shell.js +10 -4
- package/dist/charts/chart.js +1 -1
- package/dist/charts/combo-chart.js +5 -7
- package/dist/charts/utils.d.ts +10 -0
- package/dist/charts/utils.js +46 -0
- package/dist/components/_qr-core.d.ts +26 -0
- package/dist/components/_qr-core.js +376 -0
- package/dist/components/activity-timeline.js +5 -5
- package/dist/components/aspect-ratio.d.ts +26 -0
- package/dist/components/aspect-ratio.js +42 -0
- package/dist/components/backup-codes.d.ts +42 -0
- package/dist/components/backup-codes.js +67 -0
- package/dist/components/breadcrumb.d.ts +2 -2
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/carousel.d.ts +12 -3
- package/dist/components/carousel.js +8 -5
- package/dist/components/collapsible.d.ts +36 -0
- package/dist/components/collapsible.js +37 -0
- package/dist/components/command.d.ts +8 -2
- package/dist/components/command.js +7 -2
- package/dist/components/image.d.ts +18 -2
- package/dist/components/image.js +12 -5
- package/dist/components/input.d.ts +1 -1
- package/dist/components/input.js +1 -1
- package/dist/components/menubar.d.ts +96 -0
- package/dist/components/menubar.js +123 -0
- package/dist/components/pin-map.d.ts +58 -0
- package/dist/components/pin-map.js +167 -0
- package/dist/components/placeholder-art.d.ts +51 -0
- package/dist/components/placeholder-art.js +82 -0
- package/dist/components/qr-code.d.ts +36 -0
- package/dist/components/qr-code.js +27 -0
- package/dist/components/record-detail-layout.d.ts +2 -2
- package/dist/components/record-detail-layout.js +1 -1
- package/dist/components/sidebar.js +8 -8
- package/dist/components/sla-timer.d.ts +75 -0
- package/dist/components/sla-timer.js +142 -0
- package/dist/components/slug-input.js +1 -1
- package/dist/components/tour.d.ts +60 -0
- package/dist/components/tour.js +139 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lib/public.d.ts +1 -0
- package/dist/lib/public.js +1 -0
- package/dist/lib/value-format.js +1 -1
- package/dist/locale-data/core/holidays.d.ts +1 -1
- package/dist/locale-data/core/holidays.js +1 -1
- package/dist/locales/ar.d.ts +2087 -0
- package/dist/locales/ar.js +2088 -0
- package/dist/locales/de.d.ts +2087 -0
- package/dist/locales/de.js +2088 -0
- package/dist/locales/en.d.ts +1 -0
- package/dist/locales/en.js +1 -0
- package/dist/locales/es.d.ts +2087 -0
- package/dist/locales/es.js +2087 -0
- package/dist/locales/fr.d.ts +2087 -0
- package/dist/locales/fr.js +2088 -0
- package/dist/locales/tr.d.ts +1 -0
- package/dist/locales/tr.js +1 -0
- package/dist/media/gallery.js +3 -1
- package/dist/media/media-library.js +1 -1
- package/dist/motion/core.d.ts +32 -0
- package/dist/motion/core.js +77 -0
- package/dist/motion/count-up.d.ts +29 -0
- package/dist/motion/count-up.js +92 -0
- package/dist/motion/index.d.ts +11 -0
- package/dist/motion/index.js +13 -0
- package/dist/motion/motion-scope.d.ts +17 -0
- package/dist/motion/motion-scope.js +16 -0
- package/dist/motion/page-transition.d.ts +33 -0
- package/dist/motion/page-transition.js +61 -0
- package/dist/motion/parallax.d.ts +37 -0
- package/dist/motion/parallax.js +90 -0
- package/dist/motion/pointer.d.ts +38 -0
- package/dist/motion/pointer.js +117 -0
- package/dist/motion/reveal.d.ts +72 -0
- package/dist/motion/reveal.js +133 -0
- package/dist/motion/scroll-shrink.d.ts +18 -0
- package/dist/motion/scroll-shrink.js +47 -0
- package/dist/motion/skeleton-swap.d.ts +17 -0
- package/dist/motion/skeleton-swap.js +43 -0
- package/dist/motion/text-reveal.d.ts +32 -0
- package/dist/motion/text-reveal.js +31 -0
- package/dist/motion/ticker.d.ts +35 -0
- package/dist/motion/ticker.js +16 -0
- package/dist/render/block-renderer.js +1 -1
- package/dist/sortable/sortable-tree.d.ts +4 -0
- package/dist/sortable/sortable-tree.js +16 -4
- package/dist/styles/blocks.css +21 -12
- package/dist/styles/editors.css +2 -1
- package/dist/styles/media.css +31 -5
- package/dist/styles/motion.css +389 -0
- package/dist/styles.css +41 -10
- package/package.json +5 -1
|
@@ -4,6 +4,8 @@ import { Container, Section } from "../section.js";
|
|
|
4
4
|
import { SectionHeader } from "../section-header.js";
|
|
5
5
|
import { BlockImage } from "../block-image.js";
|
|
6
6
|
import { CtaList, indexLabel, useBlockIds } from "./_kit-1.js";
|
|
7
|
+
import { Reveal } from "../../motion/reveal.js";
|
|
8
|
+
import { columnSizes } from "./shared/image-sizes.js";
|
|
7
9
|
/**
|
|
8
10
|
* "How we work" — discovery → design → launch; a first spa visit; SaaS setup. An ordered list
|
|
9
11
|
* (the order is announced; the numbers are visual). Server component.
|
|
@@ -24,7 +26,7 @@ export function Steps({ variant = "vertical", eyebrow, title, lead, steps, actio
|
|
|
24
26
|
return (_jsx(Tag, { className: cn("text-body text-fg-muted text-pretty", Tag === "p" ? "max-w-[52ch]" : "flex min-w-0 flex-col gap-3"), ...f(i, "text"), children: s.text }));
|
|
25
27
|
};
|
|
26
28
|
const duration = (s, i) => s.duration ? (_jsx("span", { className: "inline-flex w-fit items-center rounded-full border border-line px-2.5 py-0.5 font-mono text-[0.6875rem] tabular tracking-[0.04em] text-fg-muted", ...f(i, "duration"), children: s.duration })) : null;
|
|
27
|
-
const media = (s) => (s.media ? _jsx(BlockImage, { ...s.media, aspect: "3:2", sizes:
|
|
29
|
+
const media = (s) => (s.media ? _jsx(BlockImage, { ...s.media, aspect: "3:2", sizes: columnSizes(4 / 12, { narrow: "min(100vw, 28rem)" }), className: "mt-2 max-w-md rounded-ui", components: components, labels: labels }) : null);
|
|
28
30
|
const header = title || eyebrow || lead ? _jsx(SectionHeader, { eyebrow: eyebrow, title: title, lead: lead, align: variant === "horizontal" && lead ? "split" : "start", headingLevel: headingLevel, headingId: headingId, editMode: editMode, labels: labels, locale: locale }) : null;
|
|
29
31
|
const actionsEl = actions?.length ? _jsx(CtaList, { actions: actions, components: components, labels: labels, locale: locale, editMode: editMode }) : null;
|
|
30
32
|
let content;
|
|
@@ -37,5 +39,5 @@ export function Steps({ variant = "vertical", eyebrow, title, lead, steps, actio
|
|
|
37
39
|
else {
|
|
38
40
|
content = (_jsxs(Container, { className: "grid gap-x-(--gutter) gap-y-12 @4xl:grid-cols-12", children: [_jsxs("div", { className: "flex flex-col gap-8 @4xl:col-span-5", children: [header, actionsEl] }), _jsx("ol", { className: "vb-steps-v flex flex-col @4xl:col-span-6 @4xl:col-start-7", children: steps.map((s, i) => (_jsxs("li", { className: "vb-step relative flex flex-col gap-3 ps-12 pb-10 last:pb-0", children: [_jsx("span", { "aria-hidden": true, className: cn("vb-step-badge absolute start-0 top-0 inline-flex size-8 items-center justify-center rounded-full border border-line bg-scene font-mono text-[0.6875rem] tabular text-fg-muted", i === 0 && "vb-step-badge-first"), children: indexLabel(i) }), _jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2 pt-1", children: [stepTitle(s, i), duration(s, i)] }), stepText(s, i), media(s)] }, i))) })] }));
|
|
39
41
|
}
|
|
40
|
-
return (_jsx(Section, { theme: theme, anchor: anchor, headingId: title ? headingId : undefined, blockId: base, blockType: "steps", editMode: editMode, className: cn("vb-steps", className), "data-variant": variant, children: _jsx(
|
|
42
|
+
return (_jsx(Section, { theme: theme, anchor: anchor, headingId: title ? headingId : undefined, blockId: base, blockType: "steps", editMode: editMode, className: cn("vb-steps", className), "data-variant": variant, children: _jsx(Reveal, { disabled: editMode, children: content }) }));
|
|
41
43
|
}
|
|
@@ -535,7 +535,7 @@ export declare const mediaTextBlock: import("../schema/index.js").BlockDefinitio
|
|
|
535
535
|
"4:5": "4:5";
|
|
536
536
|
"3:2": "3:2";
|
|
537
537
|
}>>;
|
|
538
|
-
}, z.core.$strip>, "side" | "
|
|
538
|
+
}, z.core.$strip>, "side" | "bleed" | "overlap">;
|
|
539
539
|
export declare const ctaBlock: import("../schema/index.js").BlockDefinition<z.ZodObject<{
|
|
540
540
|
eyebrow: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
|
|
541
541
|
title: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
|
|
@@ -589,9 +589,14 @@ export declare const menuBlock: import("../schema/index.js").BlockDefinition<z.Z
|
|
|
589
589
|
vegetarian: "vegetarian";
|
|
590
590
|
vegan: "vegan";
|
|
591
591
|
"gluten-free": "gluten-free";
|
|
592
|
+
"dairy-free": "dairy-free";
|
|
593
|
+
"nut-free": "nut-free";
|
|
594
|
+
halal: "halal";
|
|
592
595
|
spicy: "spicy";
|
|
593
596
|
new: "new";
|
|
594
597
|
"chef-pick": "chef-pick";
|
|
598
|
+
"to-share": "to-share";
|
|
599
|
+
seasonal: "seasonal";
|
|
595
600
|
}>>>;
|
|
596
601
|
allergens: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
597
602
|
sesame: "sesame";
|
|
@@ -10,7 +10,7 @@ import { BlogList } from "./blog-list.js";
|
|
|
10
10
|
import { ArticleLayout } from "./article-layout.js";
|
|
11
11
|
import { ContactForm } from "./contact-form.js";
|
|
12
12
|
import { MapHours, localBusinessJsonLd } from "./map-hours.js";
|
|
13
|
-
import { MenuList, menuJsonLd, ALLERGENS } from "./menu-list.js";
|
|
13
|
+
import { MenuList, menuJsonLd, ALLERGENS, MENU_TAGS } from "./menu-list.js";
|
|
14
14
|
import { GallerySection } from "./gallery-section.js";
|
|
15
15
|
import { ConsentBanner } from "./consent-banner.js";
|
|
16
16
|
import { ContactDock } from "./contact-dock.js";
|
|
@@ -367,7 +367,7 @@ const MenuItemSchema = f.group({
|
|
|
367
367
|
description: f.text({ max: 160 }).optional(),
|
|
368
368
|
prices: f.list(f.group({ label: f.text({ max: 20 }).optional(), amount: f.money().optional() }), { min: 1, max: 4 }),
|
|
369
369
|
duration: f.text({ max: 12 }).optional(),
|
|
370
|
-
tags: z.array(z.enum(
|
|
370
|
+
tags: z.array(z.enum(MENU_TAGS)).optional(),
|
|
371
371
|
allergens: z.array(z.enum(ALLERGENS)).optional(),
|
|
372
372
|
image: f.image({ aspect: "1:1" }).optional(),
|
|
373
373
|
unavailable: f.toggle().optional(),
|
|
@@ -4,6 +4,7 @@ import { Container, Section } from "../section.js";
|
|
|
4
4
|
import { BlockHeading } from "../block-heading.js";
|
|
5
5
|
import { RichText } from "../rich-text.js";
|
|
6
6
|
import { field, useBlockIds } from "./_kit-1.js";
|
|
7
|
+
import { headingLines } from "./shared/heading-lines.js";
|
|
7
8
|
/**
|
|
8
9
|
* Long text — an about page, a privacy notice, a service description. Pure server rendering of
|
|
9
10
|
* the RichDoc format; no editor code reaches the site.
|
|
@@ -15,7 +16,7 @@ import { field, useBlockIds } from "./_kit-1.js";
|
|
|
15
16
|
export function RichTextSection({ variant = "column", eyebrow, title, body, dropCap, note, size = "md", id, theme, anchor, className, headingLevel = 2, labels, locale, components, editMode }) {
|
|
16
17
|
const { base, headingId } = useBlockIds(id, "text");
|
|
17
18
|
const offset = (title ? 1 : 0);
|
|
18
|
-
const heading = title ? _jsx(BlockHeading, { id: headingId, level: headingLevel, size: "display-m", text: title, editMode: editMode, field: "props.title", labels: labels, locale: locale }) : null;
|
|
19
|
+
const heading = title ? _jsx(BlockHeading, { id: headingId, level: headingLevel, size: "display-m", text: headingLines(title), editMode: editMode, field: "props.title", labels: labels, locale: locale }) : null;
|
|
19
20
|
const eyebrowEl = eyebrow ? (_jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.08em] text-fg-muted", ...field(editMode, "eyebrow"), children: eyebrow })) : null;
|
|
20
21
|
const text = (cls) => (_jsx(RichText, { doc: body, size: size, headingOffset: offset, components: components, labels: labels, locale: locale, editMode: editMode, field: "props.body", className: cls }));
|
|
21
22
|
let content;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A heading string with hand-set line breaks: `"Ateşte közde,\nmuhabbet sofrada."`. Each line is
|
|
4
|
+
* a block `<span>` and the lines are joined by a real space, so `textContent` (search engines,
|
|
5
|
+
* screen readers, reader mode, copy-paste) reads "közde, muhabbet", never "közde,muhabbet".
|
|
6
|
+
* `*emphasis*` works across and within lines. Strings without `\n` and ReactNodes pass through
|
|
7
|
+
* unchanged (BlockHeading renders their emphasis). Server-safe.
|
|
8
|
+
*/
|
|
9
|
+
export declare function headingLines(text: React.ReactNode): React.ReactNode;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { parseEmphasis } from "../../schema/emphasis.js";
|
|
4
|
+
/**
|
|
5
|
+
* A heading string with hand-set line breaks: `"Ateşte közde,\nmuhabbet sofrada."`. Each line is
|
|
6
|
+
* a block `<span>` and the lines are joined by a real space, so `textContent` (search engines,
|
|
7
|
+
* screen readers, reader mode, copy-paste) reads "közde, muhabbet", never "közde,muhabbet".
|
|
8
|
+
* `*emphasis*` works across and within lines. Strings without `\n` and ReactNodes pass through
|
|
9
|
+
* unchanged (BlockHeading renders their emphasis). Server-safe.
|
|
10
|
+
*/
|
|
11
|
+
export function headingLines(text) {
|
|
12
|
+
if (typeof text !== "string" || !text.includes("\n"))
|
|
13
|
+
return text;
|
|
14
|
+
const lines = [[]];
|
|
15
|
+
for (const part of parseEmphasis(text)) {
|
|
16
|
+
part.text.split("\n").forEach((chunk, i) => {
|
|
17
|
+
if (i > 0)
|
|
18
|
+
lines.push([]);
|
|
19
|
+
if (chunk)
|
|
20
|
+
lines[lines.length - 1].push({ text: chunk, em: part.em });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const filled = lines
|
|
24
|
+
.map((parts) => parts.map((p, i, all) => {
|
|
25
|
+
let t = i === 0 ? p.text.trimStart() : p.text;
|
|
26
|
+
if (i === all.length - 1)
|
|
27
|
+
t = t.trimEnd();
|
|
28
|
+
return { ...p, text: t };
|
|
29
|
+
}).filter((p) => p.text))
|
|
30
|
+
.filter((parts) => parts.length);
|
|
31
|
+
return filled.map((parts, i) => (_jsxs(React.Fragment, { children: [i > 0 ? " " : null, _jsx("span", { className: "vb-heading-line block", children: parts.map((p, j) => (p.em ? _jsx("em", { children: p.text }, j) : _jsx(React.Fragment, { children: p.text }, j))) })] }, i)));
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** `--container` (76rem) — the content column every Container caps at. */
|
|
2
|
+
export declare const CONTAINER_REM = 76;
|
|
3
|
+
/**
|
|
4
|
+
* A column that spans `fraction` of the container from `from` up, and `narrow` below.
|
|
5
|
+
* `columnSizes(5 / 12)` → `"(min-width: 56rem) min(42vw, 32rem), 100vw"`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function columnSizes(fraction: number, opts?: {
|
|
8
|
+
from?: "42rem" | "56rem";
|
|
9
|
+
narrow?: string;
|
|
10
|
+
bleed?: boolean;
|
|
11
|
+
}): string;
|
|
12
|
+
/** Common presets. */
|
|
13
|
+
export declare const SITE_SIZES: {
|
|
14
|
+
/** Edge-to-edge photo (full-bleed hero, editorial strip). */
|
|
15
|
+
readonly bleed: "100vw";
|
|
16
|
+
/** A photo as wide as the content container. */
|
|
17
|
+
readonly container: "min(100vw, 76rem)";
|
|
18
|
+
/** A photo only shown on narrow containers (< 56rem), full width there. */
|
|
19
|
+
readonly narrowOnly: "min(100vw, 56rem)";
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Filmstrip cell: fixed height `clamp(15rem, 38cqi, 30rem)`, width = height × ratio.
|
|
23
|
+
* `filmstripSizes(4 / 5)` → `"max(12rem, min(31vw, 24rem))"`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function filmstripSizes(ratio: number): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Responsive `sizes` for site-block photos. Every photo in a site block passes one of these to
|
|
2
|
+
// BlockImage → the kit Image / `components.Image` (e.g. next/image), so a CDN loader picks a
|
|
3
|
+
// 400–800 px file on a phone instead of the 3840 px top step. Fractions follow the block's 12-col
|
|
4
|
+
// grid and are capped at the content container (`--container`, 76rem): a 2560 px monitor does
|
|
5
|
+
// not fetch a 1500 px file for a 33% column that is never wider than 25rem.
|
|
6
|
+
// Server-safe, no React.
|
|
7
|
+
/** `--container` (76rem) — the content column every Container caps at. */
|
|
8
|
+
export const CONTAINER_REM = 76;
|
|
9
|
+
/**
|
|
10
|
+
* A column that spans `fraction` of the container from `from` up, and `narrow` below.
|
|
11
|
+
* `columnSizes(5 / 12)` → `"(min-width: 56rem) min(42vw, 32rem), 100vw"`.
|
|
12
|
+
*/
|
|
13
|
+
export function columnSizes(fraction, opts = {}) {
|
|
14
|
+
const { from = "56rem", narrow = "100vw", bleed = false } = opts;
|
|
15
|
+
const vw = Math.ceil(fraction * 100);
|
|
16
|
+
// A column bleeding to the section edge grows past the container on very wide screens.
|
|
17
|
+
const wide = bleed ? `${vw}vw` : `min(${vw}vw, ${Math.ceil(fraction * CONTAINER_REM)}rem)`;
|
|
18
|
+
return `(min-width: ${from}) ${wide}, ${narrow}`;
|
|
19
|
+
}
|
|
20
|
+
/** Common presets. */
|
|
21
|
+
export const SITE_SIZES = {
|
|
22
|
+
/** Edge-to-edge photo (full-bleed hero, editorial strip). */
|
|
23
|
+
bleed: "100vw",
|
|
24
|
+
/** A photo as wide as the content container. */
|
|
25
|
+
container: `min(100vw, ${CONTAINER_REM}rem)`,
|
|
26
|
+
/** A photo only shown on narrow containers (< 56rem), full width there. */
|
|
27
|
+
narrowOnly: "min(100vw, 56rem)",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Filmstrip cell: fixed height `clamp(15rem, 38cqi, 30rem)`, width = height × ratio.
|
|
31
|
+
* `filmstripSizes(4 / 5)` → `"max(12rem, min(31vw, 24rem))"`.
|
|
32
|
+
*/
|
|
33
|
+
export function filmstripSizes(ratio) {
|
|
34
|
+
const r = ratio > 0 ? ratio : 0.8;
|
|
35
|
+
const n = (x) => Math.round(x * 10) / 10;
|
|
36
|
+
return `max(${n(15 * r)}rem, min(${Math.ceil(38 * r)}vw, ${n(30 * r)}rem))`;
|
|
37
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const carouselLabelKeys: readonly ["previous", "next", "slideCounter", "slideLabel", "carousel", "slide"];
|
|
2
2
|
export type CarouselLabels = Record<(typeof carouselLabelKeys)[number], string>;
|
|
3
|
-
export declare const openNowLabelKeys: readonly ["openNow", "
|
|
3
|
+
export declare const openNowLabelKeys: readonly ["openNow", "openUntil", "closingSoon", "closingSoonUntil", "openAllDay", "closed", "closedToday", "opensAt", "opensTodayAt", "opensTomorrowAt"];
|
|
4
4
|
export type OpenNowLabels = Record<(typeof openNowLabelKeys)[number], string>;
|
|
5
5
|
export declare const contactIslandLabelKeys: readonly ["send", "sending", "sent", "optional", "errorSummary", "errorServer", "errorServerCall", "privacyRead", "privacyLink", "privacyRequired", "marketingConsent", "honeypot", "choose", "errorRequired", "errorEmail", "errorPhone", "errorTooLong"];
|
|
6
6
|
export type ContactIslandLabels = Record<(typeof contactIslandLabelKeys)[number], string>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Label keys each client island needs. Kept out of the "use client" files: a server component
|
|
2
2
|
// importing a plain value from a client module would receive a client reference, not the array.
|
|
3
3
|
export const carouselLabelKeys = ["previous", "next", "slideCounter", "slideLabel", "carousel", "slide"];
|
|
4
|
-
export const openNowLabelKeys = ["openNow", "
|
|
4
|
+
export const openNowLabelKeys = ["openNow", "openUntil", "closingSoon", "closingSoonUntil", "openAllDay", "closed", "closedToday", "opensAt", "opensTodayAt", "opensTomorrowAt"];
|
|
5
5
|
export const contactIslandLabelKeys = [
|
|
6
6
|
"send",
|
|
7
7
|
"sending",
|
|
@@ -86,12 +86,15 @@ export declare const site2Labels: {
|
|
|
86
86
|
email: string;
|
|
87
87
|
hours: string;
|
|
88
88
|
openNow: string;
|
|
89
|
-
|
|
89
|
+
openUntil: string;
|
|
90
90
|
closingSoon: string;
|
|
91
|
+
closingSoonUntil: string;
|
|
92
|
+
openAllDay: string;
|
|
91
93
|
closed: string;
|
|
92
94
|
closedToday: string;
|
|
93
95
|
opensAt: string;
|
|
94
96
|
opensTodayAt: string;
|
|
97
|
+
opensTomorrowAt: string;
|
|
95
98
|
open24h: string;
|
|
96
99
|
openingHours: string;
|
|
97
100
|
openingHoursOf: string;
|
|
@@ -119,6 +122,11 @@ export declare const site2Labels: {
|
|
|
119
122
|
tagSpicy: string;
|
|
120
123
|
tagNew: string;
|
|
121
124
|
tagChefPick: string;
|
|
125
|
+
tagDairyFree: string;
|
|
126
|
+
tagNutFree: string;
|
|
127
|
+
tagHalal: string;
|
|
128
|
+
tagToShare: string;
|
|
129
|
+
tagSeasonal: string;
|
|
122
130
|
allergen_gluten: string;
|
|
123
131
|
allergen_crustaceans: string;
|
|
124
132
|
allergen_eggs: string;
|
|
@@ -95,13 +95,17 @@ export const site2Labels = {
|
|
|
95
95
|
email: "Email",
|
|
96
96
|
hours: "Hours",
|
|
97
97
|
// Map & hours
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Status line "head · tail" (OpenNow). Each part says it once: "Open · until 1 AM".
|
|
99
|
+
openNow: "Open",
|
|
100
|
+
openUntil: "until {time}",
|
|
100
101
|
closingSoon: "Closing soon",
|
|
102
|
+
closingSoonUntil: "open until {time}",
|
|
103
|
+
openAllDay: "24 hours",
|
|
101
104
|
closed: "Closed",
|
|
102
105
|
closedToday: "Closed today",
|
|
103
106
|
opensAt: "opens {day} at {time}",
|
|
104
107
|
opensTodayAt: "opens at {time}",
|
|
108
|
+
opensTomorrowAt: "opens tomorrow at {time}",
|
|
105
109
|
open24h: "Open 24 hours",
|
|
106
110
|
openingHours: "Opening hours",
|
|
107
111
|
openingHoursOf: "Opening hours, {name}",
|
|
@@ -130,6 +134,11 @@ export const site2Labels = {
|
|
|
130
134
|
tagSpicy: "Spicy",
|
|
131
135
|
tagNew: "New",
|
|
132
136
|
tagChefPick: "Chef’s pick",
|
|
137
|
+
tagDairyFree: "Dairy-free",
|
|
138
|
+
tagNutFree: "Nut-free",
|
|
139
|
+
tagHalal: "Halal",
|
|
140
|
+
tagToShare: "To share",
|
|
141
|
+
tagSeasonal: "Seasonal",
|
|
133
142
|
allergen_gluten: "Gluten",
|
|
134
143
|
allergen_crustaceans: "Crustaceans",
|
|
135
144
|
allergen_eggs: "Eggs",
|
|
@@ -295,13 +304,17 @@ export const site2LabelsTr = {
|
|
|
295
304
|
phone: "Telefon",
|
|
296
305
|
email: "E-posta",
|
|
297
306
|
hours: "Çalışma saatleri",
|
|
298
|
-
|
|
299
|
-
|
|
307
|
+
// {timeTo} = "01:00’e", {timeAt} = "12:00’de" — the suffix follows the spoken time.
|
|
308
|
+
openNow: "Açık",
|
|
309
|
+
openUntil: "{timeTo} kadar",
|
|
300
310
|
closingSoon: "Birazdan kapanıyor",
|
|
311
|
+
closingSoonUntil: "{timeTo} kadar açık",
|
|
312
|
+
openAllDay: "24 saat",
|
|
301
313
|
closed: "Kapalı",
|
|
302
314
|
closedToday: "Bugün kapalı",
|
|
303
|
-
opensAt: "{day} {
|
|
304
|
-
opensTodayAt: "{
|
|
315
|
+
opensAt: "{day} {timeAt} açılıyor",
|
|
316
|
+
opensTodayAt: "bugün {timeAt} açılıyor",
|
|
317
|
+
opensTomorrowAt: "yarın {timeAt} açılıyor",
|
|
305
318
|
open24h: "24 saat açık",
|
|
306
319
|
openingHours: "Çalışma saatleri",
|
|
307
320
|
openingHoursOf: "Çalışma saatleri, {name}",
|
|
@@ -329,6 +342,11 @@ export const site2LabelsTr = {
|
|
|
329
342
|
tagSpicy: "Acılı",
|
|
330
343
|
tagNew: "Yeni",
|
|
331
344
|
tagChefPick: "Şefin seçimi",
|
|
345
|
+
tagDairyFree: "Sütsüz",
|
|
346
|
+
tagNutFree: "Kuruyemişsiz",
|
|
347
|
+
tagHalal: "Helal",
|
|
348
|
+
tagToShare: "Paylaşımlık",
|
|
349
|
+
tagSeasonal: "Mevsimlik",
|
|
332
350
|
allergen_gluten: "Gluten",
|
|
333
351
|
allergen_crustaceans: "Kabuklular",
|
|
334
352
|
allergen_eggs: "Yumurta",
|
|
@@ -15,7 +15,7 @@ export interface OpenNowProps {
|
|
|
15
15
|
className?: string;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* "Open
|
|
18
|
+
* "Open · until 10 PM" / "Closed · opens tomorrow at 12 PM" — computed in the business's time zone on the client, refreshed
|
|
19
19
|
* every minute. Renders nothing on the server and without JS: a cached page never shows a stale
|
|
20
20
|
* status. ~1 KB.
|
|
21
21
|
*/
|
|
@@ -4,7 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { cn2 as cn } from "./kit-2.js";
|
|
5
5
|
import { describeStatus, hoursStatus } from "./opening-hours.js";
|
|
6
6
|
/**
|
|
7
|
-
* "Open
|
|
7
|
+
* "Open · until 10 PM" / "Closed · opens tomorrow at 12 PM" — computed in the business's time zone on the client, refreshed
|
|
8
8
|
* every minute. Renders nothing on the server and without JS: a cached page never shows a stale
|
|
9
9
|
* status. ~1 KB.
|
|
10
10
|
*/
|
|
@@ -110,8 +110,24 @@ export declare function summarizeHours(hours: OpeningHours, locale?: string): {
|
|
|
110
110
|
}[];
|
|
111
111
|
/** schema.org `OpeningHoursSpecification[]` for JSON-LD (weekly + special days). */
|
|
112
112
|
export declare function openingHoursJsonLd(hours: OpeningHours): Record<string, unknown>[];
|
|
113
|
-
/**
|
|
114
|
-
export
|
|
113
|
+
/** Label keys `describeStatus` reads. */
|
|
114
|
+
export type StatusLabelKey = "openNow" | "openUntil" | "closingSoon" | "closingSoonUntil" | "openAllDay" | "closed" | "closedToday" | "opensAt" | "opensTodayAt" | "opensTomorrowAt";
|
|
115
|
+
/**
|
|
116
|
+
* Variables for a time in a status template: `{time}` ("01:00", "1 AM"), plus `{timeTo}` /
|
|
117
|
+
* `{timeAt}` — the time with the Turkish dative / locative suffix ("01:00’e", "12:00’de") so the
|
|
118
|
+
* TR strings read naturally; other locales get the plain time.
|
|
119
|
+
*/
|
|
120
|
+
export declare function timeVars(hhmm: string, locale?: string): {
|
|
121
|
+
time: string;
|
|
122
|
+
timeTo: string;
|
|
123
|
+
timeAt: string;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* The status as `{ head, tail }`, each said once: "Open · until 1 AM", "Closing soon · open until
|
|
127
|
+
* 1 AM", "Open · 24 hours", "Closed · opens tomorrow at 12 PM", "Closed today · Staff day · opens
|
|
128
|
+
* Thu at 12 PM". TR: "Açık · 01:00’e kadar", "Kapalı · yarın 12:00’de açılıyor".
|
|
129
|
+
*/
|
|
130
|
+
export declare function describeStatus(s: OpenStatus, labels: Record<StatusLabelKey, string>, locale?: string): {
|
|
115
131
|
head: string;
|
|
116
132
|
tail?: string;
|
|
117
133
|
};
|
|
@@ -227,19 +227,48 @@ export function openingHoursJsonLd(hours) {
|
|
|
227
227
|
}
|
|
228
228
|
return out;
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
// Turkish case suffixes on a clock time follow the last spoken word: "01:00" is read "bir" →
|
|
231
|
+
// "01:00’e" (dative) / "01:00’de" (locative); "22:30" ends in "otuz" → "22:30’a" / "22:30’da".
|
|
232
|
+
const TR_UNITS_DAT = ["", "e", "ye", "e", "e", "e", "ya", "ye", "e", "a"];
|
|
233
|
+
const TR_UNITS_LOC = ["", "de", "de", "te", "te", "te", "da", "de", "de", "da"];
|
|
234
|
+
const TR_TENS_DAT = ["a", "a", "ye", "a", "a", "ye"]; // sıfır, on, yirmi, otuz, kırk, elli
|
|
235
|
+
const TR_TENS_LOC = ["da", "da", "de", "da", "ta", "de"];
|
|
236
|
+
function trSuffix(hhmm, kind) {
|
|
237
|
+
const [h = 0, m = 0] = hhmm.split(":").map(Number);
|
|
238
|
+
const n = m === 0 ? h % 24 : m; // the last word read aloud
|
|
239
|
+
const unit = n % 10;
|
|
240
|
+
if (unit)
|
|
241
|
+
return (kind === "dat" ? TR_UNITS_DAT : TR_UNITS_LOC)[unit];
|
|
242
|
+
return (kind === "dat" ? TR_TENS_DAT : TR_TENS_LOC)[Math.floor(n / 10)];
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Variables for a time in a status template: `{time}` ("01:00", "1 AM"), plus `{timeTo}` /
|
|
246
|
+
* `{timeAt}` — the time with the Turkish dative / locative suffix ("01:00’e", "12:00’de") so the
|
|
247
|
+
* TR strings read naturally; other locales get the plain time.
|
|
248
|
+
*/
|
|
249
|
+
export function timeVars(hhmm, locale = "en-US") {
|
|
250
|
+
const time = formatTime(hhmm, locale);
|
|
251
|
+
if (!/^tr\b/i.test(locale))
|
|
252
|
+
return { time, timeTo: time, timeAt: time };
|
|
253
|
+
return { time, timeTo: `${time}’${trSuffix(hhmm, "dat")}`, timeAt: `${time}’${trSuffix(hhmm, "loc")}` };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The status as `{ head, tail }`, each said once: "Open · until 1 AM", "Closing soon · open until
|
|
257
|
+
* 1 AM", "Open · 24 hours", "Closed · opens tomorrow at 12 PM", "Closed today · Staff day · opens
|
|
258
|
+
* Thu at 12 PM". TR: "Açık · 01:00’e kadar", "Kapalı · yarın 12:00’de açılıyor".
|
|
259
|
+
*/
|
|
231
260
|
export function describeStatus(s, labels, locale = "en-US") {
|
|
232
261
|
const f = (tpl, vars) => formatMessage(tpl, vars, locale);
|
|
233
262
|
if (s.state !== "closed") {
|
|
234
263
|
if (s.allDay)
|
|
235
|
-
return { head: f(labels.openNow), tail: f(labels.
|
|
236
|
-
const
|
|
237
|
-
return
|
|
264
|
+
return { head: f(labels.openNow), tail: f(labels.openAllDay) };
|
|
265
|
+
const vars = timeVars(s.closesAt, locale);
|
|
266
|
+
return s.state === "closing-soon" ? { head: f(labels.closingSoon), tail: f(labels.closingSoonUntil, vars) } : { head: f(labels.openNow), tail: f(labels.openUntil, vars) };
|
|
238
267
|
}
|
|
239
268
|
const head = s.special?.closed ? (s.special.note ? `${f(labels.closedToday)} · ${s.special.note}` : f(labels.closedToday)) : f(labels.closed);
|
|
240
269
|
if (!s.opensAt)
|
|
241
270
|
return { head };
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
return { head, tail };
|
|
271
|
+
const vars = { ...timeVars(s.opensAt.time, locale), day: weekdayName(s.opensAt.weekday, locale, "short") };
|
|
272
|
+
const tpl = s.opensAt.dayOffset === 0 ? labels.opensTodayAt : s.opensAt.dayOffset === 1 ? labels.opensTomorrowAt : labels.opensAt;
|
|
273
|
+
return { head, tail: f(tpl, vars) };
|
|
245
274
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** ≥ 24×24 CSS px everywhere (WCAG 2.5.8), ≥ 44px tall on touch. For text links in lists. */
|
|
2
|
+
export declare const TAP = "inline-flex min-h-6 items-center pointer-coarse:min-h-11";
|
|
3
|
+
/** A square icon control: ≥ 24px everywhere, 44×44 on touch. Combine with the control's own size. */
|
|
4
|
+
export declare const TAP_ICON = "min-h-6 min-w-6 pointer-coarse:min-h-11 pointer-coarse:min-w-11";
|
|
5
|
+
/**
|
|
6
|
+
* WCAG 2.5.3 Label in Name: an `aria-label` must contain the visible label. Returns `name` when
|
|
7
|
+
* it already does (case-insensitive), else `"<visible>, <name>"`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function nameWithLabel(visible: string | undefined, name: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Tap-target sizing for site blocks (WCAG 2.5.8 + touch comfort). Padding/min-size only — the
|
|
2
|
+
// visual stays the same on a mouse; on touch (`pointer: coarse`) primary controls grow to 44px.
|
|
3
|
+
/** ≥ 24×24 CSS px everywhere (WCAG 2.5.8), ≥ 44px tall on touch. For text links in lists. */
|
|
4
|
+
export const TAP = "inline-flex min-h-6 items-center pointer-coarse:min-h-11";
|
|
5
|
+
/** A square icon control: ≥ 24px everywhere, 44×44 on touch. Combine with the control's own size. */
|
|
6
|
+
export const TAP_ICON = "min-h-6 min-w-6 pointer-coarse:min-h-11 pointer-coarse:min-w-11";
|
|
7
|
+
/**
|
|
8
|
+
* WCAG 2.5.3 Label in Name: an `aria-label` must contain the visible label. Returns `name` when
|
|
9
|
+
* it already does (case-insensitive), else `"<visible>, <name>"`.
|
|
10
|
+
*/
|
|
11
|
+
export function nameWithLabel(visible, name) {
|
|
12
|
+
const v = visible?.trim();
|
|
13
|
+
if (!v)
|
|
14
|
+
return name;
|
|
15
|
+
return name.toLocaleLowerCase().includes(v.toLocaleLowerCase()) ? name : `${v}, ${name}`;
|
|
16
|
+
}
|
|
@@ -36,6 +36,13 @@ export interface SiteShellProps extends Omit<React.HTMLAttributes<HTMLDivElement
|
|
|
36
36
|
pageParam?: string;
|
|
37
37
|
/** Query key for the locale. Default `"lang"`. */
|
|
38
38
|
localeParam?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Motion level for the whole site → `data-motion`. subtle (default): short fades and small
|
|
41
|
+
* rises, count-ups, the logo ticker. full: longer, wider, a wiping hero title, magnetic
|
|
42
|
+
* calls-to-action. off: final state everywhere. Reduced motion always wins. Page and
|
|
43
|
+
* language switches cross-fade (View Transitions where available, a fade otherwise).
|
|
44
|
+
*/
|
|
45
|
+
motion?: "full" | "subtle" | "off";
|
|
39
46
|
}
|
|
40
47
|
export interface SiteShellContextValue {
|
|
41
48
|
page: string;
|
|
@@ -46,6 +53,10 @@ export interface SiteShellContextValue {
|
|
|
46
53
|
navigate: (to: SiteLocation) => void;
|
|
47
54
|
/** The href for a page (respects `routing` and `pageParam`). */
|
|
48
55
|
hrefFor: (page: string, anchor?: string) => string;
|
|
56
|
+
/** The shell's motion level. */
|
|
57
|
+
motion: "full" | "subtle" | "off";
|
|
58
|
+
/** True after a switch that should fade the new page in (no View Transitions available). */
|
|
59
|
+
pageEnter: boolean;
|
|
49
60
|
}
|
|
50
61
|
/** Page, locale and navigation of the closest Site shell. Throws outside one. */
|
|
51
62
|
export declare function useSiteShell(): SiteShellContextValue;
|
|
@@ -5,6 +5,8 @@ import { cn } from "../../lib/cn.js";
|
|
|
5
5
|
import { ThemeScope } from "../../lib/theme-scope.js";
|
|
6
6
|
import { useControllableState } from "../../lib/use-controllable-state.js";
|
|
7
7
|
import { isBuiltInAccent, siteAccent, siteAccentCss, siteAccentKey } from "./shared/site-accent.js";
|
|
8
|
+
import { motionAllowed } from "../../motion/core.js";
|
|
9
|
+
import { startPageTransition, viewTransitionsSupported } from "../../motion/page-transition.js";
|
|
8
10
|
import { parseSiteHref, readSiteLocation, sitePageHref, writeSiteLocation } from "./shared/site-routing.js";
|
|
9
11
|
const SiteShellContext = React.createContext(null);
|
|
10
12
|
/** Page, locale and navigation of the closest Site shell. Throws outside one. */
|
|
@@ -32,7 +34,7 @@ export function useSiteShell() {
|
|
|
32
34
|
* </SiteShell>
|
|
33
35
|
* ```
|
|
34
36
|
*/
|
|
35
|
-
export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDark, font, material, density, theme, dir, locales = ["en", "tr"], locale: localeProp, defaultLocale, onLocaleChange, pages = ["home"], page: pageProp, defaultPage, onPageChange, routing = "search", pageParam = "page", localeParam = "lang", className, onClick, children, ...props }, ref) {
|
|
37
|
+
export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDark, font, material, density, theme, dir, locales = ["en", "tr"], locale: localeProp, defaultLocale, onLocaleChange, pages = ["home"], page: pageProp, defaultPage, onPageChange, routing = "search", pageParam = "page", localeParam = "lang", motion = "subtle", className, onClick, children, ...props }, ref) {
|
|
36
38
|
const homeLocale = defaultLocale ?? locales[0] ?? "en";
|
|
37
39
|
const homePage = defaultPage ?? pages[0] ?? "home";
|
|
38
40
|
const [locale, setLocale] = useControllableState({ value: localeProp, defaultValue: homeLocale, onChange: onLocaleChange, name: "SiteShell" });
|
|
@@ -47,6 +49,19 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
47
49
|
}, [ref]);
|
|
48
50
|
/** What to do after the next render: focus the h1, scroll to the top or to an anchor. */
|
|
49
51
|
const pending = React.useRef(null);
|
|
52
|
+
const [pageEnter, setPageEnter] = React.useState(false);
|
|
53
|
+
/** Swap page/locale as a page transition: a View Transition, or a fade on the new page. */
|
|
54
|
+
const transition = React.useCallback((commit) => {
|
|
55
|
+
const root = rootRef.current;
|
|
56
|
+
const animate = motion !== "off" && motionAllowed(root);
|
|
57
|
+
setPageEnter(animate && !viewTransitionsSupported());
|
|
58
|
+
if (animate)
|
|
59
|
+
startPageTransition(commit, { scope: root });
|
|
60
|
+
else
|
|
61
|
+
commit();
|
|
62
|
+
}, [motion]);
|
|
63
|
+
const transitionRef = React.useRef(transition);
|
|
64
|
+
transitionRef.current = transition;
|
|
50
65
|
const pageKey = pages.join("|");
|
|
51
66
|
const localeKey = locales.join("|");
|
|
52
67
|
const params = React.useMemo(() => ({ pageParam, localeParam }), [pageParam, localeParam]);
|
|
@@ -63,10 +78,14 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
63
78
|
}
|
|
64
79
|
if (pageChanged)
|
|
65
80
|
pending.current = { focus: !to.anchor, scroll: true, anchor: to.anchor };
|
|
66
|
-
if (nextLocale !== locale)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
if (pageChanged || nextLocale !== locale) {
|
|
82
|
+
transition(() => {
|
|
83
|
+
if (nextLocale !== locale)
|
|
84
|
+
setLocale(nextLocale);
|
|
85
|
+
if (pageChanged)
|
|
86
|
+
setPage(nextPage);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
70
89
|
else if (to.anchor)
|
|
71
90
|
scrollToAnchor(rootRef.current, to.anchor, false);
|
|
72
91
|
// The Navbar's mobile menu is a native popover: close it after any switch.
|
|
@@ -80,7 +99,7 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
80
99
|
});
|
|
81
100
|
},
|
|
82
101
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
-
[page, locale, pageKey, localeKey, routing, homePage, homeLocale, params, setLocale, setPage]);
|
|
102
|
+
[page, locale, pageKey, localeKey, routing, homePage, homeLocale, params, setLocale, setPage, transition]);
|
|
84
103
|
// URL → state on mount and on back/forward.
|
|
85
104
|
React.useEffect(() => {
|
|
86
105
|
if (routing === "memory")
|
|
@@ -90,12 +109,18 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
90
109
|
const nextPage = loc.page && pages.includes(loc.page) ? loc.page : homePage;
|
|
91
110
|
const nextLocale = loc.locale && locales.includes(loc.locale) ? loc.locale : homeLocale;
|
|
92
111
|
const anchor = routing === "search" && window.location.hash.length > 1 ? decodeURIComponent(window.location.hash.slice(1)) : undefined;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
const commit = () => {
|
|
113
|
+
setLocale(nextLocale);
|
|
114
|
+
setPage((prev) => {
|
|
115
|
+
if (prev !== nextPage)
|
|
116
|
+
pending.current = { focus: !initial && !anchor, scroll: !initial, anchor: initial ? anchor : undefined };
|
|
117
|
+
return nextPage;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
if (initial)
|
|
121
|
+
commit();
|
|
122
|
+
else
|
|
123
|
+
transitionRef.current(commit);
|
|
99
124
|
};
|
|
100
125
|
// A deep link (`?page=order`) is server-rendered as home and switched here. As a transition,
|
|
101
126
|
// React finishes hydrating the home tree (and any Suspense boundary still dehydrated) before
|
|
@@ -167,9 +192,11 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
167
192
|
locales,
|
|
168
193
|
navigate: (to) => apply(to, "push"),
|
|
169
194
|
hrefFor: (p, anchor) => sitePageHref(p, anchor, { routing, pageParam }),
|
|
195
|
+
motion,
|
|
196
|
+
pageEnter,
|
|
170
197
|
}),
|
|
171
198
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
172
|
-
[page, locale, pageKey, localeKey, apply, routing, pageParam]);
|
|
199
|
+
[page, locale, pageKey, localeKey, apply, routing, pageParam, motion, pageEnter]);
|
|
173
200
|
// A colour → scoped variables. A name (built-in preset or your own, like "brand") goes
|
|
174
201
|
// straight to data-accent.
|
|
175
202
|
const custom = accent && !isBuiltInAccent(accent) && looksLikeColour(accent) ? accent : undefined;
|
|
@@ -186,7 +213,7 @@ export const SiteShell = React.forwardRef(function SiteShell({ accent, accentDar
|
|
|
186
213
|
return null;
|
|
187
214
|
}
|
|
188
215
|
}, [custom, accentDark]);
|
|
189
|
-
return (_jsx(SiteShellContext.Provider, { value: ctx, children: _jsxs(ThemeScope, { ref: setRoot, theme: theme, material: material, density: density, font: font, dir: dir, accent: accentStyle ? "site" : custom ? undefined : accent, "data-site-accent": accentStyle?.key, "data-site-shell": "", "data-page": page, lang: locale, className: cn("vb-scope font-sans text-fg", className), onClick: handleClick, ...props, children: [accentStyle ? _jsx("style", { children: accentStyle.css }) : null, children] }) }));
|
|
216
|
+
return (_jsx(SiteShellContext.Provider, { value: ctx, children: _jsxs(ThemeScope, { ref: setRoot, theme: theme, material: material, density: density, font: font, dir: dir, accent: accentStyle ? "site" : custom ? undefined : accent, "data-site-accent": accentStyle?.key, "data-site-shell": "", "data-motion": motion, "data-page": page, lang: locale, className: cn("vb-scope font-sans text-fg", className), onClick: handleClick, ...props, children: [accentStyle ? _jsx("style", { children: accentStyle.css }) : null, children] }) }));
|
|
190
217
|
});
|
|
191
218
|
const looksLikeColour = (v) => v.trim().startsWith("#") || v.includes("(");
|
|
192
219
|
const cssString = (s) => s.replace(/["\\]/g, "\\$&");
|
|
@@ -198,7 +225,7 @@ function scrollToAnchor(root, anchor, instant) {
|
|
|
198
225
|
}
|
|
199
226
|
/** One inner page: rendered only while it is the shell's current page. */
|
|
200
227
|
export function SiteShellPage({ id, title, children }) {
|
|
201
|
-
const { page } = useSiteShell();
|
|
228
|
+
const { page, pageEnter } = useSiteShell();
|
|
202
229
|
const active = page === id;
|
|
203
230
|
const first = React.useRef(true);
|
|
204
231
|
React.useEffect(() => {
|
|
@@ -208,7 +235,7 @@ export function SiteShellPage({ id, title, children }) {
|
|
|
208
235
|
}, [active, title]);
|
|
209
236
|
if (!active)
|
|
210
237
|
return null;
|
|
211
|
-
return (_jsx("div", { "data-site-page": id, className: "contents", children: children }));
|
|
238
|
+
return (_jsx("div", { "data-site-page": id, "data-page-enter": pageEnter ? "" : undefined, className: "contents", children: children }));
|
|
212
239
|
}
|
|
213
240
|
/** One language version: rendered only while it is the shell's current locale (with `lang`). */
|
|
214
241
|
export function SiteShellLocale({ code, children }) {
|
|
@@ -31,7 +31,9 @@ export interface StatsBandProps extends Omit<BlockBaseProps, "variant" | "labels
|
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* Concrete, sourced numbers ("4.8 on Google · 1,240 reviews"). Semantic `dl`, Intl formatting,
|
|
34
|
-
* units spelled out for screen readers
|
|
34
|
+
* units spelled out for screen readers. Numeric figures count up once when they scroll into view
|
|
35
|
+
* (motion kit CountUp: width reserved, final value for screen readers, still under reduced
|
|
36
|
+
* motion / `motion="off"`); the items reveal in sequence. Server component.
|
|
35
37
|
*
|
|
36
38
|
* ```tsx
|
|
37
39
|
* <StatsBand items={[{ value: 4.8, label: "Google rating", source: "1,240 reviews, Nov 2026" }]} />
|