@vamos-ui/react 0.3.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.
Files changed (103) hide show
  1. package/dist/blocks/block-image.js +1 -1
  2. package/dist/blocks/block-link.js +1 -1
  3. package/dist/blocks/index.d.ts +2 -2
  4. package/dist/blocks/index.js +2 -2
  5. package/dist/blocks/site/_kit-1.js +1 -1
  6. package/dist/blocks/site/announcement-bar.js +1 -1
  7. package/dist/blocks/site/article-layout.d.ts +1 -0
  8. package/dist/blocks/site/article-layout.js +2 -1
  9. package/dist/blocks/site/blog-list.d.ts +1 -0
  10. package/dist/blocks/site/blog-list.js +3 -2
  11. package/dist/blocks/site/contact-dock.js +3 -1
  12. package/dist/blocks/site/contact-form.js +1 -1
  13. package/dist/blocks/site/cta.d.ts +2 -2
  14. package/dist/blocks/site/cta.js +6 -4
  15. package/dist/blocks/site/feature-grid.d.ts +1 -0
  16. package/dist/blocks/site/feature-grid.js +5 -4
  17. package/dist/blocks/site/footer.d.ts +1 -1
  18. package/dist/blocks/site/footer.js +8 -7
  19. package/dist/blocks/site/gallery-section.d.ts +6 -1
  20. package/dist/blocks/site/gallery-section.js +5 -4
  21. package/dist/blocks/site/hero.d.ts +3 -2
  22. package/dist/blocks/site/hero.js +7 -5
  23. package/dist/blocks/site/logo-strip.d.ts +1 -0
  24. package/dist/blocks/site/logo-strip.js +1 -1
  25. package/dist/blocks/site/map-hours.d.ts +2 -2
  26. package/dist/blocks/site/map-hours.js +4 -2
  27. package/dist/blocks/site/media-text.d.ts +4 -1
  28. package/dist/blocks/site/media-text.js +4 -3
  29. package/dist/blocks/site/menu-list.d.ts +7 -3
  30. package/dist/blocks/site/menu-list.js +34 -7
  31. package/dist/blocks/site/navbar.d.ts +1 -0
  32. package/dist/blocks/site/navbar.js +6 -5
  33. package/dist/blocks/site/not-found.js +2 -1
  34. package/dist/blocks/site/page-intro.d.ts +1 -0
  35. package/dist/blocks/site/page-intro.js +2 -1
  36. package/dist/blocks/site/process.d.ts +1 -0
  37. package/dist/blocks/site/process.js +2 -1
  38. package/dist/blocks/site/registry-1.d.ts +1 -1
  39. package/dist/blocks/site/registry-2.d.ts +5 -0
  40. package/dist/blocks/site/registry-2.js +2 -2
  41. package/dist/blocks/site/rich-text-section.js +2 -1
  42. package/dist/blocks/site/shared/heading-lines.d.ts +9 -0
  43. package/dist/blocks/site/shared/heading-lines.js +32 -0
  44. package/dist/blocks/site/shared/image-sizes.d.ts +25 -0
  45. package/dist/blocks/site/shared/image-sizes.js +37 -0
  46. package/dist/blocks/site/shared/island-keys.d.ts +1 -1
  47. package/dist/blocks/site/shared/island-keys.js +1 -1
  48. package/dist/blocks/site/shared/labels-2.d.ts +9 -1
  49. package/dist/blocks/site/shared/labels-2.js +24 -6
  50. package/dist/blocks/site/shared/open-now.d.ts +1 -1
  51. package/dist/blocks/site/shared/open-now.js +1 -1
  52. package/dist/blocks/site/shared/opening-hours.d.ts +18 -2
  53. package/dist/blocks/site/shared/opening-hours.js +36 -7
  54. package/dist/blocks/site/shared/tap.d.ts +9 -0
  55. package/dist/blocks/site/shared/tap.js +16 -0
  56. package/dist/blocks/site/team.d.ts +1 -1
  57. package/dist/blocks/site/team.js +4 -3
  58. package/dist/blocks/site/testimonials.d.ts +2 -2
  59. package/dist/blocks/site/testimonials.js +3 -3
  60. package/dist/charts/bar-chart.js +1 -1
  61. package/dist/charts/chart-shell.d.ts +9 -2
  62. package/dist/charts/chart-shell.js +10 -4
  63. package/dist/charts/chart.js +1 -1
  64. package/dist/charts/combo-chart.js +5 -7
  65. package/dist/charts/utils.d.ts +10 -0
  66. package/dist/charts/utils.js +46 -0
  67. package/dist/components/activity-timeline.js +5 -5
  68. package/dist/components/aspect-ratio.d.ts +26 -0
  69. package/dist/components/aspect-ratio.js +42 -0
  70. package/dist/components/carousel.d.ts +12 -3
  71. package/dist/components/carousel.js +8 -5
  72. package/dist/components/collapsible.d.ts +36 -0
  73. package/dist/components/collapsible.js +37 -0
  74. package/dist/components/image.d.ts +18 -2
  75. package/dist/components/image.js +12 -5
  76. package/dist/components/menubar.d.ts +96 -0
  77. package/dist/components/menubar.js +123 -0
  78. package/dist/components/placeholder-art.d.ts +51 -0
  79. package/dist/components/placeholder-art.js +82 -0
  80. package/dist/index.d.ts +4 -0
  81. package/dist/index.js +4 -0
  82. package/dist/lib/value-format.js +1 -1
  83. package/dist/locales/ar.d.ts +2087 -0
  84. package/dist/locales/ar.js +2088 -0
  85. package/dist/locales/de.d.ts +2087 -0
  86. package/dist/locales/de.js +2088 -0
  87. package/dist/locales/en.d.ts +1 -0
  88. package/dist/locales/en.js +1 -0
  89. package/dist/locales/es.d.ts +2087 -0
  90. package/dist/locales/es.js +2087 -0
  91. package/dist/locales/fr.d.ts +2087 -0
  92. package/dist/locales/fr.js +2088 -0
  93. package/dist/locales/tr.d.ts +1 -0
  94. package/dist/locales/tr.js +1 -0
  95. package/dist/media/media-library.js +1 -1
  96. package/dist/motion/text-reveal.d.ts +3 -1
  97. package/dist/motion/text-reveal.js +5 -3
  98. package/dist/render/block-renderer.js +1 -1
  99. package/dist/styles/blocks.css +18 -9
  100. package/dist/styles/editors.css +2 -1
  101. package/dist/styles/media.css +31 -5
  102. package/dist/styles.css +39 -9
  103. package/package.json +1 -1
