srcdev-nuxt-components 9.0.9 → 9.0.10

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.
@@ -156,6 +156,24 @@ Playwright `STORY_BASE` slug. The slug is derived by lowercasing and replacing s
156
156
  | Enum / union | `{ type: "select" }` + `options` |
157
157
  | Array / object | `"object"` |
158
158
 
159
+ ## Scoped slots
160
+
161
+ When a component exposes data via slot props (e.g. an internally-generated `headingId`),
162
+ use the scoped slot destructuring syntax directly in the inline template string:
163
+
164
+ ```ts
165
+ template: `
166
+ <ComponentName v-bind="args">
167
+ <template #heroText="{ headingId }">
168
+ <HeroText :id="headingId" tag="h2" ... />
169
+ </template>
170
+ </ComponentName>
171
+ `,
172
+ ```
173
+
174
+ This keeps the ID wiring self-contained inside the component — the parent story just
175
+ consumes what the slot exposes, rather than generating its own ID.
176
+
159
177
  ## Notes
160
178
 
161
179
  - Use `table: { category: "..." }` in `argTypes` when a component has many props — it groups
@@ -107,6 +107,19 @@ describe("ComponentName", () => {
107
107
  wrapper = await createWrapper({}, { default: "<span>Content</span>" });
108
108
  expect(wrapper.find("span").exists()).toBe(true);
109
109
  });
110
+
111
+ it("exposes scoped slot prop and it matches the rendered element", async () => {
112
+ // Import h from vue at the top of the file (not auto-imported in test files)
113
+ wrapper = await mountSuspended(ComponentName, {
114
+ props: { ...defaultProps },
115
+ slots: {
116
+ slotName: (props: Record<string, unknown>) =>
117
+ h("h2", { id: props.exposedId, class: "target" }, "Content"),
118
+ },
119
+ });
120
+ const attrValue = wrapper.find(".component-name").attributes("aria-labelledby");
121
+ expect(wrapper.find(".target").attributes("id")).toBe(attrValue);
122
+ });
110
123
  });
111
124
 
112
125
  // -------------------------
@@ -121,6 +134,31 @@ describe("ComponentName", () => {
121
134
  });
