srcdev-nuxt-components 9.0.15 → 9.0.16

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 (107) hide show
  1. package/.claude/settings.json +25 -0
  2. package/.claude/skills/component-aria-landmark.md +68 -0
  3. package/.claude/skills/component-dynamic-slots.md +150 -0
  4. package/.claude/skills/component-local-style-override.md +126 -0
  5. package/.claude/skills/component-prop-driven-container-layout.md +42 -0
  6. package/.claude/skills/components/accordian-core.md +159 -0
  7. package/.claude/skills/components/contact-section.md +101 -0
  8. package/.claude/skills/components/expanding-panel.md +156 -0
  9. package/.claude/skills/components/eyebrow-text.md +25 -0
  10. package/.claude/skills/components/hero-text.md +25 -0
  11. package/.claude/skills/components/layout-grid-by-cols.md +147 -0
  12. package/.claude/skills/components/layout-row.md +35 -0
  13. package/.claude/skills/components/link-text.md +33 -0
  14. package/.claude/skills/components/page-hero-highlights.md +224 -0
  15. package/.claude/skills/components/services-card.md +28 -0
  16. package/.claude/skills/components/services-section.md +25 -0
  17. package/.claude/skills/components/stepper-list.md +227 -0
  18. package/.claude/skills/css-grid-max-width-gutters.md +67 -0
  19. package/.claude/skills/index.md +14 -3
  20. package/.claude/skills/storybook-add-story.md +60 -0
  21. package/.claude/skills/testing-add-unit-test.md +56 -0
  22. package/app/assets/styles/setup/01.config/index.css +0 -1
  23. package/app/assets/styles/setup/03.theming/default/_dark.css +2 -2
  24. package/app/assets/styles/setup/04.elements/forms/02.typography.css +1 -0
  25. package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-link.css +14 -14
  26. package/app/assets/styles/setup/index.css +0 -1
  27. package/app/components/01.atoms/card/CardCore.vue +92 -0
  28. package/app/components/01.atoms/card/stories/CardCore.stories.ts +132 -0
  29. package/app/components/01.atoms/card/tests/CardCore.spec.ts +207 -0
  30. package/app/components/01.atoms/card/tests/__snapshots__/CardCore.spec.ts.snap +43 -0
  31. package/app/components/01.atoms/content-wrappers/content-columns-2/ContentColumns2.vue +51 -0
  32. package/app/components/01.atoms/content-wrappers/content-columns-2/stories/ContentColumns2.stories.ts +110 -0
  33. package/app/components/01.atoms/content-wrappers/content-columns-2/tests/ContentColumns2.spec.ts +105 -0
  34. package/app/components/01.atoms/content-wrappers/content-columns-2/tests/__snapshots__/ContentColumns2.spec.ts.snap +14 -0
  35. package/app/components/01.atoms/content-wrappers/content-width/ContentWidth.vue +88 -0
  36. package/app/components/01.atoms/content-wrappers/content-width/stories/ContentWidth.stories.ts +362 -0
  37. package/app/components/01.atoms/content-wrappers/content-width/tests/ContentWidth.spec.ts +132 -0
  38. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +71 -0
  39. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +219 -0
  40. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +174 -0
  41. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/__snapshots__/LayoutGrid.spec.ts.snap +36 -0
  42. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/__snapshots__/LayoutGridByCols.spec.ts.snap +36 -0
  43. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/LayoutGridByWidth.vue +70 -0
  44. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/stories/LayoutGridByWidth.stories.ts +220 -0
  45. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/LayoutGridByWidth.spec.ts +174 -0
  46. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGrid.spec.ts.snap +36 -0
  47. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGridByCols.spec.ts.snap +36 -0
  48. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGridByWidth.spec.ts.snap +36 -0
  49. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +1 -1
  50. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +1 -1
  51. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +1 -1
  52. package/app/components/02.molecules/contact-section/stories/ContactSection.stories.ts +5 -0
  53. package/app/components/02.molecules/contact-section/tests/ContactSection.spec.ts +15 -0
  54. package/app/components/02.molecules/contact-section/tests/ContactSection.vue +25 -17
  55. package/app/components/{accordian → 02.molecules/expandable/accordian}/stories/AccordianCore.stories.ts +1 -1
  56. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +245 -0
  57. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +351 -0
  58. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +38 -0
  59. package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontal.vue +139 -0
  60. package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontalAdvanced.vue +172 -0
  61. package/app/components/02.molecules/profile-section/ProfileSection.vue +2 -3
  62. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  63. package/app/components/02.molecules/stepper-list/StepperList.vue +131 -92
  64. package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +31 -0
  65. package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +24 -0
  66. package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +22 -9
  67. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +782 -0
  68. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +233 -0
  69. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +226 -0
  70. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +69 -0
  71. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +1 -1
  72. package/app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue +1 -1
  73. package/app/components/03.organisms/services/services-section/ServicesSection.vue +2 -3
  74. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +239 -0
  75. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +404 -0
  76. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +198 -0
  77. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +19 -0
  78. package/app/components/container-glow/ContainerGlowCore.vue +20 -27
  79. package/app/components/forms/input-button/InputButtonCore.vue +105 -104
  80. package/app/components/glowing-border/stories/GlowingBorder.stories.ts +21 -21
  81. package/app/composables/useAriaLabelledById.ts +13 -0
  82. package/app/layouts/default.vue +8 -3
  83. package/app/pages/forms/examples/buttons/index.vue +6 -6
  84. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +3 -3
  85. package/app/pages/forms/examples/material/text-fields.vue +607 -610
  86. package/app/pages/page-hero-highlights.vue +81 -0
  87. package/app/pages/ui/{display-card.vue → card-core.vue} +15 -15
  88. package/app/pages/ui/contact-section.vue +1 -1
  89. package/app/pages/ui/container-glow.vue +1 -1
  90. package/app/pages/ui/content-width.vue +126 -0
  91. package/app/pages/ui/glowing-border.vue +9 -9
  92. package/app/pages/ui/navigation/navigation-horizontal.vue +493 -0
  93. package/app/pages/ui/services/services-section/[slug].vue +3 -1
  94. package/package.json +2 -2
  95. package/app/assets/styles/setup/01.config/_basic-resets.css +0 -9
  96. package/app/components/content-columns/TwoColumns.vue +0 -59
  97. package/app/components/content-columns/stories/TwoColumns.stories.ts +0 -561
  98. package/app/components/content-containers/ContentContainer.vue +0 -89
  99. package/app/components/content-containers/stories/ContentContainer.stories.ts +0 -465
  100. package/app/components/content-grid/ContentGrid.vue +0 -85
  101. package/app/components/display-card/DisplayCard.vue +0 -122
  102. package/app/components/image-galleries/SliderGallery.vue +0 -786
  103. package/app/pages/ui/content-container.vue +0 -112
  104. /package/app/components/{accordian → 02.molecules/expandable/accordian}/AccordianCore.vue +0 -0
  105. /package/app/components/{accordian → 02.molecules/expandable/accordian}/tests/AccordianCore.spec.ts +0 -0
  106. /package/app/components/{accordian → 02.molecules/expandable/accordian}/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -0
  107. /package/app/components/{expanding-panel → 02.molecules/expandable/expanding-panel}/ExpandingPanel.vue +0 -0
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ContentWidth from "../ContentWidth.vue";
4
+
5
+ const mockElementClasses = { value: "" };
6
+
7
+ const mockUseStyleClassPassthrough = vi.fn(() => ({
8
+ elementClasses: mockElementClasses,
9
+ }));
10
+
11
+ vi.mock("#imports", () => ({
12
+ useStyleClassPassthrough: mockUseStyleClassPassthrough,
13
+ useId: () => "test-id",
14
+ }));
15
+
16
+ describe("ContentWidth", () => {
17
+ let wrapper: Awaited<ReturnType<typeof mountSuspended>>;
18
+
19
+ const createWrapper = async (props = {}, slots = {}) => {
20
+ wrapper = await mountSuspended(ContentWidth, {
21
+ props: { styleClassPassthrough: [], ...props },
22
+ slots,
23
+ });
24
+ return wrapper;
25
+ };
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mockElementClasses.value = "";
30
+ });
31
+
32
+ afterEach(() => {
33
+ wrapper?.unmount();
34
+ });
35
+
36
+ describe("Component Rendering", () => {
37
+ it("mounts without error", async () => {
38
+ await createWrapper();
39
+ expect(wrapper.find(".content-width-wrapper").exists()).toBe(true);
40
+ });
41
+
42
+ it("renders the correct inner structure", async () => {
43
+ await createWrapper();
44
+ expect(wrapper.find(".content-width").exists()).toBe(true);
45
+ expect(wrapper.find(".content-width-inner").exists()).toBe(true);
46
+ });
47
+
48
+ it("renders as a div by default", async () => {
49
+ await createWrapper();
50
+ expect(wrapper.find(".content-width-wrapper").element.tagName).toBe("DIV");
51
+ });
52
+ });
53
+
54
+ describe("Tag Prop", () => {
55
+ const tags = ["section", "article", "aside", "header", "footer", "main", "nav"] as const;
56
+
57
+ tags.forEach((tag) => {
58
+ it(`renders as <${tag}> when tag="${tag}"`, async () => {
59
+ await createWrapper({ tag });
60
+ expect(wrapper.find(".content-width-wrapper").element.tagName).toBe(tag.toUpperCase());
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("isLandmark Prop", () => {
66
+ it("does not set tabindex or aria-label by default", async () => {
67
+ await createWrapper();
68
+ const el = wrapper.find(".content-width-wrapper");
69
+ expect(el.attributes("tabindex")).toBeUndefined();
70
+ expect(el.attributes("aria-label")).toBeUndefined();
71
+ });
72
+
73
+ it("sets tab-index=0 when isLandmark is true", async () => {
74
+ await createWrapper({ isLandmark: true });
75
+ expect(wrapper.find(".content-width-wrapper").attributes("tab-index")).toBe("0");
76
+ });
77
+
78
+ it("sets aria-label when isLandmark is true", async () => {
79
+ await createWrapper({ isLandmark: true });
80
+ expect(wrapper.find(".content-width-wrapper").attributes("aria-label")).toBe(
81
+ "Content Width Landmark"
82
+ );
83
+ });
84
+ });
85
+
86
+ describe("Slot", () => {
87
+ it("renders default slot content", async () => {
88
+ await createWrapper({}, { default: "<p>Slot content</p>" });
89
+ expect(wrapper.find(".content-width-inner").html()).toContain("Slot content");
90
+ });
91
+
92
+ it("renders nested slot content", async () => {
93
+ await createWrapper(
94
+ {},
95
+ { default: "<section><h2>Title</h2><p>Body</p></section>" }
96
+ );
97
+ expect(wrapper.find(".content-width-inner h2").text()).toBe("Title");
98
+ });
99
+
100
+ it("renders without slot content gracefully", async () => {
101
+ await createWrapper();
102
+ expect(wrapper.find(".content-width-inner").exists()).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe("styleClassPassthrough Prop", () => {
107
+ it("accepts a string value", async () => {
108
+ await createWrapper({ styleClassPassthrough: "custom-class" });
109
+ expect(wrapper.find(".content-width-wrapper").exists()).toBe(true);
110
+ });
111
+
112
+ it("accepts an array of strings", async () => {
113
+ await createWrapper({ styleClassPassthrough: ["class-a", "class-b"] });
114
+ expect(wrapper.find(".content-width-wrapper").exists()).toBe(true);
115
+ });
116
+
117
+ it("applies elementClasses from useStyleClassPassthrough to wrapper", async () => {
118
+ mockElementClasses.value = "injected-class";
119
+ await createWrapper({ styleClassPassthrough: "injected-class" });
120
+ expect(wrapper.find(".content-width-wrapper").classes()).toContain("injected-class");
121
+ });
122
+ });
123
+
124
+ describe("DOM Structure", () => {
125
+ it("maintains correct nesting: wrapper > content-width > inner > slot", async () => {
126
+ await createWrapper({}, { default: "<span>nested</span>" });
127
+ const inner = wrapper.find(".content-width-wrapper .content-width .content-width-inner");
128
+ expect(inner.exists()).toBe(true);
129
+ expect(inner.find("span").text()).toBe("nested");
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ class="layout-grid-by-cols"
5
+ :aria-labelledby="ariaLabelledby"
6
+ :class="[elementClasses]"
7
+ >
8
+ <p v-if="ariaLabelledby" :id="headingId" class="sr-only">
9
+ {{ props.label || "If tag='section' then a label is required" }}
10
+ </p>
11
+ <div class="layout-grid-inner">
12
+ <template v-for="(_, name) in $slots" :key="name">
13
+ <slot :name="name"></slot>
14
+ </template>
15
+ </div>
16
+ </component>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ interface Props {
21
+ tag?: "div" | "section";
22
+ label?: string;
23
+ columnCount?: 2 | 3 | 4 | 5 | 6;
24
+ gap?: string;
25
+ singleColBelow?: string;
26
+ styleClassPassthrough?: string | string[];
27
+ }
28
+
29
+ const props = withDefaults(defineProps<Props>(), {
30
+ tag: "div",
31
+ label: "",
32
+ columnCount: 2,
33
+ gap: "1rem",
34
+ singleColBelow: "768px",
35
+ styleClassPassthrough: () => [],
36
+ });
37
+
38
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
39
+ const columnCount = computed(() => (props.columnCount < 2 ? 2 : props.columnCount));
40
+
41
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
42
+
43
+ watch(
44
+ () => props.styleClassPassthrough,
45
+ () => {
46
+ updateElementClasses(props.styleClassPassthrough);
47
+ }
48
+ );
49
+ </script>
50
+
51
+ <style lang="css">
52
+ @layer components {
53
+ .layout-grid-by-cols {
54
+ container-type: inline-size;
55
+ container-name: layoutGrid;
56
+
57
+ --_gap: v-bind(gap);
58
+
59
+ .layout-grid-inner {
60
+ display: grid;
61
+ grid-auto-flow: row;
62
+ gap: var(--_gap);
63
+
64
+ @container layoutGrid (width >= 768px) {
65
+ grid-template-columns: repeat(v-bind(columnCount), 1fr);
66
+ gap: var(--_gap);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ </style>
@@ -0,0 +1,219 @@
1
+ import { computed } from "vue";
2
+ import LayoutGridByCols from "../LayoutGridByCols.vue";
3
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
+
5
+ type StoryArgs = InstanceType<typeof LayoutGridByCols>["$props"] & { itemCount?: number };
6
+
7
+ const meta: Meta<StoryArgs> = {
8
+ title: "Atoms/Content Wrappers/Layout Grid By Cols",
9
+ component: LayoutGridByCols,
10
+ argTypes: {
11
+ tag: {
12
+ control: { type: "select" },
13
+ options: ["div", "section"],
14
+ description: "Semantic HTML tag for the root element — use section with a label for landmark regions",
15
+ table: { category: "Semantic" },
16
+ },
17
+ label: {
18
+ control: "text",
19
+ description:
20
+ "Accessible label for the grid — required when tag is section (rendered as a visually hidden element)",
21
+ table: { category: "Semantic" },
22
+ },
23
+ itemCount: {
24
+ control: { type: "range", min: 0, max: 18, step: 1 },
25
+ description: "Number of grid cells to render — must match the number of #item-{n} slots provided",
26
+ },
27
+ columnCount: {
28
+ control: { type: "range", min: 1, max: 8, step: 1 },
29
+ description:
30
+ "Integer → repeat(N, 1fr) equal columns. CSS string (e.g. '200px', '15rem') → repeat(auto-fill, minmax(value, 1fr)) wrapping columns.",
31
+ },
32
+ gap: {
33
+ control: "text",
34
+ description: "Grid gap — any valid CSS length (e.g. '1rem', '2.4rem', '16px')",
35
+ },
36
+ singleColBelow: {
37
+ control: "text",
38
+ description:
39
+ "Container width below which the grid collapses to a single column — any valid CSS length (e.g. '600px', '40rem'). Default '0px' means never collapse.",
40
+ },
41
+ styleClassPassthrough: {
42
+ table: { disable: true },
43
+ },
44
+ },
45
+ args: {
46
+ tag: "div",
47
+ itemCount: 6,
48
+ columnCount: 3,
49
+ gap: "1rem",
50
+ singleColBelow: "768px",
51
+ styleClassPassthrough: [],
52
+ },
53
+ parameters: {
54
+ docs: {
55
+ description: {
56
+ component:
57
+ "A CSS grid wrapper driven by props. Content is placed via dynamic named slots (#item-0, #item-1, …). Pass an integer to columnCount for N equal columns, or a CSS width string for auto-fill behaviour. The grid collapses to a single column below singleColBelow using a CSS container query.",
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ export default meta;
64
+ type Story = StoryObj<StoryArgs>;
65
+
66
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
67
+
68
+ const colours = [
69
+ { bg: "#dbeafe", fg: "#1e40af" },
70
+ { bg: "#dcfce7", fg: "#166534" },
71
+ { bg: "#fef3c7", fg: "#92400e" },
72
+ { bg: "#ede9fe", fg: "#5b21b6" },
73
+ { bg: "#fce7f3", fg: "#9d174d" },
74
+ { bg: "#ffedd5", fg: "#9a3412" },
75
+ { bg: "#f0fdf4", fg: "#14532d" },
76
+ { bg: "#fdf4ff", fg: "#7e22ce" },
77
+ ];
78
+
79
+ const cell = (label: string, index: number, body = "Placeholder content for this grid cell.") =>
80
+ `<div style="padding: 2.4rem; background: ${colours[index % colours.length]?.bg}; border-radius: 0.8rem; color: ${colours[index % colours.length]?.fg}; font-family: sans-serif; height: 100%;">
81
+ <strong style="display: block; margin-bottom: 0.8rem;">${label}</strong>
82
+ <p style="margin: 0; font-size: 1.4rem; line-height: 1.6; opacity: 0.8;">${body}</p>
83
+ </div>`;
84
+
85
+ // Pre-generate max slots — the component only renders up to itemCount, extras are ignored.
86
+ const allSlots = Array.from(
87
+ { length: 18 },
88
+ (_, i) => `<template #item-${i}>${cell(`Item ${i + 1}`, i)}</template>`
89
+ ).join("\n ");
90
+
91
+ // Normalise args: explicitly maps each prop so no raw slider number leaks through to the component.
92
+ // colWidth: 0 → undefined (not set), N → "Nrem" CSS string.
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ const toStoryArgs = (args: any) =>
95
+ computed(() => ({
96
+ tag: args.tag as "div" | "section",
97
+ label: args.label as string,
98
+ itemCount: Number(args.itemCount),
99
+ columnCount: args.columnCount as number,
100
+ gap: args.gap as string,
101
+ singleColBelow: args.singleColBelow as string,
102
+ styleClassPassthrough: args.styleClassPassthrough as string[],
103
+ }));
104
+
105
+ // ─── Stories ──────────────────────────────────────────────────────────────────
106
+
107
+ /** Default — equal columns. Adjust columns, colWidth and itemCount with the sliders. */
108
+ export const Default: Story = {
109
+ args: {
110
+ itemCount: 6,
111
+ columnCount: 3,
112
+ },
113
+ render: (args) => ({
114
+ components: { LayoutGridByCols },
115
+ setup() {
116
+ return { storyArgs: toStoryArgs(args) };
117
+ },
118
+ template: `
119
+ <LayoutGridByCols v-bind="storyArgs">
120
+ ${allSlots}
121
+ </LayoutGridByCols>
122
+ `,
123
+ }),
124
+ };
125
+
126
+ /** Single column below a breakpoint — collapses to stacked layout on narrow containers. */
127
+ export const SingleColBelow: Story = {
128
+ name: "Single Column Below 600px",
129
+ args: {
130
+ itemCount: 6,
131
+ columnCount: 3,
132
+ singleColBelow: "600px",
133
+ },
134
+ render: (args) => ({
135
+ components: { LayoutGridByCols },
136
+ setup() {
137
+ return { storyArgs: toStoryArgs(args) };
138
+ },
139
+ template: `
140
+ <LayoutGridByCols v-bind="storyArgs">
141
+ ${allSlots}
142
+ </LayoutGridByCols>
143
+ `,
144
+ }),
145
+ parameters: {
146
+ docs: {
147
+ description: {
148
+ story:
149
+ "Below 600px container width the grid collapses to a single column via a CSS container query. Resize the canvas to see the switch.",
150
+ },
151
+ },
152
+ },
153
+ };
154
+
155
+ /** Custom gap — wider spacing between cells. */
156
+ export const CustomGap: Story = {
157
+ name: "Custom Gap",
158
+ args: {
159
+ itemCount: 6,
160
+ columnCount: 3,
161
+ gap: "3.2rem",
162
+ },
163
+ render: (args) => ({
164
+ components: { LayoutGridByCols },
165
+ setup() {
166
+ return { storyArgs: toStoryArgs(args) };
167
+ },
168
+ template: `
169
+ <LayoutGridByCols v-bind="storyArgs">
170
+ ${allSlots}
171
+ </LayoutGridByCols>
172
+ `,
173
+ }),
174
+ };
175
+
176
+ /** Semantic section — tag='section' with a visually hidden label for accessibility. */
177
+ export const SemanticSection: Story = {
178
+ name: "Semantic Section",
179
+ args: {
180
+ tag: "section",
181
+ label: "Feature highlights",
182
+ itemCount: 6,
183
+ columnCount: 3,
184
+ },
185
+ render: (args) => ({
186
+ components: { LayoutGridByCols },
187
+ setup() {
188
+ return { storyArgs: toStoryArgs(args) };
189
+ },
190
+ template: `
191
+ <LayoutGridByCols v-bind="storyArgs">
192
+ ${allSlots}
193
+ </LayoutGridByCols>
194
+ `,
195
+ }),
196
+ parameters: {
197
+ docs: {
198
+ description: {
199
+ story:
200
+ "When tag is section, an aria-labelledby attribute is added and the label is rendered as a visually hidden element for screen readers.",
201
+ },
202
+ },
203
+ },
204
+ };
205
+
206
+ /** Zero items — renders an empty grid with no cells. */
207
+ export const ZeroItems: Story = {
208
+ name: "Zero Items",
209
+ args: {
210
+ itemCount: 0,
211
+ },
212
+ render: (args) => ({
213
+ components: { LayoutGridByCols },
214
+ setup() {
215
+ return { storyArgs: toStoryArgs(args) };
216
+ },
217
+ template: `<LayoutGridByCols v-bind="storyArgs" />`,
218
+ }),
219
+ };
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import LayoutGridByCols from "../LayoutGridByCols.vue";
4
+
5
+ describe("LayoutGridByCols", () => {
6
+ // ─── Mount ───────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(LayoutGridByCols);
10
+ expect(wrapper.vm).toBeTruthy();
11
+ });
12
+
13
+ // ─── Snapshots ───────────────────────────────────────────────────────────
14
+
15
+ it("renders correct HTML structure (div, 2 items)", async () => {
16
+ const wrapper = await mountSuspended(LayoutGridByCols, {
17
+ slots: {
18
+ "item-0": "<p>First</p>",
19
+ "item-1": "<p>Second</p>",
20
+ },
21
+ });
22
+ expect(wrapper.html()).toMatchSnapshot();
23
+ });
24
+
25
+ it("renders correct HTML structure (section with label)", async () => {
26
+ const wrapper = await mountSuspended(LayoutGridByCols, {
27
+ props: { tag: "section", label: "Feature grid" },
28
+ });
29
+ expect(wrapper.html()).toMatchSnapshot();
30
+ });
31
+
32
+ it("renders correct HTML structure with styleClassPassthrough", async () => {
33
+ const wrapper = await mountSuspended(LayoutGridByCols, {
34
+ props: { styleClassPassthrough: ["custom-class", "another-class"] },
35
+ });
36
+ expect(wrapper.html()).toMatchSnapshot();
37
+ });
38
+
39
+ // ─── Tag rendering ───────────────────────────────────────────────────────
40
+
41
+ it("renders as <div> by default", async () => {
42
+ const wrapper = await mountSuspended(LayoutGridByCols);
43
+ expect(wrapper.element.tagName).toBe("DIV");
44
+ });
45
+
46
+ it("renders as <section> when tag='section'", async () => {
47
+ const wrapper = await mountSuspended(LayoutGridByCols, {
48
+ props: { tag: "section" },
49
+ });
50
+ expect(wrapper.element.tagName).toBe("SECTION");
51
+ });
52
+
53
+ // ─── Base class ──────────────────────────────────────────────────────────
54
+
55
+ it("always has the layout-grid class", async () => {
56
+ const wrapper = await mountSuspended(LayoutGridByCols);
57
+ expect(wrapper.classes()).toContain("layout-grid-by-cols");
58
+ });
59
+
60
+ // ─── Inner div ───────────────────────────────────────────────────────────
61
+
62
+ it("renders a .layout-grid-inner div", async () => {
63
+ const wrapper = await mountSuspended(LayoutGridByCols);
64
+ expect(wrapper.find(".layout-grid-inner").exists()).toBe(true);
65
+ });
66
+
67
+ // ─── Accessibility ───────────────────────────────────────────────────────
68
+
69
+ it("renders sr-only label and aria-labelledby when tag is section", async () => {
70
+ const wrapper = await mountSuspended(LayoutGridByCols, {
71
+ props: { tag: "section", label: "Card grid" },
72
+ });
73
+ const srOnly = wrapper.find(".sr-only");
74
+ expect(srOnly.exists()).toBe(true);
75
+ expect(srOnly.text()).toBe("Card grid");
76
+ expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
77
+ });
78
+
79
+ it("does not render sr-only label or aria-labelledby when tag is div", async () => {
80
+ const wrapper = await mountSuspended(LayoutGridByCols, {
81
+ props: { tag: "div", label: "Ignored" },
82
+ });
83
+ expect(wrapper.find(".sr-only").exists()).toBe(false);
84
+ expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
85
+ });
86
+
87
+ // ─── Dynamic slots ───────────────────────────────────────────────────────
88
+
89
+ it("renders slot content for each item inside the inner div", async () => {
90
+ const wrapper = await mountSuspended(LayoutGridByCols, {
91
+ slots: {
92
+ "item-0": "<span>Alpha</span>",
93
+ "item-1": "<span>Beta</span>",
94
+ "item-2": "<span>Gamma</span>",
95
+ },
96
+ });
97
+ const inner = wrapper.find(".layout-grid-inner");
98
+ expect(inner.text()).toContain("Alpha");
99
+ expect(inner.text()).toContain("Beta");
100
+ expect(inner.text()).toContain("Gamma");
101
+ });
102
+
103
+ it("renders no slot content when no slots are provided", async () => {
104
+ const wrapper = await mountSuspended(LayoutGridByCols);
105
+ expect(wrapper.find(".layout-grid-inner").text().trim()).toBe("");
106
+ });
107
+
108
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
109
+
110
+ it("applies a single styleClassPassthrough string", async () => {
111
+ const wrapper = await mountSuspended(LayoutGridByCols, {
112
+ props: { styleClassPassthrough: "my-class" },
113
+ });
114
+ expect(wrapper.classes()).toContain("my-class");
115
+ });
116
+
117
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
118
+ const wrapper = await mountSuspended(LayoutGridByCols, {
119
+ props: { styleClassPassthrough: ["class-a", "class-b"] },
120
+ });
121
+ expect(wrapper.classes()).toContain("class-a");
122
+ expect(wrapper.classes()).toContain("class-b");
123
+ });
124
+
125
+ // ─── columnCount prop ─────────────────────────────────────────────────────
126
+
127
+ it("accepts columnCount without error", async () => {
128
+ const wrapper = await mountSuspended(LayoutGridByCols, {
129
+ props: { columnCount: 3 },
130
+ });
131
+ expect(wrapper.vm).toBeTruthy();
132
+ });
133
+
134
+ // ─── Other prop acceptance ────────────────────────────────────────────────
135
+
136
+ it("accepts gap prop without error", async () => {
137
+ const wrapper = await mountSuspended(LayoutGridByCols, {
138
+ props: { gap: "2rem" },
139
+ });
140
+ expect(wrapper.vm).toBeTruthy();
141
+ });
142
+
143
+ it("accepts singleColBelow prop without error", async () => {
144
+ const wrapper = await mountSuspended(LayoutGridByCols, {
145
+ props: { singleColBelow: "600px" },
146
+ });
147
+ expect(wrapper.vm).toBeTruthy();
148
+ });
149
+
150
+ // ─── Combined ────────────────────────────────────────────────────────────
151
+
152
+ it("renders correctly with all props and slots combined", async () => {
153
+ const wrapper = await mountSuspended(LayoutGridByCols, {
154
+ props: {
155
+ tag: "section",
156
+ label: "Services",
157
+ columnCount: 3,
158
+ gap: "2rem",
159
+ singleColBelow: "768px",
160
+ styleClassPassthrough: ["services-grid"],
161
+ },
162
+ slots: {
163
+ "item-0": "<p>One</p>",
164
+ "item-1": "<p>Two</p>",
165
+ "item-2": "<p>Three</p>",
166
+ },
167
+ });
168
+ expect(wrapper.element.tagName).toBe("SECTION");
169
+ expect(wrapper.classes()).toContain("services-grid");
170
+ expect(wrapper.find(".layout-grid-inner").exists()).toBe(true);
171
+ expect(wrapper.text()).toContain("One");
172
+ expect(wrapper.html()).toMatchSnapshot();
173
+ });
174
+ });
@@ -0,0 +1,36 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`LayoutGridByCols > renders correct HTML structure (div, 2 items) 1`] = `
4
+ "<div class="layout-grid">
5
+ <!--v-if-->
6
+ <div class="layout-grid-inner">
7
+ <p>First</p>
8
+ <p>Second</p>
9
+ </div>
10
+ </div>"
11
+ `;
12
+
13
+ exports[`LayoutGridByCols > renders correct HTML structure (section with label) 1`] = `
14
+ "<section class="layout-grid" aria-labelledby="v-0-0">
15
+ <p id="v-0-0" class="sr-only">Feature grid</p>
16
+ <div class="layout-grid-inner"></div>
17
+ </section>"
18
+ `;
19
+
20
+ exports[`LayoutGridByCols > renders correct HTML structure with styleClassPassthrough 1`] = `
21
+ "<div class="layout-grid custom-class another-class">
22
+ <!--v-if-->
23
+ <div class="layout-grid-inner"></div>
24
+ </div>"
25
+ `;
26
+
27
+ exports[`LayoutGridByCols > renders correctly with all props and slots combined 1`] = `
28
+ "<section class="layout-grid services-grid" aria-labelledby="v-0-0" columns="3">
29
+ <p id="v-0-0" class="sr-only">Services</p>
30
+ <div class="layout-grid-inner">
31
+ <p>One</p>
32
+ <p>Two</p>
33
+ <p>Three</p>
34
+ </div>
35
+ </section>"
36
+ `;
@@ -0,0 +1,36 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`LayoutGridByCols > renders correct HTML structure (div, 2 items) 1`] = `
4
+ "<div class="layout-grid-by-cols">
5
+ <!--v-if-->
6
+ <div class="layout-grid-inner">
7
+ <p>First</p>
8
+ <p>Second</p>
9
+ </div>
10
+ </div>"
11
+ `;
12
+
13
+ exports[`LayoutGridByCols > renders correct HTML structure (section with label) 1`] = `
14
+ "<section class="layout-grid-by-cols" aria-labelledby="v-0-0">
15
+ <p id="v-0-0" class="sr-only">Feature grid</p>
16
+ <div class="layout-grid-inner"></div>
17
+ </section>"
18
+ `;
19
+
20
+ exports[`LayoutGridByCols > renders correct HTML structure with styleClassPassthrough 1`] = `
21
+ "<div class="layout-grid-by-cols custom-class another-class">
22
+ <!--v-if-->
23
+ <div class="layout-grid-inner"></div>
24
+ </div>"
25
+ `;
26
+
27
+ exports[`LayoutGridByCols > renders correctly with all props and slots combined 1`] = `
28
+ "<section class="layout-grid-by-cols services-grid" aria-labelledby="v-0-0">
29
+ <p id="v-0-0" class="sr-only">Services</p>
30
+ <div class="layout-grid-inner">
31
+ <p>One</p>
32
+ <p>Two</p>
33
+ <p>Three</p>
34
+ </div>
35
+ </section>"
36
+ `;