@@ -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", "closesAt", "closingSoon", "closed", "closedToday", "opensAt", "opensTodayAt", "open24h", "today"];
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", "closesAt", "closingSoon", "closed", "closedToday", "opensAt", "opensTodayAt", "open24h", "today"];
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
- closesAt: string;
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
- openNow: "Open now",
99
- closesAt: "closes at {time}",
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
- openNow: "Şu an açık",
299
- closesAt: "{time}’de kapanıyor",
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} {time}’de açılıyor",
304
- opensTodayAt: "{time}’de açılıyor",
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 now · closes at 22:00" — computed in the business's time zone on the client, refreshed
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 now · closes at 22:00" — computed in the business's time zone on the client, refreshed
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
- /** The status sentence: "Open now · closes at 22:00", "Closed · opens Thu at 12:00". */
114
- export declare function describeStatus(s: OpenStatus, labels: Record<"openNow" | "closesAt" | "closingSoon" | "closed" | "closedToday" | "opensAt" | "opensTodayAt" | "open24h", string>, locale?: string): {
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
- /** The status sentence: "Open now · closes at 22:00", "Closed · opens Thu at 12:00". */
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.open24h) };
236
- const tail = f(labels.closesAt, { time: formatTime(s.closesAt, locale) });
237
- return { head: f(s.state === "closing-soon" ? labels.closingSoon : labels.openNow), tail };
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 time = formatTime(s.opensAt.time, locale);
243
- const tail = s.opensAt.dayOffset === 0 ? f(labels.opensTodayAt, { time }) : f(labels.opensAt, { day: weekdayName(s.opensAt.weekday, locale, "short"), time });
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
+ }
@@ -11,7 +11,7 @@ export interface TeamMember {
11
11
  role: React.ReactNode;
12
12
  /** Specialty line in `list` ("Deep tissue · Sports massage"). */
13
13
  specialty?: React.ReactNode;
14
- /** 4:5 portrait. No photo = an empty inset area, never initials. */
14
+ /** 4:5 portrait. No photo = an empty inset area, never initials. `sizes`: grid `min(25vw, 19rem)` / 50vw · rows 56px · feature `min(42vw, 32rem)` / 100vw · compact 160px. */
15
15
  photo?: SiteImage2;
16
16
  bio?: React.ReactNode;
17
17
  /** Up to 3 profile links, rendered as short text links ("LinkedIn"). */
@@ -7,6 +7,7 @@ import { BlockLink } from "../block-link.js";
7
7
  import { site2Text } from "./shared/labels-2.js";
8
8
  import { fieldAttr, textOf, useBlockIds2 } from "./shared/kit-2.js";
9
9
  import { BRAND_NAMES } from "./shared/brand-icons.js";
10
+ import { columnSizes } from "./shared/image-sizes.js";
10
11
  /**
11
12
  * Team and specialist introduction — spa therapists, clinic doctors, an agency team. Card-less
12
13
  * by default (D6-1). Server component; the hover portrait in `list` is CSS only.
@@ -30,7 +31,7 @@ function ProfileLinks({ member, ctx, className }) {
30
31
  if (!member.links?.length)
31
32
  return null;
32
33
  const name = textOf(member.name);
33
- return (_jsx("span", { className: cn("flex flex-wrap gap-x-3 gap-y-1", className), children: member.links.slice(0, 3).map((l, i) => (_jsx(BlockLink, { href: l.href, newTab: true, components: ctx.components, labels: ctx.labels, locale: ctx.locale, "aria-label": name ? ctx.t("profileOf", { name, network: BRAND_NAMES[l.network] }) : undefined, className: "relative z-10 text-small text-fg-muted underline decoration-line underline-offset-[0.25em] transition-colors hover:text-fg hover:decoration-current focus-ring rounded-[2px]", children: BRAND_NAMES[l.network] }, i))) }));
34
+ return (_jsx("span", { className: cn("flex flex-wrap gap-x-3 gap-y-1 pointer-coarse:gap-y-0", className), children: member.links.slice(0, 3).map((l, i) => (_jsx(BlockLink, { href: l.href, newTab: true, components: ctx.components, labels: ctx.labels, locale: ctx.locale, "aria-label": name ? ctx.t("profileOf", { name, network: BRAND_NAMES[l.network] }) : undefined, className: "relative z-10 inline-flex min-h-6 items-center pointer-coarse:min-h-11 text-small text-fg-muted underline decoration-line underline-offset-[0.25em] transition-colors hover:text-fg hover:decoration-current focus-ring rounded-[2px]", children: BRAND_NAMES[l.network] }, i))) }));
34
35
  }
35
36
  function Portrait({ photo, ctx, className, sizes }) {
36
37
  if (!photo?.src)
@@ -38,11 +39,11 @@ function Portrait({ photo, ctx, className, sizes }) {
38
39
  return _jsx(BlockImage, { ...photo, alt: photo.alt ?? "", decorative: !photo.alt, aspect: "4:5", sizes: sizes, className: cn("rounded-ui", className), components: ctx.components });
39
40
  }
40
41
  function PortraitMember({ member, index, ctx }) {
41
- return (_jsxs("li", { className: "flex flex-col gap-4", children: [_jsx(Portrait, { photo: member.photo, ctx: ctx, sizes: "(min-width: 56rem) 25vw, 50vw" }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1", children: [_jsx("h3", { className: "text-h3 font-semibold tracking-[-0.012em] text-fg", ...fieldAttr(ctx.editMode, `members.${index}.name`), children: _jsx(DisplayName, { member: member }) }), _jsx(ProfileLinks, { member: member, ctx: ctx })] }), _jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: member.role }), member.bio ? _jsx("p", { className: "mt-2 text-small text-fg-muted text-pretty", children: member.bio }) : null] })] }));
42
+ return (_jsxs("li", { className: "flex flex-col gap-4", children: [_jsx(Portrait, { photo: member.photo, ctx: ctx, sizes: columnSizes(1 / 4, { narrow: "50vw" }) }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1", children: [_jsx("h3", { className: "text-h3 font-semibold tracking-[-0.012em] text-fg", ...fieldAttr(ctx.editMode, `members.${index}.name`), children: _jsx(DisplayName, { member: member }) }), _jsx(ProfileLinks, { member: member, ctx: ctx })] }), _jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: member.role }), member.bio ? _jsx("p", { className: "mt-2 text-small text-fg-muted text-pretty", children: member.bio }) : null] })] }));
42
43
  }
43
44
  function ListMember({ member, index, ctx }) {
44
45
  return (_jsxs("li", { className: "vb-team-row group/row relative grid grid-cols-[3.5rem_1fr] items-center gap-x-4 gap-y-1 border-t border-line py-6 @2xl:grid-cols-12 @2xl:gap-x-(--gutter)", children: [_jsx("span", { className: "row-span-2 @2xl:hidden", children: _jsx(Portrait, { photo: member.photo, ctx: ctx, className: "w-14 rounded-ui-sm", sizes: "56px" }) }), _jsx("h3", { className: "text-h3 font-semibold tracking-[-0.012em] text-fg @2xl:col-span-4", ...fieldAttr(ctx.editMode, `members.${index}.name`), children: _jsx(DisplayName, { member: member }) }), _jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted @2xl:col-span-3", children: member.role }), _jsxs("div", { className: "col-start-2 flex flex-wrap items-baseline gap-x-6 gap-y-1 @2xl:col-span-5 @2xl:col-start-auto @2xl:justify-between", children: [member.specialty ? _jsx("p", { className: "text-small text-fg-muted", children: member.specialty }) : _jsx("span", {}), _jsx(ProfileLinks, { member: member, ctx: ctx })] }), member.photo?.src ? (_jsx("span", { "aria-hidden": true, className: "vb-team-hover pointer-events-none absolute end-[22%] top-1/2 z-10 hidden w-40 -translate-y-1/2 @4xl:block", children: _jsx(BlockImage, { ...member.photo, decorative: true, aspect: "4:5", sizes: "160px", className: "rounded-ui", components: ctx.components }) })) : null] }));
45
46
  }
46
47
  function FeatureMember({ member, header, ctx }) {
47
- return (_jsxs("div", { className: "grid gap-x-(--gutter) gap-y-10 @4xl:grid-cols-12 @4xl:items-start", children: [_jsx("div", { className: "@4xl:col-span-5", children: _jsx(Portrait, { photo: member.photo, ctx: ctx, sizes: "(min-width: 56rem) 40vw, 100vw" }) }), _jsxs("div", { className: "flex flex-col gap-8 @4xl:col-span-6 @4xl:col-start-7 @4xl:pt-6", children: [header, member.bio ? _jsx("div", { className: "text-lead text-fg-muted text-pretty [&_p+p]:mt-4", children: typeof member.bio === "string" ? member.bio.split(/\n{2,}/).map((p, i) => _jsx("p", { children: p }, i)) : member.bio }) : null, _jsxs("div", { className: "flex flex-col gap-2 border-t border-line pt-6", children: [_jsx("p", { className: "vb-signature font-display text-display-m italic text-fg", ...fieldAttr(ctx.editMode, "members.0.name"), children: _jsx(DisplayName, { member: member }) }), _jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: member.role }), _jsx(ProfileLinks, { member: member, ctx: ctx, className: "mt-2" })] })] })] }));
48
+ return (_jsxs("div", { className: "grid gap-x-(--gutter) gap-y-10 @4xl:grid-cols-12 @4xl:items-start", children: [_jsx("div", { className: "@4xl:col-span-5", children: _jsx(Portrait, { photo: member.photo, ctx: ctx, sizes: columnSizes(5 / 12) }) }), _jsxs("div", { className: "flex flex-col gap-8 @4xl:col-span-6 @4xl:col-start-7 @4xl:pt-6", children: [header, member.bio ? _jsx("div", { className: "text-lead text-fg-muted text-pretty [&_p+p]:mt-4", children: typeof member.bio === "string" ? member.bio.split(/\n{2,}/).map((p, i) => _jsx("p", { children: p }, i)) : member.bio }) : null, _jsxs("div", { className: "flex flex-col gap-2 border-t border-line pt-6", children: [_jsx("p", { className: "vb-signature font-display text-display-m italic text-fg", ...fieldAttr(ctx.editMode, "members.0.name"), children: _jsx(DisplayName, { member: member }) }), _jsx("p", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: member.role }), _jsx(ProfileLinks, { member: member, ctx: ctx, className: "mt-2" })] })] })] }));
48
49
  }
@@ -10,9 +10,9 @@ export interface TestimonialItem {
10
10
  author: React.ReactNode;
11
11
  role?: React.ReactNode;
12
12
  company?: React.ReactNode;
13
- /** Small square portrait. No avatar = name only (never an initials circle). */
13
+ /** Small round portrait, 40px (`sizes="40px"`). No avatar = name only (never an initials circle). */
14
14
  avatar?: SiteImage2;
15
- /** Company logo (feature variant). */
15
+ /** Company logo (feature variant), 6rem wide (`sizes="6rem"`). */
16
16
  logo?: SiteImage2;
17
17
  /** 1–5. Rendered only with a `source` (D6-8). */
18
18
  rating?: number;
@@ -42,14 +42,14 @@ export function RatingStars({ rating, label, size = "md", className }) {
42
42
  }
43
43
  function Attribution({ item, ctx, align = "start", as: Comp = "figcaption" }) {
44
44
  const meta = [item.role, item.company].filter(Boolean);
45
- return (_jsxs(Comp, { className: cn("flex items-center gap-3", align === "between" && "justify-between"), children: [_jsxs("span", { className: "flex items-center gap-3", children: [item.avatar ? (_jsx(BlockImage, { ...item.avatar, alt: item.avatar.alt ?? "", decorative: !item.avatar.alt, aspect: "1:1", sizes: "40px", className: "size-10 shrink-0 rounded-full", components: ctx.components })) : null, _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm font-medium text-fg", children: item.author }), meta.length ? (_jsx("span", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: meta.map((m, i) => (_jsxs(React.Fragment, { children: [i > 0 ? " · " : null, m] }, i))) })) : null] })] }), align === "between" && item.logo ? (_jsx(BlockImage, { ...item.logo, alt: item.logo.alt ?? "", decorative: !item.logo.alt, fit: "contain", className: "h-7 w-24 bg-transparent opacity-80", imgClassName: "vb-logo-mono", components: ctx.components })) : null] }));
45
+ return (_jsxs(Comp, { className: cn("flex items-center gap-3", align === "between" && "justify-between"), children: [_jsxs("span", { className: "flex items-center gap-3", children: [item.avatar ? (_jsx(BlockImage, { ...item.avatar, alt: item.avatar.alt ?? "", decorative: !item.avatar.alt, aspect: "1:1", sizes: "40px", className: "size-10 shrink-0 rounded-full", components: ctx.components })) : null, _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm font-medium text-fg", children: item.author }), meta.length ? (_jsx("span", { className: "font-mono text-eyebrow uppercase tracking-[0.06em] text-fg-muted", children: meta.map((m, i) => (_jsxs(React.Fragment, { children: [i > 0 ? " · " : null, m] }, i))) })) : null] })] }), align === "between" && item.logo ? (_jsx(BlockImage, { ...item.logo, alt: item.logo.alt ?? "", decorative: !item.logo.alt, fit: "contain", sizes: "6rem", className: "h-7 w-24 bg-transparent opacity-80", imgClassName: "vb-logo-mono", components: ctx.components })) : null] }));
46
46
  }
47
47
  function SourceLine({ item, ctx }) {
48
48
  const showRating = item.rating !== undefined && item.source;
49
49
  const name = item.source ? REVIEW_SOURCE_NAMES[item.source] : "";
50
50
  if (!showRating && !item.date && !name)
51
51
  return null;
52
- return (_jsxs("p", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-fg-muted", children: [showRating ? _jsx(RatingStars, { rating: item.rating, label: ctx.t("ratingOutOf", { rating: item.rating }) }) : null, name ? (item.link ? (_jsx(BlockLink, { href: item.link.href, newTab: item.link.newTab ?? true, components: ctx.components, labels: ctx.labels, locale: ctx.locale, className: "underline decoration-line underline-offset-[0.25em] hover:decoration-current focus-ring rounded-[2px]", children: name })) : (_jsx("span", { children: name }))) : null, item.date ? _jsx("time", { dateTime: isoDay(item.date), children: formatDate2(isoDay(item.date), ctx.locale, "medium") }) : null] }));
52
+ return (_jsxs("p", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-fg-muted", children: [showRating ? _jsx(RatingStars, { rating: item.rating, label: ctx.t("ratingOutOf", { rating: item.rating }) }) : null, name ? (item.link ? (_jsx(BlockLink, { href: item.link.href, newTab: item.link.newTab ?? true, components: ctx.components, labels: ctx.labels, locale: ctx.locale, className: "inline-flex min-h-6 items-center underline decoration-line underline-offset-[0.25em] hover:decoration-current focus-ring rounded-[2px]", children: name })) : (_jsx("span", { children: name }))) : null, item.date ? _jsx("time", { dateTime: isoDay(item.date), children: formatDate2(isoDay(item.date), ctx.locale, "medium") }) : null] }));
53
53
  }
54
54
  function QuoteFigure({ item, index, ctx, size = "s" }) {
55
55
  return (_jsxs("figure", { className: "flex flex-col gap-5", children: [_jsx(SourceLine, { item: item, ctx: ctx }), _jsx("blockquote", { className: cn("text-fg text-pretty", size === "m" ? "text-lead" : "text-body"), ...fieldAttr(ctx.editMode, `items.${index}.quote`), children: _jsxs("p", { children: [_jsx("span", { "aria-hidden": true, children: ctx.t("quoteOpen") }), item.quote, _jsx("span", { "aria-hidden": true, children: ctx.t("quoteClose") })] }) }), _jsx(Attribution, { item: item, ctx: ctx })] }));
@@ -62,5 +62,5 @@ function ReviewsLayout({ header, items, summary, ctx }) {
62
62
  const sourceName = summary ? REVIEW_SOURCE_NAMES[summary.source] : "";
63
63
  return (_jsxs("div", { className: "grid gap-x-(--gutter) gap-y-12 @4xl:grid-cols-12", children: [_jsxs("div", { className: "flex flex-col gap-8 @4xl:sticky @4xl:top-[calc(var(--topbar-h,0px)+2rem)] @4xl:col-span-4 @4xl:self-start", children: [header, summary ? (_jsxs("div", { className: "flex flex-col gap-3 border-t border-line pt-6", children: [_jsxs("p", { className: "flex items-baseline gap-3", children: [_jsx("span", { className: "font-display text-display-l tabular text-fg", children: nf.number(summary.rating, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) }), _jsx(RatingStars, { rating: summary.rating, label: ctx.t("ratingOutOf", { rating: nf.number(summary.rating, { maximumFractionDigits: 1 }) }) })] }), _jsxs("p", { className: "text-small text-fg-muted", children: [sourceName
64
64
  ? ctx.t("ratingSummary", { rating: nf.number(summary.rating, { maximumFractionDigits: 1 }), source: sourceName })
65
- : ctx.t("ratingSummaryDirect", { rating: nf.number(summary.rating, { maximumFractionDigits: 1 }) }), " ", "\u2014 ", ctx.t("reviewCount", { count: summary.count })] }), summary.link ? (_jsx(BlockLink, { href: summary.link.href, newTab: summary.link.newTab ?? true, appearance: "link", components: ctx.components, labels: ctx.labels, locale: ctx.locale, className: "self-start text-small", children: summary.link.label ?? ctx.t("readAllReviews") })) : null] })) : null] }), _jsx("ol", { className: "flex flex-col @4xl:col-span-7 @4xl:col-start-6", children: items.map((item, i) => (_jsx("li", { className: "border-t border-line py-7 first:border-t-0 first:pt-0 @4xl:first:border-t @4xl:first:pt-7", children: _jsx(QuoteFigure, { item: item, index: i, ctx: ctx }) }, i))) })] }));
65
+ : ctx.t("ratingSummaryDirect", { rating: nf.number(summary.rating, { maximumFractionDigits: 1 }) }), " ", "\u2014 ", ctx.t("reviewCount", { count: summary.count })] }), summary.link ? (_jsx(BlockLink, { href: summary.link.href, newTab: summary.link.newTab ?? true, appearance: "link", components: ctx.components, labels: ctx.labels, locale: ctx.locale, className: "inline-flex min-h-6 items-center self-start text-small", children: summary.link.label ?? ctx.t("readAllReviews") })) : null] })) : null] }), _jsx("ol", { className: "flex flex-col @4xl:col-span-7 @4xl:col-start-6", children: items.map((item, i) => (_jsx("li", { className: "border-t border-line py-7 first:border-t-0 first:pt-0 @4xl:first:border-t @4xl:first:pt-7", children: _jsx(QuoteFigure, { item: item, index: i, ctx: ctx }) }, i))) })] }));
66
66
  }
@@ -14,7 +14,7 @@ import { estimateAxisWidth, extent, formatValue, seriesColor } from "./utils.js"
14
14
  * ```
15
15
  */
16
16
  export function BarChart({ series, layout = "vertical", stack = "none", radius = 4, highlight, valueLabels, barSize = "auto", reference, ...props }) {
17
- return (_jsx(Chart, { skeleton: layout === "horizontal" ? "bar-horizontal" : "bar", ...props, children: _jsx(BarPlot, { series: series, layout: layout, stack: stack, radius: radius, highlight: highlight, valueLabels: valueLabels, barSize: barSize, reference: reference }) }));
17
+ return (_jsx(Chart, { skeleton: layout === "horizontal" ? "bar-horizontal" : "bar", barKeys: series ?? Object.keys(props.config), ...props, children: _jsx(BarPlot, { series: series, layout: layout, stack: stack, radius: radius, highlight: highlight, valueLabels: valueLabels, barSize: barSize, reference: reference }) }));
18
18
  }
19
19
  function BarPlot({ series: keys, layout, stack, radius = 4, highlight, valueLabels, barSize, reference }) {
20
20
  const chart = useChart();
@@ -59,6 +59,8 @@ export interface ChartContextValue {
59
59
  onPointClick?: (row: Row, seriesKey: string) => void;
60
60
  height: number | undefined;
61
61
  idPrefix: string;
62
+ /** Series drawn as bars: no stroke pattern, so their legend and tooltip swatch is a solid square. */
63
+ isBar: (key: string) => boolean;
62
64
  }
63
65
  /** Chart context: config, formatters and hidden series. Use inside `<Chart>`. */
64
66
  export declare function useChart(): ChartContextValue;
@@ -100,6 +102,11 @@ export interface ChartProps<T extends object = Row> extends Omit<React.HTMLAttri
100
102
  labels?: Partial<ChartLabels>;
101
103
  /** Replace the generated data table. */
102
104
  table?: ChartTableSpec;
105
+ /**
106
+ * Series drawn as bars. Bars carry no stroke pattern, so their legend and tooltip swatch is a
107
+ * solid square even when the other series get dashed/dotted lines. Bar and Combo charts set it.
108
+ */
109
+ barKeys?: string[];
103
110
  /** Content beside (or under) the plot — e.g. the Donut legend list. */
104
111
  aside?: React.ReactNode;
105
112
  asidePosition?: "side" | "bottom";
@@ -127,8 +134,8 @@ export interface ChartLegendContentProps extends React.HTMLAttributes<HTMLDivEle
127
134
  keys?: string[];
128
135
  /** Click toggles visibility. Default `true`. */
129
136
  interactive?: boolean;
130
- /** Swatch shape. Default: a line for line/area patterns, a dot otherwise. */
131
- swatch?: "dot" | "line";
137
+ /** Swatch shape. Default: a solid square for bar series, a line for line/area patterns, a dot otherwise. */
138
+ swatch?: "dot" | "line" | "square";
132
139
  }
133
140
  /** The legend: one toggle per series (`aria-pressed`). At least one series always stays visible. */
134
141
  export declare function ChartLegendContent({ keys, interactive, swatch, className, ...props }: ChartLegendContentProps): React.JSX.Element;
@@ -52,7 +52,7 @@ const capitalise = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
52
52
  * ```
53
53
  */
54
54
  export function Chart(props) {
55
- const { data: dataProp, config, x, xLabel, xFormat, yFormat, height, aspect, state: stateProp, title, summary, tableToggle, legend, tooltipIndicator = "dot", animate = true, onPointClick, hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, labels, table, aside, asidePosition = "side", skeleton = "area", toolbar, className, style, children, ...rest } = props;
55
+ const { data: dataProp, config, x, xLabel, xFormat, yFormat, height, aspect, state: stateProp, title, summary, tableToggle, legend, tooltipIndicator = "dot", animate = true, onPointClick, hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, labels, table, aside, asidePosition = "side", skeleton = "area", toolbar, barKeys, className, style, children, ...rest } = props;
56
56
  const data = dataProp;
57
57
  const f = useFormat();
58
58
  const t = useLabels("chart", chartLabels, labels);
@@ -79,6 +79,9 @@ export function Chart(props) {
79
79
  const status = stateProp ?? (data.length === 0 ? { status: "empty" } : { status: "ready" });
80
80
  const [showTable, setShowTable] = React.useState(false);
81
81
  const seriesFormat = React.useCallback((key) => config[key]?.format, [config]);
82
+ const barKeySet = barKeys?.join("\u0000") ?? "";
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ const isBar = React.useCallback((key) => (barKeys ?? []).includes(key), [barKeySet]);
82
85
  const ctx = React.useMemo(() => ({
83
86
  config,
84
87
  series,
@@ -99,7 +102,8 @@ export function Chart(props) {
99
102
  onPointClick: onPointClick,
100
103
  height: height === "auto" ? undefined : typeof height === "string" ? CHART_HEIGHTS[height] : height,
101
104
  idPrefix,
102
- }), [config, series, visibleSeries, hiddenSet, toggleSeries, data, x, xFormat, yFormat, f, t, animate, reduced, tooltipIndicator, onPointClick, height, idPrefix, seriesFormat]);
105
+ isBar,
106
+ }), [config, series, visibleSeries, hiddenSet, toggleSeries, data, x, xFormat, yFormat, f, t, animate, reduced, tooltipIndicator, onPointClick, height, idPrefix, seriesFormat, isBar]);
103
107
  const vars = React.useMemo(() => {
104
108
  const out = {};
105
109
  for (const s of series)
@@ -149,19 +153,21 @@ export function Chart(props) {
149
153
  }
150
154
  /** The legend: one toggle per series (`aria-pressed`). At least one series always stays visible. */
151
155
  export function ChartLegendContent({ keys, interactive = true, swatch, className, ...props }) {
152
- const { series, isHidden, toggleSeries, visibleSeries, t } = useChart();
156
+ const { series, isHidden, toggleSeries, visibleSeries, t, isBar } = useChart();
153
157
  const items = keys ? series.filter((s) => keys.includes(s.key)) : series;
154
158
  return (_jsx("div", { role: "group", "aria-label": t("legend"), "data-slot": "chart-legend", className: cn("flex flex-wrap items-center gap-x-1 gap-y-1", className), ...props, children: items.map((s) => {
155
159
  const hidden = isHidden(s.key);
156
160
  const locked = !hidden && visibleSeries.length <= 1;
157
161
  const Icon = s.icon;
158
- const content = (_jsxs(_Fragment, { children: [Icon ? _jsx(Icon, { className: "size-3.5" }) : _jsx(LegendSwatch, { color: seriesColor(s.key), pattern: s.pattern, shape: swatch ?? (s.pattern !== "solid" ? "line" : "dot") }), _jsx("span", { className: cn(hidden && "line-through decoration-fg-subtle"), children: s.label })] }));
162
+ const content = (_jsxs(_Fragment, { children: [Icon ? _jsx(Icon, { className: "size-3.5" }) : _jsx(LegendSwatch, { color: seriesColor(s.key), pattern: s.pattern, shape: swatch ?? (isBar(s.key) ? "square" : s.pattern !== "solid" ? "line" : "dot") }), _jsx("span", { className: cn(hidden && "line-through decoration-fg-subtle"), children: s.label })] }));
159
163
  if (!interactive)
160
164
  return (_jsx("span", { className: "inline-flex items-center gap-1.5 px-1.5 text-[12.5px] text-fg-muted", children: content }, s.key));
161
165
  return (_jsx("button", { type: "button", "aria-pressed": !hidden, "aria-disabled": locked || undefined, onClick: () => toggleSeries(s.key), className: cn("inline-flex h-7 items-center gap-1.5 rounded-ui-sm px-2 text-[12.5px] text-fg-muted transition-[color,background-color,opacity] focus-ring hover:bg-hover hover:text-fg", hidden && "opacity-55", locked && "cursor-default hover:bg-transparent"), children: content }, s.key));
162
166
  }) }));
163
167
  }
164
168
  function LegendSwatch({ color, pattern, shape }) {
169
+ if (shape === "square")
170
+ return _jsx("span", { "aria-hidden": true, "data-swatch": "square", className: "size-2.5 shrink-0 rounded-[2px]", style: { background: color } });
165
171
  if (shape === "line")
166
172
  return (_jsx("svg", { width: "14", height: "8", viewBox: "0 0 14 8", "aria-hidden": true, className: "shrink-0", children: _jsx("line", { x1: "1", y1: "4", x2: "13", y2: "4", stroke: color, strokeWidth: "2", strokeLinecap: "round", strokeDasharray: pattern === "dashed" ? "4 3" : pattern === "dotted" ? "0.5 3" : undefined }) }));
167
173
  return _jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-[3px]", style: { background: color } });
@@ -35,7 +35,7 @@ export function ChartTooltipContent({ active, payload, label, indicator, showTot
35
35
  return (_jsxs("div", { "data-slot": "chart-tooltip", className: cn("vui-chart-tooltip min-w-36 rounded-ui-sm px-2.5 py-2 text-[12px]", className), children: [header ? _jsx("div", { className: "mb-1.5 font-medium text-fg", children: header }) : null, _jsxs("div", { className: "grid gap-1", children: [rows.map((p) => {
36
36
  const key = String(p.dataKey);
37
37
  const s = chart.series.find((x) => x.key === key);
38
- return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ChartIndicator, { color: seriesColor(key), variant: ind, pattern: s?.pattern }), _jsx("span", { className: "me-auto text-fg-muted", children: s?.label ?? String(p.name ?? key) }), _jsx("span", { className: "font-mono text-fg tabular", children: chart.formatSeries(key, raw[key] ?? p.value) }), showShare && total ? _jsx("span", { className: "w-11 text-end font-mono text-fg-subtle tabular", children: chart.f.percent(numeric(key) / total) }) : null] }, key));
38
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ChartIndicator, { color: seriesColor(key), variant: chart.isBar(key) ? "dot" : ind, pattern: chart.isBar(key) ? undefined : s?.pattern }), _jsx("span", { className: "me-auto text-fg-muted", children: s?.label ?? String(p.name ?? key) }), _jsx("span", { className: "font-mono text-fg tabular", children: chart.formatSeries(key, raw[key] ?? p.value) }), showShare && total ? _jsx("span", { className: "w-11 text-end font-mono text-fg-subtle tabular", children: chart.f.percent(numeric(key) / total) }) : null] }, key));
39
39
  }), showTotal && rows.length > 1 ? (_jsxs("div", { className: "mt-0.5 flex items-center gap-2 border-t border-line pt-1.5", children: [_jsx("span", { className: "me-auto text-fg-muted", children: chart.t("total") }), _jsx("span", { className: "font-mono font-medium text-fg tabular", children: chart.formatSeries(String(rows[0]?.dataKey), total) })] })) : null] })] }));
40
40
  }