122
135
  ```
123
136
 
137
+ ## Scoped slots
138
+
139
+ When a component exposes data via slot props (e.g. an internally-generated `headingId`),
140
+ import `h` from `vue` explicitly — it is **not** auto-imported in test files — and use a
141
+ render function as the slot value so VTU receives a real VNode:
142
+
143
+ ```ts
144
+ import { h } from "vue";
145
+
146
+ it("exposes headingId via scoped slot", async () => {
147
+ const wrapper = await mountSuspended(ProfileSection, {
148
+ props: { tag: "section", ...defaultProps },
149
+ slots: {
150
+ heroText: (props: Record<string, unknown>) =>
151
+ h("h2", { id: props.headingId, class: "hero-heading" }, "Heading"),
152
+ },
153
+ });
154
+ const labelledBy = wrapper.find(".profile-section").attributes("aria-labelledby");
155
+ expect(labelledBy).toBeTruthy();
156
+ expect(wrapper.find(".hero-heading").attributes("id")).toBe(labelledBy);
157
+ });
158
+ ```
159
+
160
+ > Returning a plain string from a slot function does **not** produce DOM — always use `h()`.
161
+
124
162
  ## Key rules
125
163
 
126
164
  - Always `mountSuspended` — never `mount` or `shallowMount` from `@vue/test-utils` directly.
@@ -20,8 +20,8 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
20
20
  <style lang="css">
21
21
  @layer components {
22
22
  .text-block {
23
- padding-block-start: var(--fluid-40-80);
24
- padding-block-end: var(--fluid-40-80);
23
+ padding-block-start: var(--fluid-space-48-96);
24
+ padding-block-end: var(--fluid-space-48-96);
25
25
  }
26
26
  }
27
27
  </style>
@@ -0,0 +1,29 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`LinkText > renders correct HTML structure with all props set 1`] = `
4
+ "<a href="https://example.com" rel="noopener noreferrer" target="_blank" class="link-text custom-class">
5
+ <!--v-if--><span class="link-text__label">Visit Us</span>
6
+ <!--v-if-->
7
+ </a>"
8
+ `;
9
+
10
+ exports[`LinkText > renders correct HTML structure with both slots 1`] = `"<a href="/about" class="link-text"><span class="link-text__icon link-text__icon--left"><svg data-testid="icon-left"></svg></span><span class="link-text__label">About Us</span><span class="link-text__icon link-text__icon--right"><svg data-testid="icon-right"></svg></span></a>"`;
11
+
12
+ exports[`LinkText > renders correct HTML structure with default props 1`] = `
13
+ "<a href="/about" class="link-text">
14
+ <!--v-if--><span class="link-text__label">About Us</span>
15
+ <!--v-if-->
16
+ </a>"
17
+ `;
18
+
19
+ exports[`LinkText > renders correct HTML structure with left slot 1`] = `
20
+ "<a href="/about" class="link-text"><span class="link-text__icon link-text__icon--left"><svg data-testid="icon-left"></svg></span><span class="link-text__label">About Us</span>
21
+ <!--v-if-->
22
+ </a>"
23
+ `;
24
+
25
+ exports[`LinkText > renders correct HTML structure with right slot 1`] = `
26
+ "<a href="/about" class="link-text">
27
+ <!--v-if--><span class="link-text__label">About Us</span><span class="link-text__icon link-text__icon--right"><svg data-testid="icon-right"></svg></span>
28
+ </a>"
29
+ `;
@@ -6,19 +6,8 @@
6
6
  :aria-labelledby="needsLabel ? headingId : undefined"
7
7
  >
8
8
  <header class="profile-section-header">
9
- <EyebrowText tag="p" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
10
- <HeroText
11
- :id="headingId"
12
- tag="h2"
13
- axis="vertical"
14
- font-size="display"
15
- :text-content="[
16
- { text: 'Your', styleClass: 'normal' },
17
- { text: 'mobile hairdresser', styleClass: 'accent' },
18
- { text: 'in Bath', styleClass: 'normal' },
19
- ]"
20
- :style-class-passthrough="['mb-20']"
21
- />
9
+ <slot v-if="hasEyebrowTextSlot" name="eyebrowText"></slot>
10
+ <slot v-if="hasHeroTextSlot" name="heroText" :heading-id="headingId"></slot>
22
11
  </header>
23
12
 
24
13
  <div class="profile-section-inner">
@@ -66,6 +55,8 @@ const headingId = useId();
66
55
  const needsLabel = computed(() => props.tag === "section" || props.tag === "article");
67
56
 
68
57
  const slots = useSlots();
58
+ const hasEyebrowTextSlot = computed(() => Boolean(slots.eyebrowText));
59
+ const hasHeroTextSlot = computed(() => Boolean(slots.heroText));
69
60
  const hasProfileLinksSlot = computed(() => Boolean(slots.profileLinks));
70
61
 
71
62
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -80,75 +71,75 @@ watch(
80
71
 
81
72
  <style lang="css">
82
73
  @layer components {
83
- .profile-section {
84
- /* .profile-section-header {
74
+ .profile-section {
75
+ /* .profile-section-header {
85
76
  } */
86
- .profile-section-inner {
87
- display: grid;
88
- grid-template-columns: 1fr;
89
- gap: 2rem;
90
-
91
- @media (min-width: 768px) {
92
- grid-template-columns: 384px 1fr;
93
- align-items: start;
94
- gap: 4rem;
95
- }
77
+ .profile-section-inner {
78
+ display: grid;
79
+ grid-template-columns: 1fr;
80
+ gap: 2rem;
81
+
82
+ @media (min-width: 768px) {
83
+ grid-template-columns: 384px 1fr;
84
+ align-items: start;
85
+ gap: 4rem;
86
+ }
96
87
 
97
- .picture {
98
- aspect-ratio: 3 / 4;
99
- border-radius: 8px;
100
- overflow: hidden;
88
+ .picture {
89
+ aspect-ratio: 3 / 4;
90
+ border-radius: 8px;
91
+ overflow: hidden;
101
92
 
102
- .profile-picture {
103
- object-fit: cover;
104
- width: 100%;
93
+ .profile-picture {
94
+ object-fit: cover;
95
+ width: 100%;
96
+ }
105
97
  }
106
- }
107
98
 
108
- .profile-info {
109
- display: flex;
110
- flex-direction: column;
111
- gap: 1.5rem;
112
- height: stretch;
99
+ .profile-info {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 1.5rem;
103
+ height: stretch;
113
104
 
114
- .profile-info-content {
115
- .profile-info-block {
116
- margin-block-end: 1.5rem;
105
+ .profile-info-content {
106
+ .profile-info-block {
107
+ margin-block-end: 1.5rem;
117
108
 
118
- /* .experience {
109
+ /* .experience {
119
110
  } */
120
111
 
121
- .location {
122
- .highlight {
123
- color: var(--colour-text-accent);
124
- font-weight: 600;
125
- font-variation-settings: "wght" 600;
112
+ .location {
113
+ .highlight {
114
+ color: var(--colour-text-accent);
115
+ font-weight: 600;
116
+ font-variation-settings: "wght" 600;
117
+ }
126
118
  }
127
- }
128
119
 
129
- .services {
130
- .highlight {
131
- color: var(--colour-link-default);
132
- font-weight: 600;
133
- font-variation-settings: "wght" 600;
120
+ .services {
121
+ .highlight {
122
+ color: var(--colour-link-default);
123
+ font-weight: 600;
124
+ font-variation-settings: "wght" 600;
134
125
 
135
- &:hover {
136
- color: var(--colour-link-hover);
126
+ &:hover {
127
+ color: var(--colour-link-hover);
128
+ }
137
129
  }
138
130
  }
139
131
  }
140
132
  }
141
- }
142
133
 
143
- .profile-links {
144
- display: flex;
145
- flex-grow: 1;
146
- gap: 1rem;
147
- align-items: end;
148
- justify-content: flex-end;
134
+ .profile-links {
135
+ display: flex;
136
+ flex-grow: 1;
137
+ gap: 1rem;
138
+ align-items: end;
139
+ justify-content: flex-end;
140
+ }
149
141
  }
150
142
  }
151
143
  }
152
144
  }
153
- }
154
145
  </style>
@@ -39,7 +39,7 @@ type Story = StoryObj<typeof ProfileSection>;
39
39
 
40
40
  // ─── Stories ─────────────────────────────────────────────────────────────────
41
41
 
42
- /** Default — three profile-info blocks with representative content. */
42
+ /** Default — three profile-info blocks with eyebrow and hero text header slots. */
43
43
  export const Default: Story = {
44
44
  render: (args) => ({
45
45
  components: { ProfileSection },
@@ -48,6 +48,23 @@ export const Default: Story = {
48
48
  },
49
49
  template: `
