srcdev-nuxt-components 9.6.4 → 9.6.6
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/a11y-no-focus-steal.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +14 -2
- package/.claude/skills/components/display-toast.md +6 -3
- package/.claude/skills/components/progress-tracker.md +140 -0
- package/.claude/skills/components/social-icons-list.md +32 -5
- package/.claude/skills/components/stepper-list.md +10 -3
- package/.claude/skills/components/tabbed-content.md +4 -0
- package/.claude/skills/css-dynamic-border-radius.md +114 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +12 -0
- package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
- package/.vscode/srcdev-component-progress-tracker.code-snippets +45 -0
- package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
- package/.vscode/srcdev-component-stepper-list.code-snippets +3 -2
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -0
- package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
- package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
- package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +2 -2
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +26 -0
- package/app/components/02.molecules/contact-section/tests/__snapshots__/ContactSection.spec.ts.snap +28 -28
- package/app/components/02.molecules/progress-tracker/CONSUMER-STYLING.md +116 -0
- package/app/components/02.molecules/progress-tracker/ProgressTracker.vue +275 -0
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +424 -0
- package/app/components/02.molecules/progress-tracker/tests/ProgressTracker.spec.ts +253 -0
- package/app/components/02.molecules/progress-tracker/tests/__snapshots__/ProgressTracker.spec.ts.snap +46 -0
- package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
- package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
- package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
- package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
- package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
- package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +42 -7
- package/app/components/02.molecules/stepper-list/StepperList.vue +42 -19
- package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +288 -2
- package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +161 -34
- package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +40 -40
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
- package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +7 -7
- package/app/types/components/index.ts +1 -0
- package/app/types/components/progress-tracker.d.ts +6 -0
- package/app/types/components/social-icons-list.d.ts +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { computed, reactive, ref } from "vue";
|
|
2
|
+
import ProgressTracker from "../ProgressTracker.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
4
|
+
import InputButton from "../../../05.forms/input-button/InputButton.vue";
|
|
5
|
+
import InputTextWithLabel from "../../../05.forms/input-text/InputTextWithLabel.vue";
|
|
6
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
7
|
+
import type { MediaCanvas, ProgressTrackerStep } from "~/types/components";
|
|
8
|
+
|
|
9
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
10
|
+
|
|
11
|
+
// Story-only args: drive CSS tokens, not ProgressTracker props. See the argTypes entries.
|
|
12
|
+
// modelValue is re-declared writable: stories drive it like a parent would.
|
|
13
|
+
type StoryArgs = Omit<InstanceType<typeof ProgressTracker>["$props"], "modelValue"> & {
|
|
14
|
+
modelValue: number;
|
|
15
|
+
titleLineClamp: LineClamp;
|
|
16
|
+
bodyLineClamp: LineClamp;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const lineClampControl = (token: string, example: string) => ({
|
|
20
|
+
control: "select" as const,
|
|
21
|
+
options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
|
|
22
|
+
description:
|
|
23
|
+
`**Story control, not a prop.** Sets the \`${token}\` CSS token on a wrapper so you can try it here. ` +
|
|
24
|
+
`To use it in an app, set the token in your own CSS, e.g. \`${example}\`. ` +
|
|
25
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
26
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const onboardingSteps: ProgressTrackerStep[] = [
|
|
30
|
+
{ title: "Create your account", body: "Enter your email and choose a password to get started." },
|
|
31
|
+
{
|
|
32
|
+
title: "Verify your email",
|
|
33
|
+
body: "Click the link we sent to your inbox. If you don't see it, check your spam folder: it should arrive within a few minutes.",
|
|
34
|
+
},
|
|
35
|
+
{ title: "Choose a plan", body: "Select from Starter, Pro, or Enterprise depending on your needs." },
|
|
36
|
+
{ title: "Invite your team", body: "Add colleagues by email so they can collaborate on your workspace." },
|
|
37
|
+
{ title: "Start building", body: "You're all set. Dive into the dashboard and start your first project." },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const meta: Meta<StoryArgs> = {
|
|
41
|
+
title: "Molecules/ProgressTracker",
|
|
42
|
+
component: ProgressTracker,
|
|
43
|
+
argTypes: {
|
|
44
|
+
titleLineClamp: lineClampControl(
|
|
45
|
+
"--progress-tracker-title-line-clamp",
|
|
46
|
+
".onboarding { --progress-tracker-title-line-clamp: 1; }"
|
|
47
|
+
),
|
|
48
|
+
bodyLineClamp: lineClampControl(
|
|
49
|
+
"--progress-tracker-body-line-clamp",
|
|
50
|
+
".onboarding { --progress-tracker-body-line-clamp: 2; }"
|
|
51
|
+
),
|
|
52
|
+
modelValue: {
|
|
53
|
+
control: { type: "number", min: 0, step: 1 },
|
|
54
|
+
description: "0-based index of the current step (`v-model`). `steps.length` means every step is done.",
|
|
55
|
+
table: { category: "State" },
|
|
56
|
+
},
|
|
57
|
+
steps: { control: "object", table: { category: "Content" } },
|
|
58
|
+
headingTag: {
|
|
59
|
+
control: "select",
|
|
60
|
+
options: ["h2", "h3", "h4", "h5", "h6"],
|
|
61
|
+
description: "Heading level for each step title; pick to fit the surrounding page outline",
|
|
62
|
+
table: { category: "Semantics", defaultValue: { summary: "h3" } },
|
|
63
|
+
},
|
|
64
|
+
stepLabel: {
|
|
65
|
+
control: "text",
|
|
66
|
+
description: "Header text; `{current}` and `{total}` are filled in",
|
|
67
|
+
table: { category: "Labels" },
|
|
68
|
+
},
|
|
69
|
+
completeLabel: { control: "text", table: { category: "Labels" } },
|
|
70
|
+
doneLabel: {
|
|
71
|
+
control: "text",
|
|
72
|
+
description: "Screen-reader text added after each done step's title",
|
|
73
|
+
table: { category: "Labels" },
|
|
74
|
+
},
|
|
75
|
+
doneIcon: { control: "text", description: "Iconify name for the done tick", table: { category: "Labels" } },
|
|
76
|
+
focusOnChange: {
|
|
77
|
+
control: "boolean",
|
|
78
|
+
description: "Move focus to the new current title (or the header label when complete) when the model changes",
|
|
79
|
+
table: { category: "Behaviour" },
|
|
80
|
+
},
|
|
81
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
82
|
+
},
|
|
83
|
+
args: {
|
|
84
|
+
titleLineClamp: "none",
|
|
85
|
+
bodyLineClamp: "none",
|
|
86
|
+
modelValue: 1,
|
|
87
|
+
steps: onboardingSteps,
|
|
88
|
+
headingTag: "h3",
|
|
89
|
+
stepLabel: "Step {current} of {total}",
|
|
90
|
+
completeLabel: "All steps complete",
|
|
91
|
+
doneLabel: "Completed",
|
|
92
|
+
doneIcon: "lucide:check",
|
|
93
|
+
focusOnChange: true,
|
|
94
|
+
styleClassPassthrough: [],
|
|
95
|
+
},
|
|
96
|
+
decorators: [
|
|
97
|
+
(story, context) => ({
|
|
98
|
+
components: { story, CanvasSwitcher },
|
|
99
|
+
setup() {
|
|
100
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
101
|
+
return { canvasName };
|
|
102
|
+
},
|
|
103
|
+
template: `
|
|
104
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
105
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
106
|
+
</div>
|
|
107
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 3.2rem 2rem; outline: 1px dashed currentColor;">
|
|
108
|
+
<story />
|
|
109
|
+
</div>
|
|
110
|
+
`,
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
parameters: {
|
|
114
|
+
layout: "fullscreen",
|
|
115
|
+
docs: {
|
|
116
|
+
description: {
|
|
117
|
+
component:
|
|
118
|
+
"A presentational progress track for a multi-step flow. `steps` (title and optional body) render through StepperList: " +
|
|
119
|
+
"steps before the `v-model` index are done (tick), the model's step is current (ringed number, focus card) and later steps are upcoming. " +
|
|
120
|
+
"A header shows `stepLabel` and a progress bar. The `#step-{n}` slot renders only inside the current step, so a parent can put a form " +
|
|
121
|
+
"or actions there and drive the model itself (see the Wizard story). The component has no events and never changes the model; " +
|
|
122
|
+
"when the model changes it moves focus to the new step's title (`focusOnChange`).",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default meta;
|
|
129
|
+
type Story = StoryObj<StoryArgs>;
|
|
130
|
+
|
|
131
|
+
function useStorySetup(args: StoryArgs) {
|
|
132
|
+
const componentArgs = computed(() => {
|
|
133
|
+
const { titleLineClamp: _titleLineClamp, bodyLineClamp: _bodyLineClamp, modelValue: _modelValue, ...rest } = args;
|
|
134
|
+
return rest;
|
|
135
|
+
});
|
|
136
|
+
const tokenStyles = computed(() => ({
|
|
137
|
+
"--progress-tracker-title-line-clamp": args.titleLineClamp,
|
|
138
|
+
"--progress-tracker-body-line-clamp": args.bodyLineClamp,
|
|
139
|
+
}));
|
|
140
|
+
return { args, componentArgs, tokenStyles };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const render = (args: StoryArgs) => ({
|
|
144
|
+
components: { ProgressTracker },
|
|
145
|
+
setup() {
|
|
146
|
+
return useStorySetup(args);
|
|
147
|
+
},
|
|
148
|
+
template: `
|
|
149
|
+
<div :style="tokenStyles" style="max-inline-size: 64rem;">
|
|
150
|
+
<ProgressTracker v-bind="componentArgs" v-model="args.modelValue" />
|
|
151
|
+
</div>
|
|
152
|
+
`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const Default: Story = {
|
|
156
|
+
render,
|
|
157
|
+
parameters: {
|
|
158
|
+
docs: {
|
|
159
|
+
description: {
|
|
160
|
+
story: "Step 2 of 5. Change the `modelValue` control to move through the steps, or set it to 5 for the complete state.",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const AllComplete: Story = {
|
|
167
|
+
name: "All Complete",
|
|
168
|
+
args: { modelValue: 5 },
|
|
169
|
+
render,
|
|
170
|
+
parameters: {
|
|
171
|
+
docs: { description: { story: "The model equals `steps.length`: every step is done and the header shows `completeLabel`." } },
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const Interactive: Story = {
|
|
176
|
+
name: "Interactive (Mark complete)",
|
|
177
|
+
render: (args) => ({
|
|
178
|
+
components: { ProgressTracker, InputButton },
|
|
179
|
+
setup() {
|
|
180
|
+
return useStorySetup(args);
|
|
181
|
+
},
|
|
182
|
+
template: `
|
|
183
|
+
<div :style="tokenStyles" style="max-inline-size: 64rem;">
|
|
184
|
+
<ProgressTracker v-bind="componentArgs" v-model="args.modelValue">
|
|
185
|
+
<template v-for="(_, index) in args.steps" :key="index" #[\`step-\${index}\`]>
|
|
186
|
+
<InputButton button-text="Mark complete" is-pill @click="args.modelValue = index + 1" />
|
|
187
|
+
</template>
|
|
188
|
+
</ProgressTracker>
|
|
189
|
+
<InputButton
|
|
190
|
+
v-if="args.modelValue >= args.steps.length"
|
|
191
|
+
button-text="Start again"
|
|
192
|
+
variant="secondary"
|
|
193
|
+
style="margin-block-start: 2.4rem;"
|
|
194
|
+
@click="args.modelValue = 0"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
`,
|
|
198
|
+
}),
|
|
199
|
+
parameters: {
|
|
200
|
+
docs: {
|
|
201
|
+
description: {
|
|
202
|
+
story:
|
|
203
|
+
"The minimal pattern: each step's `#step-{n}` slot holds a \"Mark complete\" button that sets the model to the next index. " +
|
|
204
|
+
"The button only exists in the current step, and focus moves to the next title after each click.",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const wizardSteps: ProgressTrackerStep[] = [
|
|
211
|
+
{ title: "Your details", body: "Tell us who you are." },
|
|
212
|
+
{ title: "Contact", body: "Where can we reach you?" },
|
|
213
|
+
{ title: "Review and submit", body: "Check everything looks right, then submit." },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
export const Wizard: Story = {
|
|
217
|
+
name: "Wizard (parent-owned forms)",
|
|
218
|
+
args: { modelValue: 0, steps: wizardSteps },
|
|
219
|
+
render: (args) => ({
|
|
220
|
+
components: { ProgressTracker, InputButton, InputTextWithLabel },
|
|
221
|
+
setup() {
|
|
222
|
+
const story = useStorySetup(args);
|
|
223
|
+
const form = reactive({ name: "", email: "" });
|
|
224
|
+
const errors = reactive({ name: "", email: "" });
|
|
225
|
+
const submitted = ref(false);
|
|
226
|
+
|
|
227
|
+
const steps = computed<ProgressTrackerStep[]>(() => [
|
|
228
|
+
{ title: "Your details", body: args.modelValue > 0 && form.name ? form.name : "Tell us who you are." },
|
|
229
|
+
{ title: "Contact", body: args.modelValue > 1 && form.email ? form.email : "Where can we reach you?" },
|
|
230
|
+
{ title: "Review and submit", body: "Check everything looks right, then submit." },
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
const next = (field: "name" | "email") => {
|
|
234
|
+
errors[field] = form[field].trim() ? "" : "This field is required";
|
|
235
|
+
if (!errors[field]) args.modelValue = (args.modelValue ?? 0) + 1;
|
|
236
|
+
};
|
|
237
|
+
const back = () => {
|
|
238
|
+
args.modelValue = Math.max(0, (args.modelValue ?? 0) - 1);
|
|
239
|
+
};
|
|
240
|
+
const submit = () => {
|
|
241
|
+
submitted.value = true;
|
|
242
|
+
args.modelValue = steps.value.length;
|
|
243
|
+
};
|
|
244
|
+
const restart = () => {
|
|
245
|
+
Object.assign(form, { name: "", email: "" });
|
|
246
|
+
submitted.value = false;
|
|
247
|
+
args.modelValue = 0;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return { ...story, form, errors, steps, submitted, next, back, submit, restart };
|
|
251
|
+
},
|
|
252
|
+
template: `
|
|
253
|
+
<div :style="tokenStyles" style="max-inline-size: 64rem;">
|
|
254
|
+
<ProgressTracker v-bind="componentArgs" v-model="args.modelValue" :steps="steps">
|
|
255
|
+
<template #step-0="{ headingId }">
|
|
256
|
+
<form :aria-labelledby="headingId" style="display: grid; gap: 1.6rem;" @submit.prevent="next('name')">
|
|
257
|
+
<InputTextWithLabel
|
|
258
|
+
v-model="form.name"
|
|
259
|
+
type="text"
|
|
260
|
+
name="wizard-name"
|
|
261
|
+
label="Full name"
|
|
262
|
+
:error-message="errors.name"
|
|
263
|
+
:field-has-error="!!errors.name"
|
|
264
|
+
required
|
|
265
|
+
/>
|
|
266
|
+
<div style="display: flex; gap: 1.2rem;">
|
|
267
|
+
<InputButton type="submit" button-text="Continue" is-pill />
|
|
268
|
+
</div>
|
|
269
|
+
</form>
|
|
270
|
+
</template>
|
|
271
|
+
<template #step-1="{ headingId }">
|
|
272
|
+
<form :aria-labelledby="headingId" style="display: grid; gap: 1.6rem;" @submit.prevent="next('email')">
|
|
273
|
+
<InputTextWithLabel
|
|
274
|
+
v-model="form.email"
|
|
275
|
+
type="email"
|
|
276
|
+
name="wizard-email"
|
|
277
|
+
label="Email address"
|
|
278
|
+
:error-message="errors.email"
|
|
279
|
+
:field-has-error="!!errors.email"
|
|
280
|
+
required
|
|
281
|
+
/>
|
|
282
|
+
<div style="display: flex; gap: 1.2rem; flex-wrap: wrap;">
|
|
283
|
+
<InputButton button-text="Back" variant="secondary" is-pill @click="back" />
|
|
284
|
+
<InputButton type="submit" button-text="Continue" is-pill />
|
|
285
|
+
</div>
|
|
286
|
+
</form>
|
|
287
|
+
</template>
|
|
288
|
+
<template #step-2="{ headingId }">
|
|
289
|
+
<form :aria-labelledby="headingId" style="display: grid; gap: 1.6rem;" @submit.prevent="submit">
|
|
290
|
+
<dl style="display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.4rem 1.6rem; margin: 0;">
|
|
291
|
+
<dt>Name</dt><dd style="margin: 0;">{{ form.name }}</dd>
|
|
292
|
+
<dt>Email</dt><dd style="margin: 0;">{{ form.email }}</dd>
|
|
293
|
+
</dl>
|
|
294
|
+
<div style="display: flex; gap: 1.2rem; flex-wrap: wrap;">
|
|
295
|
+
<InputButton button-text="Back" variant="secondary" is-pill @click="back" />
|
|
296
|
+
<InputButton type="submit" button-text="Submit" is-pill />
|
|
297
|
+
</div>
|
|
298
|
+
</form>
|
|
299
|
+
</template>
|
|
300
|
+
</ProgressTracker>
|
|
301
|
+
<div v-if="submitted" style="margin-block-start: 2.4rem;">
|
|
302
|
+
<InputButton button-text="Start again" variant="secondary" is-pill @click="restart" />
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
`,
|
|
306
|
+
}),
|
|
307
|
+
parameters: {
|
|
308
|
+
docs: {
|
|
309
|
+
description: {
|
|
310
|
+
story:
|
|
311
|
+
"An n-step wizard where the parent owns everything: the form values, validation and the model. Each `#step-{n}` slot holds that " +
|
|
312
|
+
"step's form, labelled by its step title through the slot's `headingId`. Continue validates and increments the model, Back " +
|
|
313
|
+
"decrements it, Submit sets it to `steps.length`. Done steps show what was entered, by passing it back in as the step's `body`. " +
|
|
314
|
+
"Try it with the keyboard only: focus follows each step change to the new title.",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const TranslatedLabels: Story = {
|
|
321
|
+
name: "Translated Labels",
|
|
322
|
+
args: {
|
|
323
|
+
stepLabel: "Schritt {current} von {total}",
|
|
324
|
+
completeLabel: "Alle Schritte erledigt",
|
|
325
|
+
doneLabel: "Erledigt",
|
|
326
|
+
steps: [
|
|
327
|
+
{ title: "Konto erstellen", body: "Geben Sie Ihre E-Mail-Adresse ein und wählen Sie ein Passwort." },
|
|
328
|
+
{ title: "E-Mail bestätigen", body: "Klicken Sie auf den Link, den wir Ihnen geschickt haben." },
|
|
329
|
+
{ title: "Tarif wählen", body: "Wählen Sie zwischen Starter, Pro oder Enterprise." },
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
render,
|
|
333
|
+
parameters: {
|
|
334
|
+
docs: {
|
|
335
|
+
description: {
|
|
336
|
+
story: "Every visible and screen-reader string is a prop: `stepLabel`, `completeLabel` and `doneLabel`.",
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const WhiteCard: Story = {
|
|
343
|
+
name: "White Card on a Panel",
|
|
344
|
+
render: (args) => ({
|
|
345
|
+
components: { ProgressTracker },
|
|
346
|
+
setup() {
|
|
347
|
+
return useStorySetup(args);
|
|
348
|
+
},
|
|
349
|
+
template: `
|
|
350
|
+
<div
|
|
351
|
+
:style="tokenStyles"
|
|
352
|
+
style="max-inline-size: 64rem; padding: 4rem 3.2rem; border-radius: 2.4rem; background: #f4f4f5; --progress-tracker-card-surface: #ffffff; --progress-tracker-label-font-family: ui-monospace, Menlo, monospace;"
|
|
353
|
+
>
|
|
354
|
+
<ProgressTracker v-bind="componentArgs" v-model="args.modelValue" />
|
|
355
|
+
</div>
|
|
356
|
+
`,
|
|
357
|
+
}),
|
|
358
|
+
parameters: {
|
|
359
|
+
docs: {
|
|
360
|
+
description: {
|
|
361
|
+
story:
|
|
362
|
+
"Token overrides on a wrapper: `--progress-tracker-card-surface: #ffffff` lifts the focus card off a grey panel, and " +
|
|
363
|
+
"`--progress-tracker-label-font-family` sets a monospace header label.",
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
const unbroken = "Kontoverifizierungsbestätigungsschreiben".repeat(3);
|
|
372
|
+
const longGerman =
|
|
373
|
+
"Bitte bestätigen Sie Ihre E-Mail-Adresse innerhalb von vierundzwanzig Stunden, da der Bestätigungslink danach aus Sicherheitsgründen automatisch ungültig wird.";
|
|
374
|
+
const longUrl = "https://example.com/a/very/long/url/without/any/spaces/that/keeps/going/and/going/and/going";
|
|
375
|
+
|
|
376
|
+
const stressSteps: ProgressTrackerStep[] = [
|
|
377
|
+
{ title: unbroken, body: longGerman },
|
|
378
|
+
{ title: `${longGerman} ${longGerman}`, body: longUrl },
|
|
379
|
+
{ title: "🦄💇♀️ Emoji first" },
|
|
380
|
+
{ title: "مرحبا بكم في صالوننا", body: "نحن نقدم جميع خدمات الشعر" },
|
|
381
|
+
{ title: "<script>alert(1)</script> renders as text", body: "<b>not bold</b>" },
|
|
382
|
+
{ title: "A", body: "" },
|
|
383
|
+
...Array.from({ length: 14 }, (_, i) => ({ title: `Extra step ${i + 7}`, body: i % 2 ? undefined : "Short body." })),
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
export const StressTest: Story = {
|
|
387
|
+
name: "Stress Test (Worst-Case Data)",
|
|
388
|
+
args: {
|
|
389
|
+
modelValue: 1,
|
|
390
|
+
steps: stressSteps,
|
|
391
|
+
stepLabel: "Schritt {current} von insgesamt {total} Schritten im Registrierungsvorgang",
|
|
392
|
+
},
|
|
393
|
+
render: (args) => ({
|
|
394
|
+
components: { ProgressTracker },
|
|
395
|
+
setup() {
|
|
396
|
+
const story = useStorySetup(args);
|
|
397
|
+
return { ...story, unbroken };
|
|
398
|
+
},
|
|
399
|
+
template: `
|
|
400
|
+
<div :style="tokenStyles" style="max-inline-size: 64rem; display: grid; gap: 4.8rem;">
|
|
401
|
+
<ProgressTracker v-bind="componentArgs" v-model="args.modelValue">
|
|
402
|
+
<template #step-1><p style="margin: 0;">Slot content: {{ unbroken }}</p></template>
|
|
403
|
+
</ProgressTracker>
|
|
404
|
+
<ProgressTracker :steps="[]" :model-value="0" />
|
|
405
|
+
<ProgressTracker :steps="[{ title: 'Only step' }]" :model-value="2.5" />
|
|
406
|
+
<ProgressTracker :steps="[{ title: 'First' }, { title: 'Second' }]" :model-value="-1" />
|
|
407
|
+
</div>
|
|
408
|
+
`,
|
|
409
|
+
}),
|
|
410
|
+
parameters: {
|
|
411
|
+
initialCanvas: "mobileCanvas",
|
|
412
|
+
docs: {
|
|
413
|
+
story: { inline: false, height: "1400px" },
|
|
414
|
+
description: {
|
|
415
|
+
story:
|
|
416
|
+
"Worst-case data at every canvas width. Check that: the unbroken title, long German copy and URL wrap inside the step instead of " +
|
|
417
|
+
"widening it, and the focus card stays inside the canvas; the long translated header label wraps and the bar drops below it; " +
|
|
418
|
+
"emoji, RTL and HTML-like text render as plain text; a missing or empty body leaves no gap; 20 steps keep their numbers inside the " +
|
|
419
|
+
"indicators. Below it: an empty `steps` array renders nothing, a single step with model 2.5 shows as complete, and model -1 shows " +
|
|
420
|
+
"the first step as current. Try the line clamp controls too: they set CSS tokens, they are not props.",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
5
|
+
import ProgressTracker from "../ProgressTracker.vue";
|
|
6
|
+
import type { ProgressTrackerStep } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
const steps: ProgressTrackerStep[] = [
|
|
9
|
+
{ title: "Create your account", body: "Enter your email and choose a password." },
|
|
10
|
+
{ title: "Verify your email", body: "Click the link we sent to your inbox." },
|
|
11
|
+
{ title: "Choose a plan" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
let wrapper: VueWrapper | undefined;
|
|
15
|
+
|
|
16
|
+
const mountTracker = async (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
|
17
|
+
wrapper = await mountSuspended(ProgressTracker, {
|
|
18
|
+
props: { steps, modelValue: 1, ...props },
|
|
19
|
+
slots,
|
|
20
|
+
attachTo: document.body,
|
|
21
|
+
});
|
|
22
|
+
return wrapper;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const stepClasses = (w: VueWrapper) => w.findAll(".progress-tracker-step").map((step) => step.classes());
|
|
26
|
+
|
|
27
|
+
describe("ProgressTracker", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
wrapper?.unmount();
|
|
30
|
+
wrapper = undefined;
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("mounts without error", async () => {
|
|
35
|
+
await mountTracker();
|
|
36
|
+
expect(wrapper!.vm).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders correct HTML structure", async () => {
|
|
40
|
+
await mountTracker();
|
|
41
|
+
expect(wrapper!.html()).toMatchSnapshot();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── Rows ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
it("renders one row per step with its title and body", async () => {
|
|
47
|
+
await mountTracker();
|
|
48
|
+
expect(wrapper!.findAll("li")).toHaveLength(3);
|
|
49
|
+
expect(wrapper!.findAll(".progress-tracker-title")[0]!.text()).toContain("Create your account");
|
|
50
|
+
expect(wrapper!.findAll(".progress-tracker-body")[1]!.text()).toBe("Click the link we sent to your inbox.");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("omits the body paragraph when a step has no body", async () => {
|
|
54
|
+
await mountTracker();
|
|
55
|
+
expect(wrapper!.findAll(".progress-tracker-step")[2]!.find(".progress-tracker-body").exists()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders nothing but the root when there are no steps", async () => {
|
|
59
|
+
await mountTracker({ steps: [], modelValue: 0 });
|
|
60
|
+
expect(wrapper!.find(".progress-tracker-header").exists()).toBe(false);
|
|
61
|
+
expect(wrapper!.find("ol").exists()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders the list as an ol", async () => {
|
|
65
|
+
await mountTracker();
|
|
66
|
+
expect(wrapper!.find(".progress-tracker-list").element.tagName).toBe("OL");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("uses headingTag for step titles", async () => {
|
|
70
|
+
await mountTracker({ headingTag: "h4" });
|
|
71
|
+
expect(wrapper!.findAll("h4")).toHaveLength(3);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─── States ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
it("marks steps before the model done, the model's step current and later steps upcoming", async () => {
|
|
77
|
+
await mountTracker();
|
|
78
|
+
expect(stepClasses(wrapper!)[0]).toContain("is-done");
|
|
79
|
+
expect(stepClasses(wrapper!)[1]).toContain("is-current");
|
|
80
|
+
expect(stepClasses(wrapper!)[2]).toContain("is-upcoming");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows the tick icon for done steps and the number otherwise", async () => {
|
|
84
|
+
await mountTracker();
|
|
85
|
+
const indicators = wrapper!.findAll(".progress-tracker-indicator");
|
|
86
|
+
expect(indicators[0]!.find(".progress-tracker-indicator-icon").exists()).toBe(true);
|
|
87
|
+
expect(indicators[1]!.text()).toBe("2");
|
|
88
|
+
expect(indicators[2]!.text()).toBe("3");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("hides indicators from assistive technology", async () => {
|
|
92
|
+
await mountTracker();
|
|
93
|
+
for (const indicator of wrapper!.findAll(".progress-tracker-indicator")) {
|
|
94
|
+
expect(indicator.attributes("aria-hidden")).toBe("true");
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("adds doneLabel as screen-reader text to done titles only", async () => {
|
|
99
|
+
await mountTracker({ doneLabel: "Erledigt" });
|
|
100
|
+
const titles = wrapper!.findAll(".progress-tracker-title");
|
|
101
|
+
expect(titles[0]!.find(".sr-only").text()).toBe(", Erledigt");
|
|
102
|
+
expect(titles[1]!.find(".sr-only").exists()).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("sets aria-current='step' on the current title only", async () => {
|
|
106
|
+
await mountTracker();
|
|
107
|
+
const titles = wrapper!.findAll(".progress-tracker-title");
|
|
108
|
+
expect(titles.map((title) => title.attributes("aria-current"))).toEqual([undefined, "step", undefined]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("marks every step done and the root complete when the model equals steps.length", async () => {
|
|
112
|
+
await mountTracker({ modelValue: 3 });
|
|
113
|
+
expect(stepClasses(wrapper!).every((classes) => classes.includes("is-done"))).toBe(true);
|
|
114
|
+
expect(wrapper!.classes()).toContain("is-complete");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("restyles steps when the model changes", async () => {
|
|
118
|
+
await mountTracker({ focusOnChange: false });
|
|
119
|
+
await wrapper!.setProps({ modelValue: 2 });
|
|
120
|
+
expect(stepClasses(wrapper!)[1]).toContain("is-done");
|
|
121
|
+
expect(stepClasses(wrapper!)[2]).toContain("is-current");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("renders no interactive elements of its own", async () => {
|
|
125
|
+
await mountTracker();
|
|
126
|
+
expect(wrapper!.findAll("button, a, input")).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── Clamping ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
it("clamps a negative model to the first step", async () => {
|
|
132
|
+
await mountTracker({ modelValue: -1 });
|
|
133
|
+
expect(stepClasses(wrapper!)[0]).toContain("is-current");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("clamps a model above steps.length to complete", async () => {
|
|
137
|
+
await mountTracker({ modelValue: 99 });
|
|
138
|
+
expect(wrapper!.classes()).toContain("is-complete");
|
|
139
|
+
expect(wrapper!.find(".progress-tracker-bar").attributes("aria-valuenow")).toBe("3");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("floors a fractional model", async () => {
|
|
143
|
+
await mountTracker({ modelValue: 1.7 });
|
|
144
|
+
expect(stepClasses(wrapper!)[1]).toContain("is-current");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── Header ──────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
it("fills stepLabel with the current step number and total", async () => {
|
|
150
|
+
await mountTracker();
|
|
151
|
+
expect(wrapper!.find(".progress-tracker-label").text()).toBe("Step 2 of 3");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("uses a translated stepLabel", async () => {
|
|
155
|
+
await mountTracker({ stepLabel: "Schritt {current} von {total}" });
|
|
156
|
+
expect(wrapper!.find(".progress-tracker-label").text()).toBe("Schritt 2 von 3");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows completeLabel when every step is done", async () => {
|
|
160
|
+
await mountTracker({ modelValue: 3, completeLabel: "Alles erledigt" });
|
|
161
|
+
expect(wrapper!.find(".progress-tracker-label").text()).toBe("Alles erledigt");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("announces the header label politely", async () => {
|
|
165
|
+
await mountTracker();
|
|
166
|
+
expect(wrapper!.find(".progress-tracker-label").attributes("aria-live")).toBe("polite");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("exposes the bar as a progressbar with min, max, done count and value text", async () => {
|
|
170
|
+
await mountTracker();
|
|
171
|
+
const bar = wrapper!.find(".progress-tracker-bar");
|
|
172
|
+
expect(bar.attributes("role")).toBe("progressbar");
|
|
173
|
+
expect(bar.attributes("aria-valuemin")).toBe("0");
|
|
174
|
+
expect(bar.attributes("aria-valuemax")).toBe("3");
|
|
175
|
+
expect(bar.attributes("aria-valuenow")).toBe("1");
|
|
176
|
+
expect(bar.attributes("aria-valuetext")).toBe("Step 2 of 3");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("sizes the bar fill from the done count", async () => {
|
|
180
|
+
await mountTracker({ modelValue: 3 });
|
|
181
|
+
const style = (wrapper!.find(".progress-tracker-bar").element as HTMLElement).style;
|
|
182
|
+
expect(style.getPropertyValue("--_progress-tracker-progress")).toBe("1");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── Step slot ───────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
it("renders step-{n} only for the current step, with the heading id", async () => {
|
|
188
|
+
await mountTracker(
|
|
189
|
+
{},
|
|
190
|
+
{
|
|
191
|
+
"step-0": "<p class='slot-0'>Step one form</p>",
|
|
192
|
+
"step-1": "<p class='slot-1' :aria-labelledby='params.headingId'>Step two form</p>",
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
expect(wrapper!.find(".slot-0").exists()).toBe(false);
|
|
196
|
+
const slot = wrapper!.find(".slot-1");
|
|
197
|
+
expect(slot.exists()).toBe(true);
|
|
198
|
+
expect(slot.attributes("aria-labelledby")).toBe(wrapper!.findAll(".progress-tracker-title")[1]!.attributes("id"));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("passes the step and index to step-{n}", async () => {
|
|
202
|
+
await mountTracker({}, { "step-1": "<p class='slot-1'>{{ params.index }}: {{ params.step.title }}</p>" });
|
|
203
|
+
expect(wrapper!.find(".slot-1").text()).toBe("1: Verify your email");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("renders no slot wrapper when the current step has no slot", async () => {
|
|
207
|
+
await mountTracker();
|
|
208
|
+
expect(wrapper!.find(".progress-tracker-slot").exists()).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── Focus ───────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
it("moves focus to the new current title when the model changes", async () => {
|
|
214
|
+
await mountTracker();
|
|
215
|
+
await wrapper!.setProps({ modelValue: 2 });
|
|
216
|
+
await nextTick();
|
|
217
|
+
expect(document.activeElement).toBe(wrapper!.findAll(".progress-tracker-title")[2]!.element);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("moves focus to the header label when every step is complete", async () => {
|
|
221
|
+
await mountTracker();
|
|
222
|
+
await wrapper!.setProps({ modelValue: 3 });
|
|
223
|
+
await nextTick();
|
|
224
|
+
expect(document.activeElement).toBe(wrapper!.find(".progress-tracker-label").element);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("does not move focus on mount", async () => {
|
|
228
|
+
await mountTracker();
|
|
229
|
+
expect(document.activeElement).toBe(document.body);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not move focus when focusOnChange is false", async () => {
|
|
233
|
+
await mountTracker({ focusOnChange: false });
|
|
234
|
+
await wrapper!.setProps({ modelValue: 2 });
|
|
235
|
+
await nextTick();
|
|
236
|
+
expect(document.activeElement).toBe(document.body);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ─── Props ───────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
it("uses doneIcon for the tick", async () => {
|
|
242
|
+
await mountTracker({ doneIcon: "mdi:check" });
|
|
243
|
+
expect(wrapper!.find(".progress-tracker-indicator-icon").html()).toContain("mdi");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("applies styleClassPassthrough and updates it after mount", async () => {
|
|
247
|
+
await mountTracker({ styleClassPassthrough: ["onboarding"] });
|
|
248
|
+
expect(wrapper!.classes()).toContain("onboarding");
|
|
249
|
+
await wrapper!.setProps({ styleClassPassthrough: ["checkout"] });
|
|
250
|
+
expect(wrapper!.classes()).toContain("checkout");
|
|
251
|
+
expect(wrapper!.classes()).not.toContain("onboarding");
|
|
252
|
+
});
|
|
253
|
+
});
|