41
41
  /** Recharts Tooltip wired to `ChartTooltipContent` and the themed cursor. */
@@ -5,7 +5,7 @@ import { Area, Bar, ComposedChart, Line } from "recharts";
5
5
  import { useLocale } from "../lib/locale.js";
6
6
  import { Chart, useChart } from "./chart-shell.js";
7
7
  import { ChartFrame, ChartGrid, ChartTooltip, ChartXAxis, ChartYAxis, useValueAxisWidth } from "./chart.js";
8
- import { alignZeroDomains, extent, patternDash, seriesColor } from "./utils.js";
8
+ import { alignedAxes, extent, patternDash, seriesColor } from "./utils.js";
9
9
  /**
10
10
  * Volume and rate on two axes — bars on the start axis, a line on the end axis, zero lines aligned
11
11
  * so neither scale exaggerates. Axis sides are logical and swap in RTL.
@@ -15,7 +15,7 @@ import { alignZeroDomains, extent, patternDash, seriesColor } from "./utils.js";
15
15
  * ```
16
16
  */
17
17
  export function ComboChart({ bars, lines, areas = [], endAxisFormat, endAxis = true, stackBars, startAxisLabel, endAxisLabel, ...props }) {
18
- return (_jsx(Chart, { skeleton: "bar", ...props, children: _jsx(ComboPlot, { bars: bars, lines: lines, areas: areas, endAxisFormat: endAxisFormat, endAxis: endAxis, stackBars: stackBars, startAxisLabel: startAxisLabel, endAxisLabel: endAxisLabel }) }));
18
+ return (_jsx(Chart, { skeleton: "bar", barKeys: bars, ...props, children: _jsx(ComboPlot, { bars: bars, lines: lines, areas: areas, endAxisFormat: endAxisFormat, endAxis: endAxis, stackBars: stackBars, startAxisLabel: startAxisLabel, endAxisLabel: endAxisLabel }) }));
19
19
  }