50
50
  <ProfileSection v-bind="args">
51
+ <template #eyebrowText>
52
+ <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
53
+ </template>
54
+ <template #heroText="{ headingId }">
55
+ <HeroText
56
+ :id="headingId"
57
+ tag="h2"
58
+ axis="vertical"
59
+ font-size="display"
60
+ :text-content="[
61
+ { text: 'Your', styleClass: 'normal' },
62
+ { text: 'mobile hairdresser', styleClass: 'accent' },
63
+ { text: 'in Bath', styleClass: 'normal' },
64
+ ]"
65
+ :style-class-passthrough="['mb-20']"
66
+ />
67
+ </template>
51
68
  <template #profile-info-1>
52
69
  <div class="experience">
53
70
  <p class="page-body-normal">With over 10 years of experience as a mobile hairdresser, Natasha brings the salon to you — whether you're at home, in the office, or getting ready for a special occasion.</p>
@@ -78,6 +95,23 @@ export const WithProfileLinks: Story = {
78
95
  },
79
96
  template: `
80
97
  <ProfileSection v-bind="args">
98
+ <template #eyebrowText>
99
+ <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
100
+ </template>
101
+ <template #heroText="{ headingId }">
102
+ <HeroText
103
+ :id="headingId"
104
+ tag="h2"
105
+ axis="vertical"
106
+ font-size="display"
107
+ :text-content="[
108
+ { text: 'Your', styleClass: 'normal' },
109
+ { text: 'mobile hairdresser', styleClass: 'accent' },
110
+ { text: 'in Bath', styleClass: 'normal' },
111
+ ]"
112
+ :style-class-passthrough="['mb-20']"
113
+ />
114
+ </template>
81
115
  <template #profile-info-1>
82
116
  <div class="experience">
83
117
  <p class="page-body-normal">With over 10 years of experience as a mobile hairdresser, Natasha brings the salon to you.</p>
@@ -102,6 +136,30 @@ export const WithProfileLinks: Story = {
102
136
  }),
103
137
  };
104
138
 
139
+ /** No header slots — renders without eyebrow or hero text. */
140
+ export const WithoutHeader: Story = {
141
+ name: "Without Header Slots",
142
+ render: (args) => ({
143
+ components: { ProfileSection },
144
+ setup() {
145
+ return { args };
146
+ },
147
+ template: `
148
+ <ProfileSection v-bind="args">
149
+ <template #profile-info-1>
150
+ <p class="page-body-normal">Profile information block one.</p>
151
+ </template>
152
+ <template #profile-info-2>
153
+ <p class="page-body-normal">Profile information block two.</p>
154
+ </template>
155
+ <template #profile-info-3>
156
+ <p class="page-body-normal">Profile information block three.</p>
157
+ </template>
158
+ </ProfileSection>
159
+ `,
160
+ }),
161
+ };
162
+
105
163
  /** Section tag — renders the root as a semantic section element. */
106
164
  export const AsSectionTag: Story = {
107
165
  name: "As section Tag",
@@ -115,6 +173,18 @@ export const AsSectionTag: Story = {
115
173
  },
116
174
  template: `
