srcdev-nuxt-components 9.5.6 → 9.6.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/.claude/skills/component-compliance-checklist.md +6 -2
- package/.claude/skills/components/breadcrumb.md +5 -0
- package/.claude/skills/components/display-pill.md +2 -0
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/opening-hours.md +12 -0
- package/.claude/skills/components/price-list.md +10 -1
- package/.claude/skills/components/responsive-header.md +74 -0
- package/.claude/skills/components/service-detail.md +32 -0
- package/.claude/skills/components/service-summary.md +8 -0
- package/.claude/skills/components/services-card-grid.md +5 -3
- package/.claude/skills/components/services-card.md +22 -12
- package/.claude/skills/components/site-header.md +2 -0
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tab-navigation.md +25 -9
- package/.claude/skills/index.md +1 -2
- package/.claude/skills/storybook-add-story.md +14 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +13 -1
- package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
- package/.vscode/srcdev-component-services-card.code-snippets +38 -36
- package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
- package/.vscode/srcdev-component-site-header.code-snippets +3 -1
- package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
- package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
- package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
- package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
- package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
- package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
- package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
- package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
- package/app/components/02.molecules/price-list/PriceList.vue +32 -9
- package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
- package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
- package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
- package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +18 -0
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +56 -18
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +106 -31
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +201 -18
- package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +36 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +131 -6
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
- package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
- package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
- package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-button/InputButton.vue +3 -1
- package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
- package/app/composables/useAnchorScroll.ts +1 -1
- package/app/utils/opening-hours.ts +23 -2
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
- package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
- package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import ServiceDetail from "../ServiceDetail.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import InputButton from "../../../../05.forms/input-button/InputButton.vue";
|
|
3
5
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
4
6
|
import type { Service } from "~/types/types.services";
|
|
7
|
+
import type { MediaCanvas } from "~/types/components";
|
|
5
8
|
|
|
6
9
|
const meta: Meta<typeof ServiceDetail> = {
|
|
7
10
|
title: "Organisms/Services/Service Detail",
|
|
@@ -60,6 +63,9 @@ const meta: Meta<typeof ServiceDetail> = {
|
|
|
60
63
|
relatedServices: { control: "object", description: "Services rendered in the 'You may also like' sidebar list" },
|
|
61
64
|
finalCtaHeading: { control: "text", description: "Heading in the closing full-width CTA banner" },
|
|
62
65
|
finalCtaBody: { control: "text", description: "Body text in the closing full-width CTA banner" },
|
|
66
|
+
pricePrefix: { control: "text", description: "Text before the price in the hero pill; empty shows the price alone" },
|
|
67
|
+
idealForIcon: { control: "text", description: "Icon shown on each ideal-for item" },
|
|
68
|
+
breadcrumbAriaLabel: { control: "text", description: "aria-label on the breadcrumb nav (Breadcrumb's default when unset)" },
|
|
63
69
|
styleClassPassthrough: { control: "object", description: "Additional CSS classes applied to the root element" },
|
|
64
70
|
},
|
|
65
71
|
args: {
|
|
@@ -70,15 +76,11 @@ const meta: Meta<typeof ServiceDetail> = {
|
|
|
70
76
|
heroContentVariant: "content",
|
|
71
77
|
bodyVariant: "content",
|
|
72
78
|
finalCtaVariant: "content",
|
|
79
|
+
pricePrefix: "From",
|
|
80
|
+
idealForIcon: "mdi:diamond-stone",
|
|
73
81
|
styleClassPassthrough: [],
|
|
74
82
|
},
|
|
75
83
|
parameters: {
|
|
76
|
-
backgrounds: {
|
|
77
|
-
default: "storybook-canvas",
|
|
78
|
-
options: {
|
|
79
|
-
"storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
84
|
docs: {
|
|
83
85
|
description: {
|
|
84
86
|
component:
|
|
@@ -154,13 +156,10 @@ const relatedServices: Service[] = [
|
|
|
154
156
|
// ServiceDetail's own --service-detail-*/--glass-panel-*/--breadcrumb-* tokens (see
|
|
155
157
|
// CONSUMER-STYLING.md) rather than new component defaults.
|
|
156
158
|
const storyTheme = {
|
|
157
|
-
"--glass-panel-bg": "oklch(0.216 0.006 40)",
|
|
158
|
-
"--glass-panel-border-color": "oklch(0.283 0.008 44)",
|
|
159
159
|
"--service-detail-process-divider-colour": "oklch(0.283 0.008 44)",
|
|
160
160
|
"--service-detail-faq-divider-colour": "oklch(0.283 0.008 44)",
|
|
161
161
|
"--service-detail-sidebar-row-divider-colour": "oklch(0.283 0.008 44)",
|
|
162
162
|
"--service-detail-final-cta-divider-colour": "oklch(0.283 0.008 44)",
|
|
163
|
-
"--service-detail-ideal-for-item-border-colour": "oklch(0.283 0.008 44)",
|
|
164
163
|
"--service-detail-process-index-colour": "oklch(0.727 0.089 87)",
|
|
165
164
|
"--service-detail-ideal-for-icon-colour": "oklch(0.727 0.089 87)",
|
|
166
165
|
"--service-detail-hero-pill-border-colour": "oklch(0.727 0.089 87)",
|
|
@@ -304,3 +303,152 @@ export const CustomRelatedServiceLink: Story = {
|
|
|
304
303
|
},
|
|
305
304
|
},
|
|
306
305
|
};
|
|
306
|
+
|
|
307
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
310
|
+
|
|
311
|
+
// Story-only args: each drives a CSS token, not a ServiceDetail prop. See the argTypes entries.
|
|
312
|
+
const lineClampTokens = {
|
|
313
|
+
breadcrumbItemLineClamp: { token: "--breadcrumb-item-line-clamp", label: "Breadcrumb item" },
|
|
314
|
+
heroEyebrowLineClamp: { token: "--service-detail-hero-eyebrow-line-clamp", label: "Hero eyebrow" },
|
|
315
|
+
heroTitleLineClamp: { token: "--service-detail-hero-title-line-clamp", label: "Hero title" },
|
|
316
|
+
sidebarLabelLineClamp: { token: "--service-detail-sidebar-label-line-clamp", label: "Sidebar label" },
|
|
317
|
+
sidebarValueLineClamp: { token: "--service-detail-sidebar-value-line-clamp", label: "Sidebar value" },
|
|
318
|
+
relatedTitleLineClamp: { token: "--service-detail-related-title-line-clamp", label: "Related title" },
|
|
319
|
+
finalCtaHeadingLineClamp: { token: "--service-detail-final-cta-heading-line-clamp", label: "Final CTA heading" },
|
|
320
|
+
finalCtaBodyLineClamp: { token: "--service-detail-final-cta-body-line-clamp", label: "Final CTA body" },
|
|
321
|
+
} as const;
|
|
322
|
+
|
|
323
|
+
type LineClampArg = keyof typeof lineClampTokens;
|
|
324
|
+
|
|
325
|
+
type StressArgs = InstanceType<typeof ServiceDetail>["$props"] & Record<LineClampArg, LineClamp>;
|
|
326
|
+
|
|
327
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
|
|
328
|
+
const longUrl = "https://example.com/a/very/long/path/that/never/breaks/because/it/has/no/spaces/at/all?utm_source=newsletter";
|
|
329
|
+
const germanParagraph = "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ";
|
|
330
|
+
|
|
331
|
+
const stressService: Service = {
|
|
332
|
+
...balayage,
|
|
333
|
+
slug: "stress",
|
|
334
|
+
category: `Kategorie ${longGerman}`,
|
|
335
|
+
subtitle: `Freihändige ${longGerman}`,
|
|
336
|
+
title: `${longGerman} und ${longGerman}`,
|
|
337
|
+
price: "95,00 € zuzüglich Pflegeprodukte nach individueller Absprache mit Ihrer Stylistin",
|
|
338
|
+
duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich ausführlicher Beratung",
|
|
339
|
+
longDescription: germanParagraph.repeat(10),
|
|
340
|
+
heroHeading: [
|
|
341
|
+
{ text: "Warum", styleClass: "normal" },
|
|
342
|
+
{ text: longGerman, styleClass: "accent" },
|
|
343
|
+
],
|
|
344
|
+
whatIsIt: `🌈✨ <b>not bold</b> <script>alert(1)</script> ${longUrl}`,
|
|
345
|
+
process: [
|
|
346
|
+
longGerman,
|
|
347
|
+
"A",
|
|
348
|
+
"وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
|
|
349
|
+
...Array.from({ length: 9 }, (_, index) => `Schritt ${index + 4}: ${germanParagraph}`),
|
|
350
|
+
],
|
|
351
|
+
idealFor: [longGerman, "😀 Emoji first", longUrl, "B", ...Array.from({ length: 8 }, (_, index) => `Filler ${index + 1}`)],
|
|
352
|
+
maintenance: germanParagraph.repeat(3),
|
|
353
|
+
faqs: [
|
|
354
|
+
{ question: `${longGerman}?`, answer: germanParagraph.repeat(4) },
|
|
355
|
+
{ question: "<i>Is this italic?</i>", answer: "" },
|
|
356
|
+
{ question: "", answer: "An answer with no question." },
|
|
357
|
+
],
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const stressRelated: Service[] = [
|
|
361
|
+
{ ...balayage, slug: "related-long", title: `${longGerman} ${longGerman}`, price: "Ab 1.250,00 € pro Sitzung" },
|
|
362
|
+
{ ...balayage, slug: "related-long", title: "Duplicate slug", price: "£0" },
|
|
363
|
+
{ ...balayage, slug: "related-broken", title: "Broken image", image: "https://example.invalid/missing.jpg", price: "" },
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
export const StressTest: StoryObj<StressArgs> = {
|
|
367
|
+
name: "Stress Test (Worst-Case Data)",
|
|
368
|
+
argTypes: Object.fromEntries(
|
|
369
|
+
Object.entries(lineClampTokens).map(([arg, { token, label }]) => [
|
|
370
|
+
arg,
|
|
371
|
+
{
|
|
372
|
+
name: `${label} line clamp`,
|
|
373
|
+
control: "select",
|
|
374
|
+
options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
|
|
375
|
+
description:
|
|
376
|
+
`**Story control, not a prop.** Sets the \`${token}\` CSS token on the component so you can try it here. ` +
|
|
377
|
+
`To use it in an app, set the token in your own CSS, e.g. \`.service-page { ${token}: 2; }\`. ` +
|
|
378
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
379
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
380
|
+
},
|
|
381
|
+
])
|
|
382
|
+
),
|
|
383
|
+
args: {
|
|
384
|
+
tag: "section",
|
|
385
|
+
pricePrefix: "Ab einem Mindestpreis von",
|
|
386
|
+
idealForIcon: "this-icon:does-not-exist",
|
|
387
|
+
location: `Mobil im gesamten Großraum ${longGerman}, nach Vereinbarung auch an Wochenenden`,
|
|
388
|
+
bookingHeading: `Diese ${longGerman} jetzt buchen`,
|
|
389
|
+
finalCtaHeading: `Bereit für Ihre ${longGerman}?`,
|
|
390
|
+
finalCtaBody: germanParagraph.repeat(2),
|
|
391
|
+
...(Object.fromEntries(Object.keys(lineClampTokens).map((arg) => [arg, "none"])) as Record<LineClampArg, LineClamp>),
|
|
392
|
+
},
|
|
393
|
+
render: (args) => ({
|
|
394
|
+
components: { ServiceDetail, InputButton },
|
|
395
|
+
setup() {
|
|
396
|
+
const componentArgs = computed(() =>
|
|
397
|
+
Object.fromEntries(Object.entries(args).filter(([arg]) => !(arg in lineClampTokens)))
|
|
398
|
+
);
|
|
399
|
+
const tokenStyles = computed(() => ({
|
|
400
|
+
...storyTheme,
|
|
401
|
+
...Object.fromEntries(
|
|
402
|
+
Object.entries(lineClampTokens).map(([arg, { token }]) => [token, args[arg as LineClampArg]])
|
|
403
|
+
),
|
|
404
|
+
}));
|
|
405
|
+
return { componentArgs, tokenStyles, stressService, stressRelated };
|
|
406
|
+
},
|
|
407
|
+
template: `
|
|
408
|
+
<ServiceDetail v-bind="componentArgs" :service-data="stressService" :related-services="stressRelated" :style="tokenStyles">
|
|
409
|
+
<template #book-cta>
|
|
410
|
+
<InputButton tag="a" href="/contact" button-text="Jetzt unverbindlich einen Beratungstermin vereinbaren" variant="primary" />
|
|
411
|
+
</template>
|
|
412
|
+
<template #sidebar-note>
|
|
413
|
+
Ein Allergietest ist mindestens 48 Stunden vor jeder Farbbehandlung erforderlich: ${longUrl}
|
|
414
|
+
</template>
|
|
415
|
+
<template #final-cta>
|
|
416
|
+
<InputButton tag="a" href="/contact" button-text="Jetzt buchen" variant="secondary" />
|
|
417
|
+
</template>
|
|
418
|
+
</ServiceDetail>
|
|
419
|
+
`,
|
|
420
|
+
}),
|
|
421
|
+
decorators: [
|
|
422
|
+
(story, context) => ({
|
|
423
|
+
components: { story, CanvasSwitcher },
|
|
424
|
+
setup() {
|
|
425
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
426
|
+
return { canvasName };
|
|
427
|
+
},
|
|
428
|
+
template: `
|
|
429
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
430
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
431
|
+
</div>
|
|
432
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
433
|
+
<story />
|
|
434
|
+
</div>
|
|
435
|
+
`,
|
|
436
|
+
}),
|
|
437
|
+
],
|
|
438
|
+
parameters: {
|
|
439
|
+
layout: "fullscreen",
|
|
440
|
+
initialCanvas: "mobileCanvas",
|
|
441
|
+
docs: {
|
|
442
|
+
description: {
|
|
443
|
+
story:
|
|
444
|
+
"Deliberately hostile data to find breakage: a hero title of two 55-character German compound words (the hero " +
|
|
445
|
+
"grows instead of clipping it), long German breadcrumb, eyebrow, pills, booking copy and location, a German price " +
|
|
446
|
+
"prefix, emoji, HTML-like text (must render as text), an unbroken URL, right-to-left Arabic, 12 process steps and " +
|
|
447
|
+
"12 ideal-for items, FAQs with an empty answer and an empty question, related services with a long title, a " +
|
|
448
|
+
"duplicate slug, a broken image and an empty price, and a broken ideal-for icon name. Check at every canvas width: " +
|
|
449
|
+
"no text runs out of its column, the sidebar values wrap and stay right-aligned, and the body switches to two " +
|
|
450
|
+
"columns from 900px. Try the line clamp controls too (breadcrumb items, hero eyebrow and title, sidebar labels and values, related titles, final CTA heading and body): they set CSS tokens, they are not props. The final CTA button never shrinks or gets pushed out by long copy.",
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
@@ -311,4 +311,62 @@ describe("ServiceDetail", () => {
|
|
|
311
311
|
expect(wrapper.classes()).not.toContain("original");
|
|
312
312
|
expect(wrapper.classes()).toContain("updated");
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
// ─── Worst-case data ───────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
it("leaves out every section whose data is empty, along with its heading", async () => {
|
|
318
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
319
|
+
props: {
|
|
320
|
+
serviceData: {
|
|
321
|
+
...mockService,
|
|
322
|
+
category: "",
|
|
323
|
+
subtitle: "",
|
|
324
|
+
title: "",
|
|
325
|
+
price: "",
|
|
326
|
+
duration: "",
|
|
327
|
+
longDescription: "",
|
|
328
|
+
heroHeading: [{ text: "", styleClass: "normal" }],
|
|
329
|
+
whatIsIt: "",
|
|
330
|
+
process: [],
|
|
331
|
+
idealFor: [],
|
|
332
|
+
maintenance: "",
|
|
333
|
+
faqs: [],
|
|
334
|
+
},
|
|
335
|
+
tag: "section",
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
expect(wrapper.find(".breadcrumb").exists()).toBe(false);
|
|
339
|
+
expect(wrapper.find(".service-detail__hero-pills").exists()).toBe(false);
|
|
340
|
+
expect(wrapper.find(".service-detail__process").exists()).toBe(false);
|
|
341
|
+
expect(wrapper.find(".service-detail__ideal-for").exists()).toBe(false);
|
|
342
|
+
expect(wrapper.find(".service-detail__faqs").exists()).toBe(false);
|
|
343
|
+
expect(wrapper.find(".service-detail__sidebar-row").exists()).toBe(false);
|
|
344
|
+
expect(wrapper.text()).not.toContain("The Process");
|
|
345
|
+
expect(wrapper.text()).not.toContain("Ideal For");
|
|
346
|
+
expect(wrapper.text()).not.toContain("Aftercare & Maintenance");
|
|
347
|
+
expect(wrapper.text()).not.toContain("Frequently Asked Questions");
|
|
348
|
+
expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("uses pricePrefix for the hero price pill", async () => {
|
|
352
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
353
|
+
props: { serviceData: mockService, pricePrefix: "Ab" },
|
|
354
|
+
});
|
|
355
|
+
expect(wrapper.find(".service-detail__hero-pills").text()).toContain(`Ab ${mockService.price}`);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("puts FAQ questions one level below subheadingTag", async () => {
|
|
359
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
360
|
+
props: { serviceData: mockService, subheadingTag: "h3" },
|
|
361
|
+
});
|
|
362
|
+
expect(wrapper.find(".service-detail__faq-question").element.tagName).toBe("H4");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("hides the decorative ideal-for icon and accepts a custom one", async () => {
|
|
366
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
367
|
+
props: { serviceData: mockService, idealForIcon: "mdi:check" },
|
|
368
|
+
});
|
|
369
|
+
const icon = wrapper.find(".service-detail__ideal-for-icon");
|
|
370
|
+
expect(icon.attributes("aria-hidden")).toBe("true");
|
|
371
|
+
});
|
|
314
372
|
});
|
|
@@ -4,7 +4,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
|
|
|
4
4
|
"<div class="service-detail">
|
|
5
5
|
<div class="grid-stack service-detail__hero">
|
|
6
6
|
<div class="grid-stack__layer">
|
|
7
|
-
<div class="page-row full service-detail__hero-image-row"><img data-nuxt-img="" srcset="/_ipx/
|
|
7
|
+
<div class="page-row full service-detail__hero-image-row"><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/balayage.jpg 1x, /_ipx/s_3840x2160/images/balayage.jpg 2x" alt="Balayage" loading="eager" fetchpriority="high" class="service-detail__hero-image" src="/_ipx/s_1920x1080/images/balayage.jpg"></div>
|
|
8
8
|
</div>
|
|
9
9
|
<div class="grid-stack__layer">
|
|
10
10
|
<div class="page-row content service-detail__hero-overlay">
|
|
@@ -18,7 +18,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
|
|
|
18
18
|
</ol>
|
|
19
19
|
</nav>
|
|
20
20
|
<div class="eyebrow-text large">Freehand colour artistry</div>
|
|
21
|
-
<h1 id="v-0-0" class="hero-text
|
|
21
|
+
<h1 id="v-0-0" class="hero-text display axis-horizontal">
|
|
22
22
|
<!--v-if--><span class="text-block-0 normal">Balayage</span>
|
|
23
23
|
</h1>
|
|
24
24
|
<div class="service-detail__hero-pills"><span class="display-pill md neutral"><!--v-if--><span class="display-pill-label">2.5 - 3.5 hours</span></span><span class="display-pill md neutral"><!--v-if--><span class="display-pill-label">From £95</span></span></div>
|
|
@@ -98,11 +98,12 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
|
|
|
98
98
|
<div class="page-row content service-detail__final-cta-row">
|
|
99
99
|
<div class="service-detail__final-cta">
|
|
100
100
|
<div class="service-detail__final-cta-copy">
|
|
101
|
-
<h2 class="hero-text
|
|
101
|
+
<h2 class="hero-text subheading axis-horizontal">
|
|
102
102
|
<!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
|
|
103
103
|
</h2>
|
|
104
104
|
<p class="page-body-normal">Get in touch to book your appointment.</p>
|
|
105
105
|
</div>
|
|
106
|
+
<!--v-if-->
|
|
106
107
|
</div>
|
|
107
108
|
</div>
|
|
108
109
|
</div>"
|
|
@@ -29,6 +29,14 @@ theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough
|
|
|
29
29
|
| `--service-summary-image-border-radius` | `0.8rem` | Corner rounding on the service image |
|
|
30
30
|
| `--service-summary-pills-gap` | `0.8rem` | Gap between the duration and price pills |
|
|
31
31
|
| `--service-summary-pills-margin-block-end` | `2rem` | Space below the pill row |
|
|
32
|
+
| `--service-summary-column-min-width` | `246px` | Minimum width of each column in the two-column layout (from the 768px container breakpoint), capped at the container width |
|
|
33
|
+
| `--service-summary-body-line-clamp` | `none` | Max lines of the `whatIsIt` summary text, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
|
|
34
|
+
|
|
35
|
+
Long unbroken text wraps in every field. A pill longer than its column is capped at the column width
|
|
36
|
+
and ends in an ellipsis (that is `DisplayPill` behaviour).
|
|
37
|
+
|
|
38
|
+
> Changed 2026-10-06: added `--service-summary-column-min-width` (was a fixed `246px`) and
|
|
39
|
+
> `--service-summary-body-line-clamp`; columns can no longer be widened by unbroken text.
|
|
32
40
|
|
|
33
41
|
All breakpoints above (row height, image padding, and the two-column grid switch) are
|
|
34
42
|
**container queries** against `.service-summary`'s own inline size (`container-name:
|
|
@@ -57,6 +65,22 @@ under `.service-summary__pills` — see [display-pill.md](../../../../../.claude
|
|
|
57
65
|
|
|
58
66
|
---
|
|
59
67
|
|
|
68
|
+
## State hooks
|
|
69
|
+
|
|
70
|
+
| Hook | When |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `.service-summary__grid--reverse` | `reverse` is set: the image column moves after the text column from the 768px container breakpoint |
|
|
73
|
+
| `.service-summary__info-wrapper--align-start` / `-center` / `-end` | The `alignment` prop |
|
|
74
|
+
|
|
75
|
+
Inner classes: `.service-summary__grid`, `.service-summary__image-wrapper`, `.service-summary__image`,
|
|
76
|
+
`.service-summary__info-wrapper`, `.service-summary__pills`. The eyebrow, title and pills are
|
|
77
|
+
`EyebrowText`, `HeroText` and `DisplayPill`; the summary text is a `p.page-body-normal`.
|
|
78
|
+
|
|
79
|
+
Empty `subtitle`, `title`, `whatIsIt`, `duration` or `price` render no element (and no pill row when
|
|
80
|
+
both pill values are empty). With no title, a landmark `tag` gets no `aria-labelledby`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
60
84
|
## Global theming
|
|
61
85
|
|
|
62
86
|
Create `assets/styles/setup/07.components/service-summary.css` in the consuming app and
|
|
@@ -117,6 +141,12 @@ visual style, pass a modifier class:
|
|
|
117
141
|
|
|
118
142
|
---
|
|
119
143
|
|
|
144
|
+
## Class passthrough
|
|
145
|
+
|
|
146
|
+
`style-class-passthrough` adds classes to the root `.service-summary`. Reactive after mount.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
120
150
|
## Notes
|
|
121
151
|
|
|
122
152
|
- `--service-summary-grid-gap-desktop` only applies at `768px` and above — the breakpoint
|
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
<NuxtImg
|
|
6
6
|
:src="serviceData.image"
|
|
7
7
|
:alt="serviceData.title"
|
|
8
|
+
width="1080"
|
|
9
|
+
height="1080"
|
|
8
10
|
:loading="imageLoading"
|
|
9
11
|
:fetchpriority="imageFetchPriority"
|
|
10
12
|
class="service-summary__image"
|
|
11
13
|
/>
|
|
12
14
|
</div>
|
|
13
15
|
<div class="service-summary__info-wrapper" :class="infoWrapperClasses">
|
|
14
|
-
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
16
|
+
<EyebrowText v-if="serviceData.subtitle" font-size="large" :text-content="serviceData.subtitle" />
|
|
15
17
|
<HeroText
|
|
18
|
+
v-if="serviceData.title"
|
|
16
19
|
:id="headingId"
|
|
17
20
|
:tag="headerTag"
|
|
18
21
|
font-size="title"
|
|
@@ -20,12 +23,12 @@
|
|
|
20
23
|
:style-class-passthrough="['mb-20']"
|
|
21
24
|
/>
|
|
22
25
|
|
|
23
|
-
<div class="service-summary__pills">
|
|
24
|
-
<DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
|
|
25
|
-
<DisplayPill
|
|
26
|
+
<div v-if="serviceData.duration || serviceData.price" class="service-summary__pills">
|
|
27
|
+
<DisplayPill v-if="serviceData.duration" :label="serviceData.duration" size="md" variant="neutral" />
|
|
28
|
+
<DisplayPill v-if="serviceData.price" :label="priceLabel" size="md" variant="neutral" />
|
|
26
29
|
</div>
|
|
27
30
|
|
|
28
|
-
<p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
|
|
31
|
+
<p v-if="serviceData.whatIsIt" class="page-body-normal">{{ serviceData.whatIsIt }}</p>
|
|
29
32
|
|
|
30
33
|
<slot name="summary-link" :service-data="serviceData"></slot>
|
|
31
34
|
</div>
|
|
@@ -43,6 +46,8 @@ interface Props {
|
|
|
43
46
|
serviceData: Service;
|
|
44
47
|
alignment?: "start" | "center" | "end";
|
|
45
48
|
reverse?: boolean;
|
|
49
|
+
/** Text before the price in its pill, e.g. "From £95". Override for localisation; "" shows the price alone. */
|
|
50
|
+
pricePrefix?: string;
|
|
46
51
|
styleClassPassthrough?: string | string[];
|
|
47
52
|
}
|
|
48
53
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -51,10 +56,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
51
56
|
index: 0,
|
|
52
57
|
alignment: "center",
|
|
53
58
|
reverse: false,
|
|
59
|
+
pricePrefix: "From",
|
|
54
60
|
styleClassPassthrough: () => [],
|
|
55
61
|
});
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
// No title means no heading to point at, so don't label the landmark from one.
|
|
64
|
+
const { headingId, ariaLabelledby } = useAriaLabelledById(() => (props.serviceData.title ? props.tag : "div"));
|
|
65
|
+
|
|
66
|
+
const priceLabel = computed(() => `${props.pricePrefix} ${props.serviceData.price}`.trim());
|
|
58
67
|
|
|
59
68
|
const infoWrapperClasses = computed(() => ({
|
|
60
69
|
[`service-summary__info-wrapper--align-${props.alignment}`]: true,
|
|
@@ -98,14 +107,17 @@ watch(
|
|
|
98
107
|
--_grid-gap: var(--service-summary-grid-gap, 2rem);
|
|
99
108
|
|
|
100
109
|
display: grid;
|
|
101
|
-
grid-template-columns: 1fr;
|
|
110
|
+
grid-template-columns: minmax(0, 1fr);
|
|
102
111
|
gap: var(--_grid-gap);
|
|
103
112
|
height: 100%;
|
|
104
113
|
|
|
105
114
|
@container service-summary (width >= 768px) {
|
|
106
115
|
--_grid-gap: var(--service-summary-grid-gap-desktop, 3rem);
|
|
107
116
|
|
|
108
|
-
grid-template-columns: repeat(
|
|
117
|
+
grid-template-columns: repeat(
|
|
118
|
+
auto-fit,
|
|
119
|
+
minmax(min(var(--service-summary-column-min-width, 246px), 100%), 1fr)
|
|
120
|
+
);
|
|
109
121
|
|
|
110
122
|
&.service-summary__grid--reverse {
|
|
111
123
|
.service-summary__image-wrapper {
|
|
@@ -117,6 +129,17 @@ watch(
|
|
|
117
129
|
|
|
118
130
|
.service-summary__info-wrapper {
|
|
119
131
|
display: grid;
|
|
132
|
+
grid-template-columns: minmax(0, 1fr);
|
|
133
|
+
min-inline-size: 0;
|
|
134
|
+
overflow-wrap: anywhere;
|
|
135
|
+
|
|
136
|
+
.page-body-normal {
|
|
137
|
+
display: -webkit-box;
|
|
138
|
+
-webkit-box-orient: vertical;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
-webkit-line-clamp: var(--service-summary-body-line-clamp, none);
|
|
141
|
+
line-clamp: var(--service-summary-body-line-clamp, none);
|
|
142
|
+
}
|
|
120
143
|
|
|
121
144
|
&.service-summary__info-wrapper--align-start {
|
|
122
145
|
align-content: start;
|
|
@@ -171,6 +194,7 @@ watch(
|
|
|
171
194
|
display: flex;
|
|
172
195
|
flex-wrap: wrap;
|
|
173
196
|
gap: var(--service-summary-pills-gap, 0.8rem);
|
|
197
|
+
min-inline-size: 0;
|
|
174
198
|
margin-block-end: var(--service-summary-pills-margin-block-end, 2rem);
|
|
175
199
|
|
|
176
200
|
--display-pill-background: var(--service-summary-pill-bg, transparent);
|
package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import ServiceSummary from "../ServiceSummary.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
|
|
3
5
|
import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
|
|
4
6
|
import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
|
|
5
7
|
import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
|
|
6
8
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
7
9
|
import type { Service } from "~/types/types.services";
|
|
10
|
+
import type { MediaCanvas } from "~/types/components";
|
|
8
11
|
|
|
9
12
|
const meta: Meta<typeof ServiceSummary> = {
|
|
10
13
|
title: "Organisms/Services/Service Summary",
|
|
@@ -29,6 +32,10 @@ const meta: Meta<typeof ServiceSummary> = {
|
|
|
29
32
|
control: { type: "boolean" },
|
|
30
33
|
description: "Swap image and content columns",
|
|
31
34
|
},
|
|
35
|
+
pricePrefix: {
|
|
36
|
+
control: { type: "text" },
|
|
37
|
+
description: "Text before the price in its pill; override for localisation, empty shows the price alone",
|
|
38
|
+
},
|
|
32
39
|
styleClassPassthrough: {
|
|
33
40
|
control: "object",
|
|
34
41
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -39,6 +46,7 @@ const meta: Meta<typeof ServiceSummary> = {
|
|
|
39
46
|
headerTag: "h2",
|
|
40
47
|
alignment: "center",
|
|
41
48
|
reverse: false,
|
|
49
|
+
pricePrefix: "From",
|
|
42
50
|
styleClassPassthrough: [],
|
|
43
51
|
},
|
|
44
52
|
parameters: {
|
|
@@ -297,3 +305,142 @@ export const RealisticPageContext: Story = {
|
|
|
297
305
|
},
|
|
298
306
|
},
|
|
299
307
|
};
|
|
308
|
+
|
|
309
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
312
|
+
|
|
313
|
+
// Story-only arg: drives a CSS token, not a ServiceSummary prop. See the argTypes entry.
|
|
314
|
+
type StressArgs = InstanceType<typeof ServiceSummary>["$props"] & { bodyLineClamp: LineClamp };
|
|
315
|
+
|
|
316
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
|
|
317
|
+
|
|
318
|
+
const stressSummaries: Service[] = [
|
|
319
|
+
{
|
|
320
|
+
...sampleService,
|
|
321
|
+
slug: "long-german",
|
|
322
|
+
subtitle: `Freihändige ${longGerman}`,
|
|
323
|
+
title: `${longGerman} und ${longGerman}`,
|
|
324
|
+
duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich ausführlicher Beratung",
|
|
325
|
+
price: "95,00 € zuzüglich Pflegeprodukte nach individueller Absprache mit Ihrer Stylistin",
|
|
326
|
+
whatIsIt: "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(8),
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
...sampleService,
|
|
330
|
+
slug: "emoji-html",
|
|
331
|
+
subtitle: "✨💇♀️ Colour",
|
|
332
|
+
title: "<script>alert('xss')</script>",
|
|
333
|
+
duration: "⏱",
|
|
334
|
+
price: "£∞",
|
|
335
|
+
whatIsIt: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈 <b>not bold</b> https://example.com/a/very/long/url/that/never/breaks/at/all",
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
...sampleService,
|
|
339
|
+
slug: "rtl",
|
|
340
|
+
subtitle: "تلوين الشعر",
|
|
341
|
+
title: "تسريحات شعر جديدة لموسم الخريف",
|
|
342
|
+
duration: "ساعتان",
|
|
343
|
+
price: "١٢٠ جنيه",
|
|
344
|
+
whatIsIt: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
...sampleService,
|
|
348
|
+
slug: "empty",
|
|
349
|
+
image: "https://example.invalid/missing.jpg",
|
|
350
|
+
subtitle: "",
|
|
351
|
+
title: "",
|
|
352
|
+
duration: "",
|
|
353
|
+
price: "",
|
|
354
|
+
whatIsIt: "",
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
...sampleService,
|
|
358
|
+
slug: "single-chars",
|
|
359
|
+
subtitle: "A",
|
|
360
|
+
title: "B",
|
|
361
|
+
duration: "1",
|
|
362
|
+
price: "0",
|
|
363
|
+
whatIsIt: "C",
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
export const StressTest: StoryObj<StressArgs> = {
|
|
368
|
+
name: "Stress Test (Worst-Case Data)",
|
|
369
|
+
argTypes: {
|
|
370
|
+
bodyLineClamp: {
|
|
371
|
+
control: "select",
|
|
372
|
+
options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
|
|
373
|
+
description:
|
|
374
|
+
"**Story control, not a prop.** Sets the `--service-summary-body-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
375
|
+
"To use it in an app, set the token in your own CSS, e.g. `.services-page { --service-summary-body-line-clamp: 4; }`. " +
|
|
376
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
377
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
args: {
|
|
381
|
+
pricePrefix: "Ab einem Mindestpreis von",
|
|
382
|
+
bodyLineClamp: "none",
|
|
383
|
+
},
|
|
384
|
+
render: (args) => ({
|
|
385
|
+
components: { ServiceSummary },
|
|
386
|
+
setup() {
|
|
387
|
+
const componentArgs = computed(() => {
|
|
388
|
+
const { bodyLineClamp: _bodyLineClamp, ...rest } = args;
|
|
389
|
+
return rest;
|
|
390
|
+
});
|
|
391
|
+
const tokenStyles = computed(() => ({ "--service-summary-body-line-clamp": args.bodyLineClamp }));
|
|
392
|
+
return { componentArgs, tokenStyles, stressSummaries };
|
|
393
|
+
},
|
|
394
|
+
template: `
|
|
395
|
+
<div :style="tokenStyles" style="display:grid; gap:4rem;">
|
|
396
|
+
<ServiceSummary
|
|
397
|
+
v-for="(service, index) in stressSummaries"
|
|
398
|
+
:key="service.slug"
|
|
399
|
+
v-bind="componentArgs"
|
|
400
|
+
:index="index"
|
|
401
|
+
:reverse="index % 2 === 1"
|
|
402
|
+
:service-data="service"
|
|
403
|
+
>
|
|
404
|
+
<template #summary-link="{ serviceData }">
|
|
405
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
406
|
+
Mehr über {{ serviceData.title || "diese Behandlung" }} erfahren →
|
|
407
|
+
</a>
|
|
408
|
+
</template>
|
|
409
|
+
</ServiceSummary>
|
|
410
|
+
</div>
|
|
411
|
+
`,
|
|
412
|
+
}),
|
|
413
|
+
decorators: [
|
|
414
|
+
(story, context) => ({
|
|
415
|
+
components: { story, CanvasSwitcher },
|
|
416
|
+
setup() {
|
|
417
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
418
|
+
return { canvasName };
|
|
419
|
+
},
|
|
420
|
+
template: `
|
|
421
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
422
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
423
|
+
</div>
|
|
424
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
425
|
+
<story />
|
|
426
|
+
</div>
|
|
427
|
+
`,
|
|
428
|
+
}),
|
|
429
|
+
],
|
|
430
|
+
parameters: {
|
|
431
|
+
layout: "fullscreen",
|
|
432
|
+
initialCanvas: "mobileCanvas",
|
|
433
|
+
docs: {
|
|
434
|
+
description: {
|
|
435
|
+
story:
|
|
436
|
+
"Deliberately hostile data to find breakage: long German copy and unbroken compound words in every field, a long " +
|
|
437
|
+
"German price prefix, a very long duration and price (their pills cap at the column width and end in an ellipsis), " +
|
|
438
|
+
"emoji, HTML-like text (must render as text), an unbroken URL, right-to-left Arabic, a broken image, single characters, " +
|
|
439
|
+
"and a summary with every text field empty (no empty eyebrow, heading, pills or body is rendered, and a section root " +
|
|
440
|
+
"drops its aria-labelledby). Check at every canvas width, in both column orders: no text runs out of its column, " +
|
|
441
|
+
"the two columns stay balanced and stack on narrow canvases. Try the Body line clamp control too: it's a CSS token " +
|
|
442
|
+
"set by the story, not a prop.",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
};
|
|
@@ -207,4 +207,30 @@ describe("ServiceSummary", () => {
|
|
|
207
207
|
expect(wrapper.classes()).not.toContain("original");
|
|
208
208
|
expect(wrapper.classes()).toContain("updated");
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
// ─── Worst-case data ───────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
it("renders no empty eyebrow, heading, pills or body for empty strings", async () => {
|
|
214
|
+
const wrapper = await mountSuspended(ServiceSummary, {
|
|
215
|
+
props: {
|
|
216
|
+
serviceData: { ...mockService, subtitle: "", title: "", duration: "", price: "", whatIsIt: "" },
|
|
217
|
+
tag: "section",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
expect(wrapper.find(".eyebrow-text").exists()).toBe(false);
|
|
221
|
+
expect(wrapper.find(".hero-text").exists()).toBe(false);
|
|
222
|
+
expect(wrapper.find(".service-summary__pills").exists()).toBe(false);
|
|
223
|
+
expect(wrapper.find("p.page-body-normal").exists()).toBe(false);
|
|
224
|
+
expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("uses pricePrefix for the price pill, and shows the price alone when it is empty", async () => {
|
|
228
|
+
const wrapper = await mountSuspended(ServiceSummary, {
|
|
229
|
+
props: { serviceData: mockService, pricePrefix: "Ab" },
|
|
230
|
+
});
|
|
231
|
+
expect(wrapper.text()).toContain(`Ab ${mockService.price}`);
|
|
232
|
+
await wrapper.setProps({ pricePrefix: "" });
|
|
233
|
+
const labels = wrapper.findAll(".display-pill-label").map((label) => label.element.textContent);
|
|
234
|
+
expect(labels).toContain(mockService.price);
|
|
235
|
+
});
|
|
210
236
|
});
|