srcdev-nuxt-components 9.0.8 → 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.
- package/.claude/skills/storybook-add-story.md +18 -0
- package/.claude/skills/testing-add-unit-test.md +38 -0
- package/app/assets/styles/setup/06.utility-classes/spacing/_fluid-spacing.css +13 -9
- package/app/components/01.atoms/text-block/TextBlock.vue +27 -0
- package/app/components/01.atoms/text-blocks/link-text/tests/__snapshots__/LinkText.spec.ts.snap +29 -0
- package/app/components/02.molecules/profile-section/ProfileSection.vue +54 -63
- package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +83 -1
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +53 -9
- package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +2 -4
- package/app/pages/ui/profile-section.vue +18 -0
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/* @link https://utopia.fyi/clamp/calculator?a=360,1240,1—2|2—4|4—8|6—12|8—16|10—20|12—24|16—32|20—40 */
|
|
2
2
|
|
|
3
3
|
:where(html) {
|
|
4
|
-
--fluid-1-2: clamp(0.0625rem, 0.0369rem + 0.1136vw, 0.125rem);
|
|
5
|
-
--fluid-2-4: clamp(0.125rem, 0.0739rem + 0.2273vw, 0.25rem);
|
|
6
|
-
--fluid-4-8: clamp(0.25rem, 0.1477rem + 0.4545vw, 0.5rem);
|
|
7
|
-
--fluid-6-12: clamp(0.375rem, 0.2216rem + 0.6818vw, 0.75rem);
|
|
8
|
-
--fluid-8-16: clamp(0.5rem, 0.2955rem + 0.9091vw, 1rem);
|
|
9
|
-
--fluid-10-20: clamp(0.625rem, 0.3693rem + 1.1364vw, 1.25rem);
|
|
10
|
-
--fluid-12-24: clamp(0.75rem, 0.4432rem + 1.3636vw, 1.5rem);
|
|
11
|
-
--fluid-16-32: clamp(1rem, 0.5909rem + 1.8182vw, 2rem);
|
|
12
|
-
--fluid-20-40: clamp(1.25rem, 0.7386rem + 2.2727vw, 2.5rem);
|
|
4
|
+
--fluid-space-1-2: clamp(0.0625rem, 0.0369rem + 0.1136vw, 0.125rem);
|
|
5
|
+
--fluid-space-2-4: clamp(0.125rem, 0.0739rem + 0.2273vw, 0.25rem);
|
|
6
|
+
--fluid-space-4-8: clamp(0.25rem, 0.1477rem + 0.4545vw, 0.5rem);
|
|
7
|
+
--fluid-space-6-12: clamp(0.375rem, 0.2216rem + 0.6818vw, 0.75rem);
|
|
8
|
+
--fluid-space-8-16: clamp(0.5rem, 0.2955rem + 0.9091vw, 1rem);
|
|
9
|
+
--fluid-space-10-20: clamp(0.625rem, 0.3693rem + 1.1364vw, 1.25rem);
|
|
10
|
+
--fluid-space-12-24: clamp(0.75rem, 0.4432rem + 1.3636vw, 1.5rem);
|
|
11
|
+
--fluid-space-16-32: clamp(1rem, 0.5909rem + 1.8182vw, 2rem);
|
|
12
|
+
--fluid-space-20-40: clamp(1.25rem, 0.7386rem + 2.2727vw, 2.5rem);
|
|
13
|
+
--fluid-space-24-48: clamp(1.5rem, 0.8863rem + 2.7273vw, 3rem);
|
|
14
|
+
--fluid-space-32-64: clamp(2rem, 1.1818rem + 3.6364vw, 4rem);
|
|
15
|
+
--fluid-space-40-80: clamp(2.5rem, 1.4773rem + 4.5455vw, 5rem);
|
|
16
|
+
--fluid-space-48-96: clamp(3rem, 1.7727rem + 5.4545vw, 6rem);
|
|
13
17
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="text-block" :class="[elementClasses]">
|
|
3
|
+
<slot name="default"></slot>
|
|
4
|
+
</component>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
interface Props {
|
|
9
|
+
tag?: "div" | "section" | "article" | "main";
|
|
10
|
+
styleClassPassthrough?: string | string[];
|
|
11
|
+
}
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
13
|
+
tag: "div",
|
|
14
|
+
styleClassPassthrough: () => [],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<style lang="css">
|
|
21
|
+
@layer components {
|
|
22
|
+
.text-block {
|
|
23
|
+
padding-block-start: var(--fluid-space-48-96);
|
|
24
|
+
padding-block-end: var(--fluid-space-48-96);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
</style>
|
package/app/components/01.atoms/text-blocks/link-text/tests/__snapshots__/LinkText.spec.ts.snap
ADDED
|
@@ -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
|
-
<
|
|
10
|
-
<
|
|
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
|
-
|
|
74
|
+
.profile-section {
|
|
75
|
+
/* .profile-section-header {
|
|
85
76
|
} */
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
.picture {
|
|
89
|
+
aspect-ratio: 3 / 4;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
overflow: hidden;
|
|
101
92
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
.profile-picture {
|
|
94
|
+
object-fit: cover;
|
|
95
|
+
width: 100%;
|
|
96
|
+
}
|
|
105
97
|
}
|
|
106
|
-
}
|
|
107
98
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
99
|
+
.profile-info {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 1.5rem;
|
|
103
|
+
height: stretch;
|
|
113
104
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
.profile-info-content {
|
|
106
|
+
.profile-info-block {
|
|
107
|
+
margin-block-end: 1.5rem;
|
|
117
108
|
|
|
118
|
-
|
|
109
|
+
/* .experience {
|
|
119
110
|
} */
|
|
120
111
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
.services {
|
|
121
|
+
.highlight {
|
|
122
|
+
color: var(--colour-link-default);
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
font-variation-settings: "wght" 600;
|
|
134
125
|
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
&:hover {
|
|
127
|
+
color: var(--colour-link-hover);
|
|
128
|
+
}
|
|
137
129
|
}
|
|
138
130
|
}
|
|
139
131
|
}
|
|
140
132
|
}
|
|
141
|
-
}
|
|
142
133
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|
package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|