117
175
  <ProfileSection v-bind="args">
176
+ <template #eyebrowText>
177
+ <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
178
+ </template>
179
+ <template #heroText="{ headingId }">
180
+ <HeroText
181
+ :id="headingId"
182
+ tag="h2"
183
+ axis="horizontal"
184
+ font-size="large"
185
+ :text-content="[{ text: 'Profile section heading', styleClass: 'normal' }]"
186
+ />
187
+ </template>
118
188
  <template #profile-info-1>
119
189
  <p class="page-body-normal">Profile information block one.</p>
120
190
  </template>
@@ -142,6 +212,18 @@ export const CustomInfoCount: Story = {
142
212
  },
143
213
  template: `
144
214
  <ProfileSection v-bind="args">
215
+ <template #eyebrowText>
216
+ <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
217
+ </template>
218
+ <template #heroText="{ headingId }">
219
+ <HeroText
220
+ :id="headingId"
221
+ tag="h2"
222
+ axis="horizontal"
223
+ font-size="large"
224
+ :text-content="[{ text: 'Profile section heading', styleClass: 'normal' }]"
225
+ />
226
+ </template>
145
227
  <template #profile-info-1>
146
228
  <p class="page-body-normal">First block of information about this person.</p>
147
229
  </template>
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { h } from "vue";
2
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
4
  import ProfileSection from "../ProfileSection.vue";
4
5
 
@@ -79,26 +80,69 @@ describe("ProfileSection", () => {
79
80
  expect(wrapper.html()).toContain("Second info block");
80
81
  });
81
82
 
82
- it("adds aria-labelledby referencing the heading id when tag is section", async () => {
83
+ it("does not render eyebrowText slot area when slot is not provided", async () => {
84
+ const wrapper = await mountSuspended(ProfileSection, {
85
+ props: defaultProps,
86
+ });
87
+ expect(wrapper.html()).not.toContain("eyebrow");
88
+ });
89
+
90
+ it("renders eyebrowText slot content when provided", async () => {
91
+ const wrapper = await mountSuspended(ProfileSection, {
92
+ props: defaultProps,
93
+ slots: {
94
+ eyebrowText: "<p class='eyebrow'>About Natasha</p>",
95
+ },
96
+ });
97
+ expect(wrapper.find(".eyebrow").exists()).toBe(true);
98
+ expect(wrapper.html()).toContain("About Natasha");
99
+ });
100
+
101
+ it("does not render heroText slot area when slot is not provided", async () => {
102
+ const wrapper = await mountSuspended(ProfileSection, {
103
+ props: defaultProps,
104
+ });
105
+ expect(wrapper.find("header h1, header h2, header h3, header h4, header h5, header h6").exists()).toBe(false);
106
+ });
107
+
108
+ it("renders heroText slot content when provided", async () => {
109
+ const wrapper = await mountSuspended(ProfileSection, {
110
+ props: defaultProps,
111
+ slots: {
112
+ heroText: "<h2 class='hero-heading'>My Heading</h2>",
113
+ },
114
+ });
115
+ expect(wrapper.find(".hero-heading").exists()).toBe(true);
116
+ expect(wrapper.html()).toContain("My Heading");
117
+ });
118
+
119
+ it("adds aria-labelledby when tag is section", async () => {
83
120
  const wrapper = await mountSuspended(ProfileSection, {
84
121
  props: { ...defaultProps, tag: "section" },
85
122
  });
86
123
  const root = wrapper.find(".profile-section");
87
- const labelledBy = root.attributes("aria-labelledby");
88
- expect(labelledBy).toBeTruthy();
89
- const heading = wrapper.find(".hero-text");
90
- expect(heading.attributes("id")).toBe(labelledBy);
124
+ expect(root.attributes("aria-labelledby")).toBeTruthy();
91
125
  });
92
126
 
93
- it("adds aria-labelledby referencing the heading id when tag is article", async () => {
127
+ it("adds aria-labelledby when tag is article", async () => {
94
128
  const wrapper = await mountSuspended(ProfileSection, {
95
129
  props: { ...defaultProps, tag: "article" },
96
130
  });
97
131
  const root = wrapper.find(".profile-section");
98
- const labelledBy = root.attributes("aria-labelledby");
132
+ expect(root.attributes("aria-labelledby")).toBeTruthy();
133
+ });
134
+
135
+ it("exposes headingId via heroText scoped slot and aria-labelledby matches the heading id", async () => {
136
+ const wrapper = await mountSuspended(ProfileSection, {
137
+ props: { ...defaultProps, tag: "section" },
138
+ slots: {
139
+ heroText: (props: Record<string, unknown>) =>
140
+ h("h2", { id: props.headingId, class: "hero-heading" }, "My Heading"),
141
+ },
142
+ });
143
+ const labelledBy = wrapper.find(".profile-section").attributes("aria-labelledby");
99
144
  expect(labelledBy).toBeTruthy();
100
- const heading = wrapper.find(".hero-text");
101
- expect(heading.attributes("id")).toBe(labelledBy);
145
+ expect(wrapper.find(".hero-heading").attributes("id")).toBe(labelledBy);
102
146
  });
103
147
 
104
148
  it("does not add aria-labelledby when tag is div", async () => {
@@ -3,10 +3,8 @@
3
3
  exports[`ProfileSection > renders correct HTML structure 1`] = `
