srcdev-nuxt-components 9.5.7 → 9.6.1
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 +8 -2
- package/.claude/skills/component-dynamic-slots.md +3 -3
- package/.claude/skills/component-export-types.md +10 -16
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/alert-content.md +9 -1
- package/.claude/skills/components/breadcrumb.md +5 -0
- package/.claude/skills/components/container-glow.md +21 -8
- package/.claude/skills/components/display-avatar.md +13 -7
- package/.claude/skills/components/display-chip.md +37 -24
- package/.claude/skills/components/display-pill.md +58 -8
- package/.claude/skills/components/display-prompt.md +8 -5
- package/.claude/skills/components/display-toast.md +21 -4
- package/.claude/skills/components/glowing-border.md +20 -2
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/responsive-header.md +59 -0
- package/.claude/skills/components/select-menu.md +54 -5
- 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 -2
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -5
- package/.claude/skills/storybook-add-story.md +53 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
- package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
- package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- 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-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
- package/app/assets/styles/setup/03.theming/_status.css +24 -0
- package/app/assets/styles/setup/03.theming/index.css +1 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
- package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
- package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
- package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
- 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/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
- package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
- package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
- package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
- package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
- package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
- package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
- package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
- package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
- package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
- package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
- package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
- package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
- package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
- package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
- package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
- 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 +42 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -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 +5 -4
- 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 +2 -2
- 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 +6 -6
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- 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/composables/useTabs.ts +148 -206
- package/app/types/components/display-chip.d.ts +8 -4
- package/app/types/components/display-toast.d.ts +2 -0
- package/app/types/components/index.ts +0 -1
- package/app/types/components/select-menu.d.ts +2 -0
- package/app/utils/pausableTimeout.ts +41 -0
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.claude/skills/components/tab-navigation.md +0 -272
- package/.claude/skills/components/tabs-core.md +0 -187
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
- 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/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
- package/app/composables/useNavCollapse.ts +0 -86
- package/app/types/components/nav-item.d.ts +0 -11
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import ServicesCard from "../ServicesCard.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
5
|
import type { Service } from "~/types/types.services";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
4
7
|
|
|
5
8
|
const meta: Meta<typeof ServicesCard> = {
|
|
6
9
|
title: "Organisms/Services/Services Card",
|
|
@@ -47,12 +50,6 @@ const meta: Meta<typeof ServicesCard> = {
|
|
|
47
50
|
styleClassPassthrough: [],
|
|
48
51
|
},
|
|
49
52
|
parameters: {
|
|
50
|
-
backgrounds: {
|
|
51
|
-
default: "storybook-canvas",
|
|
52
|
-
options: {
|
|
53
|
-
"storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
53
|
docs: {
|
|
57
54
|
description: {
|
|
58
55
|
component:
|
|
@@ -329,32 +326,32 @@ export const DarkOverlayCards: Story = {
|
|
|
329
326
|
--services-card-border-colour-hover: #6b5a4e;
|
|
330
327
|
--services-card-transform-hover: translateY(-0.4rem);
|
|
331
328
|
|
|
332
|
-
--image-
|
|
333
|
-
--
|
|
334
|
-
--image-
|
|
335
|
-
--image-
|
|
336
|
-
--image-
|
|
337
|
-
--image-
|
|
329
|
+
--services-card-image-border-radius: 0;
|
|
330
|
+
--services-card-image-zoom-transform: none;
|
|
331
|
+
--services-card-image-details-scrim-colour: #141110;
|
|
332
|
+
--services-card-image-details-text-colour: #f3ece4;
|
|
333
|
+
--services-card-image-details-eyebrow-colour: #c9a24a;
|
|
334
|
+
--services-card-image-details-gap: 0.4rem;
|
|
338
335
|
--eyebrow-text-font-family: "Poppins", sans-serif;
|
|
339
336
|
--eyebrow-text-font-style: normal;
|
|
340
337
|
--eyebrow-text-font-weight: 600;
|
|
341
338
|
--eyebrow-text-letter-spacing: 0.25em;
|
|
342
|
-
--image-
|
|
343
|
-
--image-
|
|
344
|
-
--image-
|
|
339
|
+
--services-card-image-details-eyebrow-padding-inline: 3.6rem;
|
|
340
|
+
--services-card-image-details-title-padding-block: 0 2.8rem;
|
|
341
|
+
--services-card-image-details-title-padding-inline: 3.6rem;
|
|
345
342
|
|
|
346
|
-
--details-
|
|
347
|
-
--details-
|
|
348
|
-
--description-text-colour: #a39a94;
|
|
349
|
-
--description-line-height: 1.75;
|
|
350
|
-
--description-font-weight: 300;
|
|
351
|
-
--meta-border-colour: #2e2826;
|
|
352
|
-
--meta-padding-block: 2.4rem 0;
|
|
353
|
-
--meta-text-colour: #8a817b;
|
|
354
|
-
--meta-duration-font-weight: 400;
|
|
355
|
-
--meta-price-font-weight: 400;
|
|
356
|
-
--meta-letter-spacing: 0.15em;
|
|
357
|
-
--meta-price-text-colour: #e8dfd6;
|
|
343
|
+
--services-card-details-padding-block: 3.6rem;
|
|
344
|
+
--services-card-details-padding-inline: 3.6rem;
|
|
345
|
+
--services-card-description-text-colour: #a39a94;
|
|
346
|
+
--services-card-description-line-height: 1.75;
|
|
347
|
+
--services-card-description-font-weight: 300;
|
|
348
|
+
--services-card-meta-border-colour: #2e2826;
|
|
349
|
+
--services-card-meta-padding-block: 2.4rem 0;
|
|
350
|
+
--services-card-meta-text-colour: #8a817b;
|
|
351
|
+
--services-card-meta-duration-font-weight: 400;
|
|
352
|
+
--services-card-meta-price-font-weight: 400;
|
|
353
|
+
--services-card-meta-letter-spacing: 0.15em;
|
|
354
|
+
--services-card-meta-price-text-colour: #e8dfd6;
|
|
358
355
|
}
|
|
359
356
|
</component>
|
|
360
357
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); gap:4rem; padding:2.4rem; background:#121010;">
|
|
@@ -379,3 +376,173 @@ export const DarkOverlayCards: Story = {
|
|
|
379
376
|
},
|
|
380
377
|
},
|
|
381
378
|
};
|
|
379
|
+
|
|
380
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
383
|
+
|
|
384
|
+
// Story-only arg: drives a CSS token, not a ServicesCard prop. See the argTypes entry.
|
|
385
|
+
type StressArgs = InstanceType<typeof ServicesCard>["$props"] & { descriptionLineClamp: LineClamp };
|
|
386
|
+
|
|
387
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarungsbestätigungsschreiben";
|
|
388
|
+
const longUrl =
|
|
389
|
+
"https://example.com/services/a-very-long-path-with-no-spaces-at-all?utm_source=newsletter&utm_campaign=autumn-2026";
|
|
390
|
+
|
|
391
|
+
const stressService = (overrides: Partial<Service>): Service => ({ ...balayageService, ...overrides });
|
|
392
|
+
|
|
393
|
+
const stressServices: { service: Service; titlesWithinImageWrapper?: boolean; href?: string; action?: string }[] = [
|
|
394
|
+
{
|
|
395
|
+
service: stressService({
|
|
396
|
+
slug: "long-german",
|
|
397
|
+
subtitle: `Freihändige ${longGerman}`,
|
|
398
|
+
title: longGerman,
|
|
399
|
+
shortDescription:
|
|
400
|
+
"Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(6),
|
|
401
|
+
duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich Beratung",
|
|
402
|
+
price: "Ab 95,00 € zuzüglich Pflegeprodukte nach individueller Absprache",
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
service: stressService({
|
|
407
|
+
slug: "emoji-html",
|
|
408
|
+
subtitle: "✨💇♀️ Colour",
|
|
409
|
+
title: "<script>alert('xss')</script>",
|
|
410
|
+
shortDescription: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈 <b>not bold</b>",
|
|
411
|
+
duration: "⏱",
|
|
412
|
+
price: "£∞",
|
|
413
|
+
}),
|
|
414
|
+
href: longUrl,
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
service: stressService({
|
|
418
|
+
slug: "rtl",
|
|
419
|
+
subtitle: "تلوين الشعر",
|
|
420
|
+
title: "تسريحات شعر جديدة لموسم الخريف",
|
|
421
|
+
shortDescription: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
|
|
422
|
+
duration: "ساعتان",
|
|
423
|
+
price: "١٢٠ جنيه",
|
|
424
|
+
}),
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
service: stressService({
|
|
428
|
+
slug: "empty",
|
|
429
|
+
subtitle: "",
|
|
430
|
+
title: "",
|
|
431
|
+
shortDescription: "",
|
|
432
|
+
duration: "",
|
|
433
|
+
price: "",
|
|
434
|
+
}),
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
service: stressService({
|
|
438
|
+
slug: "broken-image",
|
|
439
|
+
image: "https://example.invalid/missing.jpg",
|
|
440
|
+
subtitle: "A",
|
|
441
|
+
title: "B",
|
|
442
|
+
shortDescription: "C",
|
|
443
|
+
duration: "1",
|
|
444
|
+
price: "£0",
|
|
445
|
+
}),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
service: stressService({
|
|
449
|
+
slug: "overlay-long",
|
|
450
|
+
image: "https://picsum.photos/seed/stress-overlay/600/800",
|
|
451
|
+
subtitle: longUrl,
|
|
452
|
+
title: `${longGerman} ${longGerman}`,
|
|
453
|
+
shortDescription: longUrl,
|
|
454
|
+
}),
|
|
455
|
+
titlesWithinImageWrapper: true,
|
|
456
|
+
href: "#overlay-long",
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
service: stressService({ slug: "long-action", title: "Long button label" }),
|
|
460
|
+
action: `Jetzt ${longGerman} buchen`,
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
export const StressTest: StoryObj<StressArgs> = {
|
|
465
|
+
name: "Stress Test (Worst-Case Data)",
|
|
466
|
+
argTypes: {
|
|
467
|
+
descriptionLineClamp: {
|
|
468
|
+
control: "select",
|
|
469
|
+
options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
|
|
470
|
+
description:
|
|
471
|
+
"**Story control, not a prop.** Sets the `--services-card-description-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
472
|
+
"To use it in an app, set the token in your own CSS, e.g. `.services-list { --services-card-description-line-clamp: 3; }`. " +
|
|
473
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
474
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
args: {
|
|
478
|
+
descriptionLineClamp: "none",
|
|
479
|
+
},
|
|
480
|
+
render: (args) => ({
|
|
481
|
+
components: { ServicesCard },
|
|
482
|
+
setup() {
|
|
483
|
+
const componentArgs = computed(() => {
|
|
484
|
+
const { descriptionLineClamp: _descriptionLineClamp, ...rest } = args;
|
|
485
|
+
return rest;
|
|
486
|
+
});
|
|
487
|
+
const tokenStyles = computed(() => ({
|
|
488
|
+
"--services-card-description-line-clamp": args.descriptionLineClamp,
|
|
489
|
+
}));
|
|
490
|
+
return { componentArgs, tokenStyles, stressServices };
|
|
491
|
+
},
|
|
492
|
+
template: `
|
|
493
|
+
<div
|
|
494
|
+
:style="tokenStyles"
|
|
495
|
+
style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(25rem, 100%), 1fr)); gap:3.2rem;"
|
|
496
|
+
>
|
|
497
|
+
<ServicesCard
|
|
498
|
+
v-for="item in stressServices"
|
|
499
|
+
:key="item.service.slug"
|
|
500
|
+
v-bind="componentArgs"
|
|
501
|
+
:service-data="item.service"
|
|
502
|
+
:href="item.href"
|
|
503
|
+
:titles-within-image-wrapper="item.titlesWithinImageWrapper"
|
|
504
|
+
>
|
|
505
|
+
<template v-if="item.action" #actions>
|
|
506
|
+
<a href="#long-action" style="display:inline-block; padding:1.2rem 2.4rem; background:#333; color:#fff; border-radius:0.4rem; text-decoration:none;">
|
|
507
|
+
{{ item.action }}
|
|
508
|
+
</a>
|
|
509
|
+
</template>
|
|
510
|
+
</ServicesCard>
|
|
511
|
+
</div>
|
|
512
|
+
`,
|
|
513
|
+
}),
|
|
514
|
+
decorators: [
|
|
515
|
+
(story, context) => ({
|
|
516
|
+
components: { story, CanvasSwitcher },
|
|
517
|
+
setup() {
|
|
518
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
519
|
+
return { canvasName };
|
|
520
|
+
},
|
|
521
|
+
template: `
|
|
522
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
523
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
524
|
+
</div>
|
|
525
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
526
|
+
<story />
|
|
527
|
+
</div>
|
|
528
|
+
`,
|
|
529
|
+
}),
|
|
530
|
+
],
|
|
531
|
+
parameters: {
|
|
532
|
+
layout: "fullscreen",
|
|
533
|
+
initialCanvas: "mobileCanvas",
|
|
534
|
+
docs: {
|
|
535
|
+
description: {
|
|
536
|
+
story:
|
|
537
|
+
"Deliberately hostile data to find breakage, in a grid like ServicesCardGrid's: a long German compound word as title, " +
|
|
538
|
+
"eyebrow and actions label, a long paragraph description, long duration and price strings, emoji, HTML-like text " +
|
|
539
|
+
"(must render as text), right-to-left Arabic, a card with every text field empty (no empty eyebrow, heading, " +
|
|
540
|
+
"description or meta row is rendered), a broken image URL (its alt text shows), single characters, a whole-card " +
|
|
541
|
+
"link to a long URL, and long unbroken titles over the image. Check at every canvas width: no text runs out of its " +
|
|
542
|
+
"card, every column stays the same width, a long duration/price wraps onto two lines rather than overflowing, and " +
|
|
543
|
+
"the meta row and actions stay pinned to the bottom. Try the Description line clamp control too: it's a CSS token " +
|
|
544
|
+
"set by the story, not a prop.",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
};
|
|
@@ -90,14 +90,14 @@ describe("ServicesCard", () => {
|
|
|
90
90
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
91
91
|
props: { serviceData: mockService },
|
|
92
92
|
});
|
|
93
|
-
expect(wrapper.find(".description").text()).toBe(mockService.shortDescription);
|
|
93
|
+
expect(wrapper.find(".services-card-description").text()).toBe(mockService.shortDescription);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
it("passes image src and alt to the image element", async () => {
|
|
97
97
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
98
98
|
props: { serviceData: mockService },
|
|
99
99
|
});
|
|
100
|
-
const img = wrapper.find(".image-wrapper img");
|
|
100
|
+
const img = wrapper.find(".services-card-image-wrapper img");
|
|
101
101
|
expect(img.attributes("src")).toContain(mockService.image);
|
|
102
102
|
expect(img.attributes("alt")).toBe(mockService.title);
|
|
103
103
|
});
|
|
@@ -106,7 +106,7 @@ describe("ServicesCard", () => {
|
|
|
106
106
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
107
107
|
props: { serviceData: mockService },
|
|
108
108
|
});
|
|
109
|
-
expect(wrapper.find(".image-wrapper img").attributes("loading")).toBe("lazy");
|
|
109
|
+
expect(wrapper.find(".services-card-image-wrapper img").attributes("loading")).toBe("lazy");
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
// ─── Meta row (duration / price) ───────────────────────────────────────
|
|
@@ -115,16 +115,16 @@ describe("ServicesCard", () => {
|
|
|
115
115
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
116
116
|
props: { serviceData: mockService },
|
|
117
117
|
});
|
|
118
|
-
expect(wrapper.find(".meta-duration").text()).toBe(mockService.duration);
|
|
119
|
-
expect(wrapper.find(".meta-price").text()).toBe(mockService.price);
|
|
118
|
+
expect(wrapper.find(".services-card-meta-duration").text()).toBe(mockService.duration);
|
|
119
|
+
expect(wrapper.find(".services-card-meta-price").text()).toBe(mockService.price);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it("overrides duration/price text with durationText/priceText props", async () => {
|
|
123
123
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
124
124
|
props: { serviceData: mockService, durationText: "1 hour", priceText: "£75" },
|
|
125
125
|
});
|
|
126
|
-
expect(wrapper.find(".meta-duration").text()).toBe("1 hour");
|
|
127
|
-
expect(wrapper.find(".meta-price").text()).toBe("£75");
|
|
126
|
+
expect(wrapper.find(".services-card-meta-duration").text()).toBe("1 hour");
|
|
127
|
+
expect(wrapper.find(".services-card-meta-price").text()).toBe("£75");
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
it("overrides duration/price content with duration/price slots", async () => {
|
|
@@ -144,7 +144,7 @@ describe("ServicesCard", () => {
|
|
|
144
144
|
const wrapper = await mountSuspended(ServicesCard, {
|
|
145
145
|
props: { serviceData: serviceWithoutMeta },
|
|
146
146
|
});
|
|
147
|
-
expect(wrapper.find(".meta").exists()).toBe(false);
|
|
147
|
+
expect(wrapper.find(".services-card-meta").exists()).toBe(false);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
// ─── Actions slot ───────────────────────────────────────────────────────
|
|
@@ -303,4 +303,30 @@ describe("ServicesCard", () => {
|
|
|
303
303
|
expect(wrapper.find("a.is-clickable").exists()).toBe(false);
|
|
304
304
|
expect(wrapper.find("button").exists()).toBe(true);
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
// ─── Worst-case data ───────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
it("renders no empty eyebrow, heading or description for empty strings", async () => {
|
|
310
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
311
|
+
props: { serviceData: { ...mockService, subtitle: "", title: "", shortDescription: "" } },
|
|
312
|
+
});
|
|
313
|
+
expect(wrapper.find(".eyebrow-text").exists()).toBe(false);
|
|
314
|
+
expect(wrapper.find(".hero-text").exists()).toBe(false);
|
|
315
|
+
expect(wrapper.find(".services-card-description").exists()).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("drops the image overlay entirely when both titles are empty", async () => {
|
|
319
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
320
|
+
props: { serviceData: { ...mockService, subtitle: "", title: "" }, titlesWithinImageWrapper: true },
|
|
321
|
+
});
|
|
322
|
+
expect(wrapper.find(".services-card-image-details").exists()).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("renders HTML-like data as text", async () => {
|
|
326
|
+
const wrapper = await mountSuspended(ServicesCard, {
|
|
327
|
+
props: { serviceData: { ...mockService, title: "<b>bold</b>" } },
|
|
328
|
+
});
|
|
329
|
+
expect(wrapper.find(".hero-text").text()).toBe("<b>bold</b>");
|
|
330
|
+
expect(wrapper.find(".hero-text b").exists()).toBe(false);
|
|
331
|
+
});
|
|
306
332
|
});
|
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`ServicesCard > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-card">
|
|
5
|
-
<div class="grid-stack image-wrapper">
|
|
6
|
-
<div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/
|
|
5
|
+
<div class="grid-stack services-card-image-wrapper">
|
|
6
|
+
<div class="grid-stack__layer"><img width="640" height="853" data-nuxt-img="" srcset="/_ipx/s_640x853/images/test.jpg 1x, /_ipx/s_1280x1706/images/test.jpg 2x" alt="Test Service" loading="lazy" class="services-card-image" src="/_ipx/s_640x853/images/test.jpg"></div>
|
|
7
7
|
<div class="grid-stack__layer"></div>
|
|
8
8
|
</div>
|
|
9
|
-
<div class="details
|
|
9
|
+
<div class="services-card-details">
|
|
10
10
|
<div class="eyebrow-text large">A subtitle</div>
|
|
11
11
|
<h2 class="hero-text heading axis-horizontal">
|
|
12
12
|
<!--v-if--><span class="text-block-0 normal">Test Service</span>
|
|
13
13
|
</h2>
|
|
14
|
-
<div class="description">A short description of the service.</div>
|
|
15
|
-
<div class="footer">
|
|
16
|
-
<div class="meta">
|
|
17
|
-
<div class="meta-duration">60 mins</div>
|
|
18
|
-
<div class="meta-price">£50</div>
|
|
14
|
+
<div class="services-card-description">A short description of the service.</div>
|
|
15
|
+
<div class="services-card-footer">
|
|
16
|
+
<div class="services-card-meta">
|
|
17
|
+
<div class="services-card-meta-duration">60 mins</div>
|
|
18
|
+
<div class="services-card-meta-price">£50</div>
|
|
19
19
|
</div>
|
|
20
20
|
</div>
|
|
21
21
|
</div>
|
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `--services-card-grid-column-min-width` | `250px` | Minimum card column width before wrapping (capped at the container width) |
|
|
16
16
|
| `--services-card-grid-gap` | `4rem` | Gap between cards |
|
|
17
|
+
| `--services-card-grid-button-text-white-space` | `normal` | Wrapping of each card CTA label (`InputButton`'s `--input-button-text-white-space`). Set `nowrap` for single-line labels |
|
|
17
18
|
|
|
18
19
|
Each item is a full `ServiceSummary` or `ServicesCard`, styled by its own tokens (see
|
|
19
20
|
`03.organisms/services/service-summary/CONSUMER-STYLING.md` and
|
|
20
21
|
`03.organisms/services/services-card/CONSUMER-STYLING.md`). ServicesCardGrid's CTA is
|
|
21
22
|
`InputButton` (`variant="secondary"`).
|
|
22
23
|
|
|
24
|
+
> Changed 2026-10-06: CTA labels wrap inside the card (they used to stay on one line and be clipped
|
|
25
|
+
> by the card edge for a long or translated title), and an empty title no longer leaves a trailing
|
|
26
|
+
> space in the label.
|
|
27
|
+
|
|
23
28
|
> Changed 2026-09-28: ServicesCardGrid columns use `minmax(min(<min-width>, 100%), 1fr)`, so a
|
|
24
29
|
> single column no longer overflows a container narrower than the minimum width.
|
|
25
30
|
|
|
@@ -42,6 +47,10 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
42
47
|
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
43
48
|
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
44
49
|
|
|
50
|
+
**Caveat:** `.services-card-grid` re-declares `--input-button-text-white-space` on itself, so setting
|
|
51
|
+
that InputButton token on an ancestor doesn't reach the grid's buttons. Use
|
|
52
|
+
`--services-card-grid-button-text-white-space` instead.
|
|
53
|
+
|
|
45
54
|
---
|
|
46
55
|
|
|
47
56
|
## Class passthrough
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<template #actions="{ serviceData }">
|
|
11
11
|
<InputButton
|
|
12
12
|
variant="secondary"
|
|
13
|
-
:button-text="`${buttonTextPrefix} ${serviceData.title}
|
|
13
|
+
:button-text="`${buttonTextPrefix} ${serviceData.title}`.trim()"
|
|
14
14
|
:href="`${hrefBase}${serviceData.slug}`"
|
|
15
15
|
:style-class-passthrough="['mbs-24']"
|
|
16
16
|
>
|
|
@@ -64,6 +64,7 @@ watch(
|
|
|
64
64
|
display: grid;
|
|
65
65
|
grid-template-columns: repeat(auto-fit, minmax(min(var(--services-card-grid-column-min-width, 250px), 100%), 1fr));
|
|
66
66
|
gap: var(--services-card-grid-gap, 4rem);
|
|
67
|
+
--input-button-text-white-space: var(--services-card-grid-button-text-white-space, normal);
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
</style>
|
package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts
CHANGED
|
@@ -32,12 +32,6 @@ const meta: Meta<typeof ServiceSummaryGrid> = {
|
|
|
32
32
|
styleClassPassthrough: [],
|
|
33
33
|
},
|
|
34
34
|
parameters: {
|
|
35
|
-
backgrounds: {
|
|
36
|
-
default: "storybook-canvas",
|
|
37
|
-
options: {
|
|
38
|
-
"storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
35
|
docs: {
|
|
42
36
|
description: {
|
|
43
37
|
component:
|
|
@@ -201,3 +195,49 @@ export const EmptyData: Story = {
|
|
|
201
195
|
},
|
|
202
196
|
},
|
|
203
197
|
};
|
|
198
|
+
|
|
199
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const stressServices: Service[] = [
|
|
202
|
+
...Array.from({ length: 7 }, (_, index) =>
|
|
203
|
+
makeService(`stress-${index}`, `Service ${index + 1}`, "Grid stress", `https://picsum.photos/seed/summary-stress-${index}/800/600`)
|
|
204
|
+
),
|
|
205
|
+
makeService("stress-0", "Duplicate slug", "Same slug as the first row", "https://picsum.photos/seed/summary-stress-dup/800/600"),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const longLinkLabel =
|
|
209
|
+
"Mehr über diese außergewöhnlich ausführliche Haarverlängerungsbehandlungsberatung erfahren und einen Termin vereinbaren";
|
|
210
|
+
|
|
211
|
+
export const StressTest: Story = {
|
|
212
|
+
name: "Stress Test (Worst-Case Data)",
|
|
213
|
+
args: {
|
|
214
|
+
useAlternateReverse: true,
|
|
215
|
+
},
|
|
216
|
+
render: (args) => ({
|
|
217
|
+
components: { ServiceSummaryGrid },
|
|
218
|
+
setup() {
|
|
219
|
+
return { args, stressServices, longLinkLabel };
|
|
220
|
+
},
|
|
221
|
+
template: `
|
|
222
|
+
<ServiceSummaryGrid v-bind="args" :services-data="stressServices">
|
|
223
|
+
<template #summary-link="{ serviceData }">
|
|
224
|
+
<a :href="'/services/' + serviceData.slug" style="display:inline-block;margin-top:1.6rem;color:inherit;overflow-wrap:anywhere;">
|
|
225
|
+
{{ longLinkLabel }}
|
|
226
|
+
</a>
|
|
227
|
+
</template>
|
|
228
|
+
</ServiceSummaryGrid>
|
|
229
|
+
`,
|
|
230
|
+
}),
|
|
231
|
+
parameters: {
|
|
232
|
+
docs: {
|
|
233
|
+
description: {
|
|
234
|
+
story:
|
|
235
|
+
"Grid-level worst case: eight rows (an odd count of distinct services, so alternate-reverse ends on a " +
|
|
236
|
+
"non-reversed row), two rows sharing a slug, and a long German summary-link label in every row. Check that " +
|
|
237
|
+
"rows alternate correctly all the way down, the duplicate slug renders both rows, and the slot content stays " +
|
|
238
|
+
"inside its column. Field-level worst-case data (long titles, empty fields, emoji) belongs to ServiceSummary's " +
|
|
239
|
+
"own stress story, since every field is rendered there.",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { computed } from "vue";
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
2
|
import ServicesCardGrid from "../ServicesCardGrid.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
3
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
4
5
|
import type { Service } from "~/types/types.services";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
5
7
|
|
|
6
8
|
type StoryArgs = {
|
|
7
9
|
tag?: "div" | "section" | "main";
|
|
@@ -57,7 +59,7 @@ const meta: Meta<StoryArgs> = {
|
|
|
57
59
|
control: { type: "inline-radio" },
|
|
58
60
|
options: [1, 2, 3, 4, 5],
|
|
59
61
|
description:
|
|
60
|
-
"Story-only control — sets ServicesCard's --description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
|
|
62
|
+
"Story-only control — sets ServicesCard's --services-card-description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
|
|
61
63
|
},
|
|
62
64
|
cardMinWidth: {
|
|
63
65
|
control: { type: "inline-radio" },
|
|
@@ -78,12 +80,6 @@ const meta: Meta<StoryArgs> = {
|
|
|
78
80
|
cardMinWidth: "250px",
|
|
79
81
|
},
|
|
80
82
|
parameters: {
|
|
81
|
-
backgrounds: {
|
|
82
|
-
default: "storybook-canvas",
|
|
83
|
-
options: {
|
|
84
|
-
"storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
83
|
docs: {
|
|
88
84
|
description: {
|
|
89
85
|
component:
|
|
@@ -201,13 +197,13 @@ const hideDemoControls = {
|
|
|
201
197
|
|
|
202
198
|
/**
|
|
203
199
|
* lineClamp and cardMinWidth are story-only controls (not real ServicesCardGrid props).
|
|
204
|
-
* They set --description-line-clamp (read by each ServicesCard's .description) and
|
|
200
|
+
* They set --services-card-description-line-clamp (read by each ServicesCard's .description) and
|
|
205
201
|
* --services-card-grid-column-min-width (the grid's minmax() floor) on a wrapper div,
|
|
206
202
|
* and are stripped from the args bound to the component so they don't leak as attributes.
|
|
207
203
|
*/
|
|
208
204
|
function useStorySetup(args: StoryArgs) {
|
|
209
205
|
const wrapperStyle = computed(() => ({
|
|
210
|
-
"--description-line-clamp": String(args.lineClamp ?? 3),
|
|
206
|
+
"--services-card-description-line-clamp": String(args.lineClamp ?? 3),
|
|
211
207
|
"--services-card-grid-column-min-width": args.cardMinWidth ?? "250px",
|
|
212
208
|
}));
|
|
213
209
|
const componentArgs = computed(() => {
|
|
@@ -360,7 +356,7 @@ export const DescriptionLineClamp: Story = {
|
|
|
360
356
|
docs: {
|
|
361
357
|
description: {
|
|
362
358
|
story:
|
|
363
|
-
"Six cards with progressively longer shortDescription text, all sharing one --description-line-clamp value set via the lineClamp control. Use the control to see how the same clamp value affects a one-line description (no visible clamping) versus a long paragraph (clamped with an ellipsis) — card heights stay equal since the meta row and actions slot sit below the clamped description rather than growing with it.",
|
|
359
|
+
"Six cards with progressively longer shortDescription text, all sharing one --services-card-description-line-clamp value set via the lineClamp control. Use the control to see how the same clamp value affects a one-line description (no visible clamping) versus a long paragraph (clamped with an ellipsis) — card heights stay equal since the meta row and actions slot sit below the clamped description rather than growing with it.",
|
|
364
360
|
},
|
|
365
361
|
},
|
|
366
362
|
},
|
|
@@ -391,3 +387,85 @@ export const CardWidth: Story = {
|
|
|
391
387
|
},
|
|
392
388
|
},
|
|
393
389
|
};
|
|
390
|
+
|
|
391
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
|
|
394
|
+
|
|
395
|
+
const stressServices: Service[] = [
|
|
396
|
+
{
|
|
397
|
+
...makeService("long-german", longGerman, `Freihändige ${longGerman}`, "https://picsum.photos/seed/stress-grid-1/600/800"),
|
|
398
|
+
shortDescription: "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert. ".repeat(6),
|
|
399
|
+
duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden",
|
|
400
|
+
price: "Ab 95,00 € zuzüglich Pflegeprodukte",
|
|
401
|
+
},
|
|
402
|
+
{ ...makeService("emoji", "✨💇♀️ <b>not bold</b>", "<script>alert(1)</script>", "https://picsum.photos/seed/stress-grid-2/600/800") },
|
|
403
|
+
{
|
|
404
|
+
...makeService("rtl", "تسريحات شعر جديدة لموسم الخريف", "تلوين الشعر", "https://picsum.photos/seed/stress-grid-3/600/800"),
|
|
405
|
+
shortDescription: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من النص من اليمين إلى اليسار.",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
...makeService("", "", "", "https://example.invalid/missing.jpg"),
|
|
409
|
+
shortDescription: "",
|
|
410
|
+
duration: "",
|
|
411
|
+
price: "",
|
|
412
|
+
},
|
|
413
|
+
{ ...makeService("a b/c?d=é#frag", "A", "B", "https://picsum.photos/seed/stress-grid-5/600/800"), shortDescription: "C" },
|
|
414
|
+
{ ...makeService("long-german", "Duplicate slug", "Same slug as the first card", "https://picsum.photos/seed/stress-grid-6/600/800") },
|
|
415
|
+
...Array.from({ length: 6 }, (_, index) =>
|
|
416
|
+
makeService(`filler-${index}`, `Filler ${index + 1}`, "Filler", `https://picsum.photos/seed/stress-grid-f${index}/600/800`)
|
|
417
|
+
),
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
export const StressTest: Story = {
|
|
421
|
+
name: "Stress Test (Worst-Case Data)",
|
|
422
|
+
args: {
|
|
423
|
+
buttonTextPrefix: "Unverbindliche Anfrage stellen zu",
|
|
424
|
+
hrefBase: "",
|
|
425
|
+
buttonIcon: "this-icon:does-not-exist",
|
|
426
|
+
},
|
|
427
|
+
render: (args) => ({
|
|
428
|
+
components: { ServicesCardGrid },
|
|
429
|
+
setup() {
|
|
430
|
+
return { ...useStorySetup(args), stressServices };
|
|
431
|
+
},
|
|
432
|
+
template: `
|
|
433
|
+
<div :style="wrapperStyle">
|
|
434
|
+
<ServicesCardGrid v-bind="componentArgs" :services-data="stressServices" />
|
|
435
|
+
</div>
|
|
436
|
+
`,
|
|
437
|
+
}),
|
|
438
|
+
decorators: [
|
|
439
|
+
(story, context) => ({
|
|
440
|
+
components: { story, CanvasSwitcher },
|
|
441
|
+
setup() {
|
|
442
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
443
|
+
return { canvasName };
|
|
444
|
+
},
|
|
445
|
+
template: `
|
|
446
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
447
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
448
|
+
</div>
|
|
449
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
450
|
+
<story />
|
|
451
|
+
</div>
|
|
452
|
+
`,
|
|
453
|
+
}),
|
|
454
|
+
],
|
|
455
|
+
parameters: {
|
|
456
|
+
layout: "fullscreen",
|
|
457
|
+
initialCanvas: "mobileCanvas",
|
|
458
|
+
docs: {
|
|
459
|
+
description: {
|
|
460
|
+
story:
|
|
461
|
+
"Deliberately hostile data to find breakage: a long German title, so the CTA label (long German prefix + title) " +
|
|
462
|
+
"is far wider than the card; emoji and HTML-like text (must render as text); right-to-left Arabic; a card with " +
|
|
463
|
+
"every text field and the slug empty (label is just the prefix, no trailing space); a broken image; a slug with " +
|
|
464
|
+
"spaces and URL characters; two cards sharing a slug; 12 cards; an empty hrefBase and a broken icon name. " +
|
|
465
|
+
"Check at every canvas width: every CTA label wraps inside its card instead of being clipped at the edge, all " +
|
|
466
|
+
"columns stay equal, and the meta rows and buttons line up along each row. The line clamp and card width " +
|
|
467
|
+
"controls are CSS tokens set by the story, not props.",
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
};
|
|
@@ -196,4 +196,12 @@ describe("ServicesCardGrid button icon", () => {
|
|
|
196
196
|
});
|
|
197
197
|
expect(iconName(wrapper)).toContain("lucide:arrow-right");
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
it("drops the trailing space from the CTA label when a title is empty", async () => {
|
|
201
|
+
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
202
|
+
props: { servicesData: [makeService("untitled", "")] },
|
|
203
|
+
});
|
|
204
|
+
expect(wrapper.find(".button-text").text()).toBe("Enquire about");
|
|
205
|
+
expect(wrapper.find(".button-text").element.textContent).toBe("Enquire about");
|
|
206
|
+
});
|
|
199
207
|
});
|