srcdev-nuxt-components 9.6.2 → 9.6.3
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/rules/consumer/consumer-overrides.md +17 -0
- package/.claude/rules/consumer/css-comment-slash.md +13 -0
- package/.claude/rules/consumer/css-native-nesting.md +13 -0
- package/.claude/rules/consumer/css-no-deep.md +13 -0
- package/.claude/rules/consumer/css-rem-base.md +11 -0
- package/.claude/rules/consumer/css-surface-text-pairing.md +13 -0
- package/.claude/rules/consumer/css-webkit-support.md +15 -0
- package/.claude/rules/consumer/explicit-vue-imports.md +11 -0
- package/.claude/rules/consumer/nuxt-img-dimensions.md +12 -0
- package/.claude/rules/consumer/srcdev-skills-location.md +9 -0
- package/.claude/rules/consumer/story-args.md +12 -0
- package/.claude/rules/consumer/story-register-components.md +12 -0
- package/.claude/rules/consumer/vue-auto-imports.md +14 -0
- package/.claude/rules/consumer/vue-no-self-closing.md +11 -0
- package/.claude/rules/consumer/vue-prop-hyphenation.md +11 -0
- package/.claude/rules/consumer/vue-props-pattern.md +13 -0
- package/.claude/rules/consumer/vue-slots-no-computed.md +14 -0
- package/.claude/rules/consumer/vue-video-source.md +12 -0
- package/.claude/skills/component-compliance-checklist.md +23 -1
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/pricing-card.md +68 -84
- package/.claude/skills/index.md +9 -3
- package/.claude/skills/new-app-scaffold.md +4 -2
- package/.claude/skills/setup-postinstall.md +5 -4
- package/.claude/skills/storybook-add-story.md +36 -0
- package/.claude/skills/theming-component-token-pattern.md +46 -0
- package/.claude/skills/theming-partial-override.md +1 -1
- package/.vscode/srcdev-component-pricing-card.code-snippets +46 -84
- package/README.md +5 -5
- package/app/components/01.atoms/animations/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +2 -0
- package/app/components/01.atoms/animations/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +2 -0
- package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +4 -0
- package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +2 -0
- package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +2 -0
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +0 -1
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +96 -149
- package/app/components/02.molecules/pricing-card/PricingCard.vue +84 -44
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +241 -104
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +149 -123
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +4 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +4 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +1 -1
- package/app/composables/useAriaDescribedById.ts +1 -1
- package/package.json +2 -1
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
3
|
import PricingCard from "../PricingCard.vue";
|
|
4
4
|
|
|
5
|
+
const baseProps = { planName: "Test Plan", price: 100 };
|
|
6
|
+
|
|
5
7
|
describe("PricingCard", () => {
|
|
6
|
-
it("renders plan name, price, and CTA", () => {
|
|
7
|
-
const wrapper =
|
|
8
|
-
props: {
|
|
9
|
-
planName: "Single Site",
|
|
10
|
-
price: 99,
|
|
11
|
-
billingPeriod: "one-time",
|
|
12
|
-
ctaText: "Get started",
|
|
13
|
-
},
|
|
8
|
+
it("renders plan name, price, and CTA", async () => {
|
|
9
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
10
|
+
props: { planName: "Single Site", price: 99, billingPeriod: "one-time", ctaText: "Get started" },
|
|
14
11
|
});
|
|
15
12
|
|
|
16
13
|
expect(wrapper.text()).toContain("Single Site");
|
|
@@ -19,173 +16,202 @@ describe("PricingCard", () => {
|
|
|
19
16
|
expect(wrapper.text()).toContain("Get started");
|
|
20
17
|
});
|
|
21
18
|
|
|
22
|
-
it("uses custom currency symbol when provided", () => {
|
|
23
|
-
const wrapper =
|
|
24
|
-
props: {
|
|
25
|
-
planName: "Single Site",
|
|
26
|
-
price: 49,
|
|
27
|
-
currencySymbol: "£",
|
|
28
|
-
},
|
|
19
|
+
it("uses custom currency symbol when provided", async () => {
|
|
20
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
21
|
+
props: { planName: "Single Site", price: 49, currencySymbol: "£" },
|
|
29
22
|
});
|
|
30
23
|
|
|
31
24
|
expect(wrapper.text()).toContain("£49");
|
|
32
25
|
expect(wrapper.text()).not.toContain("$49");
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
28
|
+
describe("price display", () => {
|
|
29
|
+
it("shows whole numbers without decimals", async () => {
|
|
30
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 1500 } });
|
|
31
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$1500");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("shows fractional prices to two decimal places", async () => {
|
|
35
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 9.5 } });
|
|
36
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$9.50");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps zero and negative prices", async () => {
|
|
40
|
+
const zero = await mountSuspended(PricingCard, { props: { ...baseProps, price: 0 } });
|
|
41
|
+
expect(zero.find(".pricing-card__amount").text()).toBe("$0");
|
|
42
|
+
|
|
43
|
+
const negative = await mountSuspended(PricingCard, { props: { ...baseProps, price: -20 } });
|
|
44
|
+
expect(negative.find(".pricing-card__amount").text()).toBe("$-20");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shows only the currency symbol for a non-finite price", async () => {
|
|
48
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: Number.NaN } });
|
|
49
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("updates when the price prop changes", async () => {
|
|
53
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 10 } });
|
|
54
|
+
await wrapper.setProps({ price: 12.25 });
|
|
55
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$12.25");
|
|
43
56
|
});
|
|
57
|
+
});
|
|
44
58
|
|
|
59
|
+
it("renders features from props", async () => {
|
|
60
|
+
const features = ["Feature 1", "Feature 2", "Feature 3"];
|
|
61
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, features } });
|
|
62
|
+
|
|
63
|
+
expect(wrapper.findAll(".pricing-card__feature")).toHaveLength(3);
|
|
45
64
|
features.forEach((feature) => {
|
|
46
65
|
expect(wrapper.text()).toContain(feature);
|
|
47
66
|
});
|
|
48
67
|
});
|
|
49
68
|
|
|
50
|
-
it("renders
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
props: {
|
|
54
|
-
planName: "Test Plan",
|
|
55
|
-
price: 100,
|
|
56
|
-
description,
|
|
57
|
-
},
|
|
69
|
+
it("renders HTML-like text as text", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
71
|
+
props: { ...baseProps, planName: "<b>Bold</b>", features: ["<script>alert(1)</script>"] },
|
|
58
72
|
});
|
|
59
73
|
|
|
60
|
-
expect(wrapper.
|
|
74
|
+
expect(wrapper.find(".pricing-card__name b").exists()).toBe(false);
|
|
75
|
+
expect(wrapper.find(".pricing-card__name").text()).toBe("<b>Bold</b>");
|
|
76
|
+
expect(wrapper.find("script").exists()).toBe(false);
|
|
61
77
|
});
|
|
62
78
|
|
|
63
|
-
it("
|
|
64
|
-
const
|
|
65
|
-
props: {
|
|
66
|
-
planName: "Test Plan",
|
|
67
|
-
price: 100,
|
|
68
|
-
isHighlighted: true,
|
|
69
|
-
},
|
|
79
|
+
it("renders description when provided and omits it otherwise", async () => {
|
|
80
|
+
const withDescription = await mountSuspended(PricingCard, {
|
|
81
|
+
props: { ...baseProps, description: "This is a great plan" },
|
|
70
82
|
});
|
|
83
|
+
expect(withDescription.find(".pricing-card__description").text()).toBe("This is a great plan");
|
|
84
|
+
|
|
85
|
+
const without = await mountSuspended(PricingCard, { props: baseProps });
|
|
86
|
+
expect(without.find(".pricing-card__description").exists()).toBe(false);
|
|
87
|
+
});
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
it("omits the billing period when empty", async () => {
|
|
90
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, billingPeriod: "" } });
|
|
91
|
+
expect(wrapper.find(".pricing-card__period").exists()).toBe(false);
|
|
73
92
|
});
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
props: {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
},
|
|
94
|
+
describe("highlight", () => {
|
|
95
|
+
it("applies highlighted class and default badge", async () => {
|
|
96
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, isHighlighted: true } });
|
|
97
|
+
|
|
98
|
+
expect(wrapper.classes()).toContain("is-highlighted");
|
|
99
|
+
expect(wrapper.find(".pricing-card__badge").text()).toBe("Most Popular");
|
|
82
100
|
});
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
it("renders custom badge text", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
104
|
+
props: { ...baseProps, isHighlighted: true, badgeText: "Le plus populaire" },
|
|
105
|
+
});
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
const wrapper = mount(PricingCard, {
|
|
89
|
-
props: {
|
|
90
|
-
planName: "Test Plan",
|
|
91
|
-
price: 100,
|
|
92
|
-
isHighlighted: true,
|
|
93
|
-
badgeText: "Le plus populaire",
|
|
94
|
-
},
|
|
107
|
+
expect(wrapper.find(".pricing-card__badge").text()).toBe("Le plus populaire");
|
|
95
108
|
});
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
it("has no badge when not highlighted", async () => {
|
|
111
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
112
|
+
expect(wrapper.find(".pricing-card__badge").exists()).toBe(false);
|
|
113
|
+
expect(wrapper.classes()).not.toContain("is-highlighted");
|
|
114
|
+
});
|
|
98
115
|
});
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
props:
|
|
103
|
-
planName: "Test Plan",
|
|
104
|
-
price: 100,
|
|
105
|
-
},
|
|
106
|
-
});
|
|
117
|
+
describe("CTA", () => {
|
|
118
|
+
it("emits select event with plan name on click", async () => {
|
|
119
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
await button.trigger("click");
|
|
121
|
+
await wrapper.find("button").trigger("click");
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
123
|
+
expect(wrapper.emitted("select")?.[0]).toEqual(["Test Plan"]);
|
|
124
|
+
});
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
},
|
|
126
|
+
it("disables CTA when ctaDisabled is true", async () => {
|
|
127
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, ctaDisabled: true } });
|
|
128
|
+
|
|
129
|
+
const button = wrapper.find("button");
|
|
130
|
+
expect(button.attributes("readonly")).toBeDefined();
|
|
131
|
+
expect(button.attributes("aria-disabled")).toBe("true");
|
|
122
132
|
});
|
|
123
133
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
it("passes scope to the cta slot", async () => {
|
|
135
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
136
|
+
props: { ...baseProps, ctaText: "Choose", ctaDisabled: true },
|
|
137
|
+
slots: {
|
|
138
|
+
cta: `<template #cta="{ ctaText, isDisabled, planName, onSelect }">
|
|
139
|
+
<button class="custom-cta" :data-disabled="isDisabled" :data-plan="planName" @click="onSelect">{{ ctaText }}</button>
|
|
140
|
+
</template>`,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const button = wrapper.find(".custom-cta");
|
|
145
|
+
expect(button.text()).toBe("Choose");
|
|
146
|
+
expect(button.attributes("data-disabled")).toBe("true");
|
|
147
|
+
expect(button.attributes("data-plan")).toBe("Test Plan");
|
|
148
|
+
await button.trigger("click");
|
|
149
|
+
expect(wrapper.emitted("select")?.[0]).toEqual(["Test Plan"]);
|
|
150
|
+
});
|
|
127
151
|
});
|
|
128
152
|
|
|
129
|
-
it("renders custom features via slot", () => {
|
|
130
|
-
const wrapper =
|
|
131
|
-
props:
|
|
132
|
-
|
|
133
|
-
price: 100,
|
|
134
|
-
},
|
|
135
|
-
slots: {
|
|
136
|
-
features: '<li>Custom Feature 1</li><li>Custom Feature 2</li>',
|
|
137
|
-
},
|
|
153
|
+
it("renders custom features via slot", async () => {
|
|
154
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
155
|
+
props: baseProps,
|
|
156
|
+
slots: { features: "<li>Custom Feature 1</li><li>Custom Feature 2</li>" },
|
|
138
157
|
});
|
|
139
158
|
|
|
140
159
|
expect(wrapper.text()).toContain("Custom Feature 1");
|
|
141
160
|
expect(wrapper.text()).toContain("Custom Feature 2");
|
|
142
161
|
});
|
|
143
162
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
props:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
describe("semantics", () => {
|
|
164
|
+
it("uses an article with an h3 by default", async () => {
|
|
165
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
166
|
+
|
|
167
|
+
expect(wrapper.element.tagName).toBe("ARTICLE");
|
|
168
|
+
expect(wrapper.find(".pricing-card__name").element.tagName).toBe("H3");
|
|
150
169
|
});
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
it("allows custom tag and heading level", async () => {
|
|
172
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
173
|
+
props: { ...baseProps, tag: "section", headingTag: "h2" },
|
|
174
|
+
});
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
props: {
|
|
158
|
-
planName: "Test Plan",
|
|
159
|
-
price: 100,
|
|
160
|
-
tag: "section",
|
|
161
|
-
},
|
|
176
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
177
|
+
expect(wrapper.find(".pricing-card__name").element.tagName).toBe("H2");
|
|
162
178
|
});
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
it("labels the root with the plan name heading", async () => {
|
|
181
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
182
|
+
|
|
183
|
+
const headingId = wrapper.find(".pricing-card__name").attributes("id");
|
|
184
|
+
expect(headingId).toBeTruthy();
|
|
185
|
+
expect(wrapper.attributes("aria-labelledby")).toBe(headingId);
|
|
186
|
+
});
|
|
165
187
|
});
|
|
166
188
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
props:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
describe("ribbon", () => {
|
|
190
|
+
it("does not render a ribbon when ribbonText is unset", async () => {
|
|
191
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
192
|
+
|
|
193
|
+
expect(wrapper.find(".pricing-card__ribbon-clip").exists()).toBe(false);
|
|
194
|
+
expect(wrapper.classes()).not.toContain("has-ribbon");
|
|
173
195
|
});
|
|
174
196
|
|
|
175
|
-
|
|
197
|
+
it("renders ribbon text and flags the root when ribbonText is provided", async () => {
|
|
198
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
199
|
+
props: { ...baseProps, ribbonText: "EARLYBIRD50 · 50% off" },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(wrapper.find(".pricing-card__ribbon").text()).toBe("EARLYBIRD50 · 50% off");
|
|
203
|
+
expect(wrapper.classes()).toContain("has-ribbon");
|
|
204
|
+
});
|
|
176
205
|
});
|
|
177
206
|
|
|
178
|
-
it("
|
|
179
|
-
const wrapper =
|
|
180
|
-
props: {
|
|
181
|
-
planName: "Test Plan",
|
|
182
|
-
price: 100,
|
|
183
|
-
ribbonText: "EARLYBIRD50 · 50% off",
|
|
184
|
-
},
|
|
207
|
+
it("applies and updates styleClassPassthrough classes", async () => {
|
|
208
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
209
|
+
props: { ...baseProps, styleClassPassthrough: ["custom-a"] },
|
|
185
210
|
});
|
|
211
|
+
expect(wrapper.classes()).toContain("custom-a");
|
|
186
212
|
|
|
187
|
-
|
|
188
|
-
expect(
|
|
189
|
-
expect(
|
|
213
|
+
await wrapper.setProps({ styleClassPassthrough: ["custom-b"] });
|
|
214
|
+
expect(wrapper.classes()).toContain("custom-b");
|
|
215
|
+
expect(wrapper.classes()).not.toContain("custom-a");
|
|
190
216
|
});
|
|
191
217
|
});
|
|
@@ -87,6 +87,10 @@ export default {
|
|
|
87
87
|
description: "This is a toast notification message.",
|
|
88
88
|
customIcon: "",
|
|
89
89
|
},
|
|
90
|
+
parameters: {
|
|
91
|
+
// Own iframe on the Docs page: each story opens a viewport-fixed toast on mount.
|
|
92
|
+
docs: { story: { inline: false, height: "320px" } },
|
|
93
|
+
},
|
|
90
94
|
} as Meta<ToastStoryArgs>;
|
|
91
95
|
|
|
92
96
|
const Template: StoryFn<ToastStoryArgs> = (args) => ({
|
|
@@ -74,6 +74,10 @@ export default {
|
|
|
74
74
|
autoDismiss: true,
|
|
75
75
|
duration: 4000,
|
|
76
76
|
},
|
|
77
|
+
parameters: {
|
|
78
|
+
// Own iframe on the Docs page: toasts are fixed to the viewport.
|
|
79
|
+
docs: { story: { inline: false, height: "400px" } },
|
|
80
|
+
},
|
|
77
81
|
} as Meta<StoryArgs>;
|
|
78
82
|
|
|
79
83
|
const Template: StoryFn<StoryArgs> = (args) => ({
|
|
@@ -87,7 +87,7 @@ const descriptionId = computed(() => `${id}-description`);
|
|
|
87
87
|
const errorId = computed(() => `${id}-error-message`);
|
|
88
88
|
|
|
89
89
|
// A plain function, like useAriaDescribedById's: slots aren't reactive, so a computed would keep
|
|
90
|
-
// its first answer if a description slot is added or removed after mount (
|
|
90
|
+
// its first answer if a description slot is added or removed after mount (CLAUDE.md pitfall #25).
|
|
91
91
|
// Lists both ids when there's a description and an error, so the description is still announced
|
|
92
92
|
// while the field is invalid.
|
|
93
93
|
const ariaDescribedby = () => {
|
|
@@ -7,7 +7,7 @@ export function useAriaDescribedById(name: string | Ref<string>, fieldHasError:
|
|
|
7
7
|
|
|
8
8
|
// A plain function, not a computed: the slots object isn't reactive, so a computed would cache
|
|
9
9
|
// its first answer and keep pointing at (or away from) the description after a description slot
|
|
10
|
-
// is added or removed post-mount (
|
|
10
|
+
// is added or removed post-mount (CLAUDE.md pitfall #25). Call it from the template,
|
|
11
11
|
// `:aria-describedby="ariaDescribedby()"`, so it's re-evaluated on every render.
|
|
12
12
|
const ariaDescribedby = () => {
|
|
13
13
|
const hasDescription = slots.descriptionText || slots.descriptionHtml || slots.description;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "srcdev-nuxt-components",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.6.
|
|
4
|
+
"version": "9.6.3",
|
|
5
5
|
"main": "nuxt.config.ts",
|
|
6
6
|
"types": "types.d.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
".claude/skills/",
|
|
39
|
+
".claude/rules/consumer/",
|
|
39
40
|
".vscode/",
|
|
40
41
|
"app/",
|
|
41
42
|
"modules/",
|