4
4
  "<div class="profile-section custom-class">
5
5
  <header class="profile-section-header">
6
- <p class="eyebrow-text mb-0 medium">About Natasha</p>
7
- <h2 id="v-0-0" class="hero-text mb-20 display axis-vertical">
8
- <!--v-if--><span class="text-block-0 normal">Your </span><span class="text-block-1 accent">mobile hairdresser </span><span class="text-block-2 normal">in Bath</span>
9
- </h2>
6
+ <!--v-if-->
7
+ <!--v-if-->
10
8
  </header>
11
9
  <div class="profile-section-inner">
12
10
  <div class="picture"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test profile picture" class="profile-picture" src="/_ipx/_/images/test.jpg"></div>
@@ -14,6 +14,24 @@
14
14
  }"
15
15
  :profile-info-count="3"
16
16
  >
17
+ <template #eyebrowText>
18
+ <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
19
+ </template>
20
+ <template #heroText="{ headingId }">
21
+ <HeroText
22
+ :id="headingId"
23
+ tag="h2"
24
+ axis="vertical"
25
+ font-size="display"
26
+ :text-content="[
27
+ { text: 'Your', styleClass: 'normal' },
28
+ { text: 'mobile hairdresser', styleClass: 'accent' },
29
+ { text: 'in Bath', styleClass: 'normal' },
30
+ ]"
31
+ :style-class-passthrough="['mb-20']"
32
+ />
33
+ </template>
34
+
17
35
  <template #profile-info-1>
18
36
  <p class="page-body-medium experience">
19
37
  With over 10 years' experience, I offer a range of professional and luxurious, one-to-one hairdressing
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.0.9",
4
+ "version": "9.0.10",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",