20
20
  function ComboPlot({ bars, lines, areas = [], endAxisFormat, endAxis, stackBars, startAxisLabel, endAxisLabel }) {
21
21
  const chart = useChart();
@@ -27,20 +27,18 @@ function ComboPlot({ bars, lines, areas = [], endAxisFormat, endAxis, stackBars,
27
27
  const startStacked = Boolean(stackBars);
28
28
  const endFormat = endAxisFormat ?? chart.config[endKeys[0] ?? ""]?.format;
29
29
  const startFormat = chart.config[startKeys[0] ?? ""]?.format ?? chart.yFormat;
30
- const [startDomain, endDomain] = React.useMemo(() => {
30
+ const { domains: [startDomain, endDomain], ticks: [startTicks, endTicks] } = React.useMemo(() => {
31
31
  const a = extent(chart.data, startKeys, startStacked);
32
32
  const b = extent(chart.data, endKeys);
33
- return alignZeroDomains(a, b);
33
+ return alignedAxes(a, b);
34
34
  // eslint-disable-next-line react-hooks/exhaustive-deps
35
35
  }, [chart.data, startKeys.join(), endKeys.join(), startStacked]);
36
- // Non-negative data: four equal steps on both axes, so the grid lines serve both scales.
37
- const ticksOf = (d) => (d[0] === 0 ? [0, 1, 2, 3, 4].map((i) => Number(((d[1] * i) / 4).toPrecision(12))) : undefined);
38
36
  const startWidth = useValueAxisWidth(startKeys, { stacked: startStacked, format: startFormat });
39
37
  const endWidth = useValueAxisWidth(endKeys, { format: endFormat });
40
38
  const left = rtl ? "end" : "start";
41
39
  const yId = (key, fallback) => axisOf(key, fallback);
42
40
  const titleProps = (value, side) => value ? { value, angle: -90, position: side === "left" ? "insideLeft" : "insideRight", className: "vui-chart-axis-title", offset: 0, style: { textAnchor: "middle" } } : undefined;
43
- return (_jsx(ChartFrame, { children: _jsxs(ComposedChart, { data: chart.data, stackOffset: startStacked && startDomain[0] < 0 ? "sign" : undefined, margin: { top: 8, right: 4, bottom: 0, left: 4 }, barCategoryGap: "30%", children: [_jsx(ChartGrid, { yAxisId: "start" }), _jsx(ChartXAxis, {}), _jsx(ChartYAxis, { yAxisId: "start", orientation: left === "start" ? "left" : "right", domain: startDomain, ticks: ticksOf(startDomain), width: startWidth + (startAxisLabel ? 16 : 0), tickFormatter: (v) => chart.formatTick(v, startFormat), label: titleProps(startAxisLabel, left === "start" ? "left" : "right"), keys: startKeys }), _jsx(ChartYAxis, { yAxisId: "end", orientation: left === "start" ? "right" : "left", domain: endDomain, ticks: ticksOf(endDomain), hide: !endAxis || endKeys.length === 0, width: endWidth + (endAxisLabel ? 16 : 0), tickFormatter: (v) => chart.formatTick(v, endFormat), label: titleProps(endAxisLabel, left === "start" ? "right" : "left"), keys: endKeys }), _jsx(ChartTooltip, { cursor: { fill: "var(--hover)" } }), areas.map((key) => (_jsx(Area, { yAxisId: yId(key, "start"), dataKey: key, name: chart.config[key]?.label, hide: chart.isHidden(key), type: "monotone", stroke: seriesColor(key), strokeWidth: 1.5, fill: `color-mix(in oklch, ${seriesColor(key)} 16%, transparent)`, isAnimationActive: chart.animate }, key))), bars.map((key, i) => (_jsx(Bar, { yAxisId: yId(key, "start"), dataKey: key, name: chart.config[key]?.label, hide: chart.isHidden(key), stackId: stackBars ? "bars" : undefined, fill: seriesColor(key), radius: stackBars && i < bars.length - 1 ? 0 : [4, 4, 0, 0], maxBarSize: 40, isAnimationActive: chart.animate }, key))), lines.map((key) => {
41
+ return (_jsx(ChartFrame, { children: _jsxs(ComposedChart, { data: chart.data, stackOffset: startStacked && startDomain[0] < 0 ? "sign" : undefined, margin: { top: 8, right: 4, bottom: 0, left: 4 }, barCategoryGap: "30%", children: [_jsx(ChartGrid, { yAxisId: "start" }), _jsx(ChartXAxis, {}), _jsx(ChartYAxis, { yAxisId: "start", orientation: left === "start" ? "left" : "right", domain: startDomain, ticks: startTicks, width: startWidth + (startAxisLabel ? 16 : 0), tickFormatter: (v) => chart.formatTick(v, startFormat), label: titleProps(startAxisLabel, left === "start" ? "left" : "right"), keys: startKeys }), _jsx(ChartYAxis, { yAxisId: "end", orientation: left === "start" ? "right" : "left", domain: endDomain, ticks: endTicks, hide: !endAxis || endKeys.length === 0, width: endWidth + (endAxisLabel ? 16 : 0), tickFormatter: (v) => chart.formatTick(v, endFormat), label: titleProps(endAxisLabel, left === "start" ? "right" : "left"), keys: endKeys }), _jsx(ChartTooltip, { cursor: { fill: "var(--hover)" } }), areas.map((key) => (_jsx(Area, { yAxisId: yId(key, "start"), dataKey: key, name: chart.config[key]?.label, hide: chart.isHidden(key), type: "monotone", stroke: seriesColor(key), strokeWidth: 1.5, fill: `color-mix(in oklch, ${seriesColor(key)} 16%, transparent)`, isAnimationActive: chart.animate }, key))), bars.map((key, i) => (_jsx(Bar, { yAxisId: yId(key, "start"), dataKey: key, name: chart.config[key]?.label, hide: chart.isHidden(key), stackId: stackBars ? "bars" : undefined, fill: seriesColor(key), radius: stackBars && i < bars.length - 1 ? 0 : [4, 4, 0, 0], maxBarSize: 40, isAnimationActive: chart.animate }, key))), lines.map((key) => {
44
42
  const s = chart.series.find((x) => x.key === key);
45
43
  return (_jsx(Line, { yAxisId: yId(key, "end"), dataKey: key, name: s?.label, hide: chart.isHidden(key), type: "monotone", stroke: seriesColor(key), strokeWidth: 2, strokeDasharray: patternDash(s?.pattern), dot: { r: 2.5, fill: seriesColor(key), stroke: "var(--chart-tooltip)", strokeWidth: 1.5 }, activeDot: { r: 4, strokeWidth: 2, stroke: "var(--chart-tooltip)", fill: seriesColor(key) }, isAnimationActive: chart.animate }, key));
46
44
  })] }) }));
@@ -100,6 +100,16 @@ export declare function niceStep(v: number): number;
100
100
  * Returns domains with the same negative share, so neither axis exaggerates.
101
101
  */
102
102
  export declare function alignZeroDomains(a: [number, number], b: [number, number]): [[number, number], [number, number]];
103
+ /**
104
+ * Two value axes (Combo) with nice, evenly spaced ticks whose zero lines meet: both axes get the
105
+ * same number of steps below and above zero, each with its own nice step (1, 2, 2.5, 5… × 10ⁿ), so
106
+ * one set of grid lines serves both scales. An axis whose data never goes below zero keeps only its
107
+ * non-negative ticks (its domain still reaches below zero to line up with the other axis).
108
+ */
109
+ export declare function alignedAxes(a: [number, number], b: [number, number]): {
110
+ domains: [[number, number], [number, number]];
111
+ ticks: [number[], number[]];
112
+ };
103
113
  export interface SeriesStats {
104
114
  key: string;
105
115
  min: number;