srcdev-nuxt-components 9.6.17 → 9.6.19
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/css-relative-colour-calc.md +15 -0
- package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +6 -1
- package/.claude/skills/component-dynamic-slots.md +2 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/accordion-chat.md +99 -0
- package/.claude/skills/components/accordion-group.md +98 -0
- package/.claude/skills/components/action-menu.md +11 -5
- package/.claude/skills/components/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/responsive-header.md +1 -1
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +8 -0
- package/.claude/skills/using-component-skills.md +1 -1
- package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
- package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
- package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
- package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
- package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
- package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
- package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
- package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
- package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
- package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
- package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
- package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
- package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
- package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
- package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
- package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
- package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
- package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +6 -2
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +21 -0
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
- package/package.json +1 -1
- package/.claude/skills/components/accordian-core.md +0 -180
- package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
- package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
- package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import AccordionChat from "../AccordionChat.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { MediaCanvas } from "~/types/components";
|
|
6
|
+
|
|
7
|
+
interface StoryArgs {
|
|
8
|
+
name?: string;
|
|
9
|
+
animationDuration?: number;
|
|
10
|
+
avatarLabel?: string;
|
|
11
|
+
showAvatar?: boolean;
|
|
12
|
+
radius?: string;
|
|
13
|
+
bubbleMaxWidth?: string;
|
|
14
|
+
gap?: string;
|
|
15
|
+
styleClassPassthrough?: string | string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tokenCategory = "CSS tokens (story only, set in your CSS)";
|
|
19
|
+
|
|
20
|
+
const meta: Meta<StoryArgs> = {
|
|
21
|
+
title: "Molecules/Expandable/AccordionChat",
|
|
22
|
+
component: AccordionChat,
|
|
23
|
+
argTypes: {
|
|
24
|
+
name: {
|
|
25
|
+
control: { type: "text" },
|
|
26
|
+
description: "Shared `name` on every panel's `<details>`: only one answer open at a time. Leave empty for independent panels.",
|
|
27
|
+
},
|
|
28
|
+
animationDuration: {
|
|
29
|
+
control: { type: "number", min: 0, step: 50 },
|
|
30
|
+
description: "Open/close (row height) animation in milliseconds, passed to every panel.",
|
|
31
|
+
},
|
|
32
|
+
avatarLabel: {
|
|
33
|
+
control: { type: "text" },
|
|
34
|
+
description: "Initials in the avatar circle (first two characters). Used when no `#avatar` or `item-{n}-avatar` slot is filled.",
|
|
35
|
+
},
|
|
36
|
+
showAvatar: {
|
|
37
|
+
control: { type: "boolean" },
|
|
38
|
+
description: "`false` removes the avatar column entirely, even when an avatar slot is filled.",
|
|
39
|
+
},
|
|
40
|
+
radius: {
|
|
41
|
+
control: { type: "select" },
|
|
42
|
+
options: ["0.8rem", "1.2rem", "2rem", "3rem"],
|
|
43
|
+
description: "**Story control, not a prop.** Sets `--accordion-chat-radius`. In your CSS: `.faq { --accordion-chat-radius: 1.2rem; }`",
|
|
44
|
+
table: { category: tokenCategory },
|
|
45
|
+
},
|
|
46
|
+
bubbleMaxWidth: {
|
|
47
|
+
control: { type: "select" },
|
|
48
|
+
options: ["60%", "82%", "100%"],
|
|
49
|
+
description: "**Story control, not a prop.** Sets `--accordion-chat-bubble-max-width`. In your CSS: `.faq { --accordion-chat-bubble-max-width: 70%; }`",
|
|
50
|
+
table: { category: tokenCategory },
|
|
51
|
+
},
|
|
52
|
+
gap: {
|
|
53
|
+
control: { type: "select" },
|
|
54
|
+
options: ["0.8rem", "1.4rem", "2.4rem"],
|
|
55
|
+
description: "**Story control, not a prop.** Sets `--accordion-chat-gap`. In your CSS: `.faq { --accordion-chat-gap: 2rem; }`",
|
|
56
|
+
table: { category: tokenCategory },
|
|
57
|
+
},
|
|
58
|
+
styleClassPassthrough: { table: { disable: true } },
|
|
59
|
+
},
|
|
60
|
+
args: {
|
|
61
|
+
name: "",
|
|
62
|
+
animationDuration: 300,
|
|
63
|
+
avatarLabel: "S",
|
|
64
|
+
showAvatar: true,
|
|
65
|
+
radius: "2rem",
|
|
66
|
+
bubbleMaxWidth: "82%",
|
|
67
|
+
gap: "1.4rem",
|
|
68
|
+
},
|
|
69
|
+
parameters: {
|
|
70
|
+
docs: {
|
|
71
|
+
description: {
|
|
72
|
+
component:
|
|
73
|
+
"An accordion drawn as a chat: each `item-{n}-summary` slot is a question bubble on the right, and its `item-{n}-content` slot is the " +
|
|
74
|
+
"answer bubble on the left, which can hold any rich content. The avatar beside each answer comes from `item-{n}-avatar`, then the " +
|
|
75
|
+
"group `#avatar` slot, then `avatarLabel`; with none of them there's no avatar column. `name` makes it exclusive-open. Built on " +
|
|
76
|
+
"[ExpandingPanelClassic](?path=/story/molecules-expandable-expanding-panel-classic--default), so it opens and closes, and animates, in every browser. " +
|
|
77
|
+
"Every colour, size, radius, font and duration is a `--accordion-chat-*` token: the Default and Dark Theme stories are two token sets for the same markup.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default meta;
|
|
84
|
+
type Story = StoryObj<StoryArgs>;
|
|
85
|
+
|
|
86
|
+
const richItems = [
|
|
87
|
+
{
|
|
88
|
+
q: "Delivery & Returns",
|
|
89
|
+
a: "<p style='margin: 0;'>Free standard delivery on orders over £50. Returns accepted within 30 days.</p>",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
q: "Materials & Care",
|
|
93
|
+
a:
|
|
94
|
+
"<ul style='margin: 0; padding-inline-start: 1.8rem;'><li>100% organic cotton</li><li>Machine wash at 30°C</li><li>Do not tumble dry</li></ul>",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
q: "Sizing Guide",
|
|
98
|
+
a: "<p style='margin: 0;'>Our pieces run true to size. If you're between sizes, we recommend sizing up.</p>",
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function useStorySetup(args: StoryArgs) {
|
|
103
|
+
const componentArgs = computed(() => ({
|
|
104
|
+
name: args.name || undefined,
|
|
105
|
+
animationDuration: args.animationDuration,
|
|
106
|
+
avatarLabel: args.avatarLabel,
|
|
107
|
+
showAvatar: args.showAvatar,
|
|
108
|
+
}));
|
|
109
|
+
const tokenStyle = computed(() => ({
|
|
110
|
+
"--accordion-chat-radius": args.radius,
|
|
111
|
+
"--accordion-chat-bubble-max-width": args.bubbleMaxWidth,
|
|
112
|
+
"--accordion-chat-gap": args.gap,
|
|
113
|
+
}));
|
|
114
|
+
return { componentArgs, tokenStyle, items: richItems };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const itemsTemplate = `
|
|
118
|
+
<template v-for="(item, i) in items" :key="'s' + i" #[\`item-\${i}-summary\`]>{{ item.q }}</template>
|
|
119
|
+
<template v-for="(item, i) in items" :key="'c' + i" #[\`item-\${i}-content\`]><div v-html="item.a"></div></template>
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
const stage = "padding: 3.2rem 2.4rem; max-inline-size: 64rem; margin-inline: auto;";
|
|
123
|
+
|
|
124
|
+
const lightTokens = {
|
|
125
|
+
background: "#f9f9f9",
|
|
126
|
+
"--accordion-chat-question-surface": "#ececec",
|
|
127
|
+
"--accordion-chat-question-colour": "#1d1d1f",
|
|
128
|
+
"--accordion-chat-question-surface-open": "#1d1d1f",
|
|
129
|
+
"--accordion-chat-question-colour-open": "#ffffff",
|
|
130
|
+
"--accordion-chat-answer-surface": "#ffffff",
|
|
131
|
+
"--accordion-chat-answer-border": "#e6e4e1",
|
|
132
|
+
"--accordion-chat-answer-colour": "#2a2a2c",
|
|
133
|
+
"--accordion-chat-avatar-surface": "#1d1d1f",
|
|
134
|
+
"--accordion-chat-avatar-colour": "#ffffff",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const darkTokens = {
|
|
138
|
+
background: "#0f0e0d",
|
|
139
|
+
"--accordion-chat-question-font-family": "'Playfair Display', Georgia, serif",
|
|
140
|
+
"--accordion-chat-question-font-size": "1.9rem",
|
|
141
|
+
"--accordion-chat-question-font-weight": "400",
|
|
142
|
+
"--accordion-chat-question-surface": "#1a1615",
|
|
143
|
+
"--accordion-chat-question-colour": "#f2ece2",
|
|
144
|
+
"--accordion-chat-question-border": "#2b2422",
|
|
145
|
+
"--accordion-chat-question-surface-open": "#c9a961",
|
|
146
|
+
"--accordion-chat-question-colour-open": "#14100f",
|
|
147
|
+
"--accordion-chat-question-border-open": "#c9a961",
|
|
148
|
+
"--accordion-chat-icon-surface": "rgb(255 255 255 / 0.08)",
|
|
149
|
+
"--accordion-chat-icon-surface-open": "rgb(0 0 0 / 0.12)",
|
|
150
|
+
"--accordion-chat-answer-surface": "#1a1615",
|
|
151
|
+
"--accordion-chat-answer-border": "#2b2422",
|
|
152
|
+
"--accordion-chat-answer-colour": "#cbbfb4",
|
|
153
|
+
"--accordion-chat-answer-font-family": "Poppins, system-ui, sans-serif",
|
|
154
|
+
"--accordion-chat-answer-font-weight": "300",
|
|
155
|
+
"--accordion-chat-answer-font-size": "1.45rem",
|
|
156
|
+
"--accordion-chat-answer-line-height": "1.7",
|
|
157
|
+
"--accordion-chat-avatar-surface": "#c9a961",
|
|
158
|
+
"--accordion-chat-avatar-colour": "#14100f",
|
|
159
|
+
"--accordion-chat-avatar-font-family": "'Playfair Display', Georgia, serif",
|
|
160
|
+
"--accordion-chat-avatar-font-weight": "400",
|
|
161
|
+
"--accordion-chat-avatar-font-size": "1.4rem",
|
|
162
|
+
"--accordion-chat-focus-ring-colour": "#c9a961",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const renderWith = (extraStyle: Record<string, string> = {}, slotsTemplate = itemsTemplate) => (args: StoryArgs) => ({
|
|
166
|
+
components: { AccordionChat },
|
|
167
|
+
setup() {
|
|
168
|
+
return { ...useStorySetup(args), extraStyle };
|
|
169
|
+
},
|
|
170
|
+
template: `
|
|
171
|
+
<div style="${stage}" :style="[extraStyle, tokenStyle]">
|
|
172
|
+
<AccordionChat v-bind="componentArgs">${slotsTemplate}</AccordionChat>
|
|
173
|
+
</div>
|
|
174
|
+
`,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const Default: Story = {
|
|
178
|
+
render: renderWith(lightTokens),
|
|
179
|
+
parameters: {
|
|
180
|
+
docs: {
|
|
181
|
+
description: {
|
|
182
|
+
story:
|
|
183
|
+
"The neutral light look: token values set to match the design reference. Without them, the colours come from the `--theme-*` tokens " +
|
|
184
|
+
"(see Theme Defaults). Click a question to open its answer.",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const ThemeDefaults: Story = {
|
|
191
|
+
name: "Theme Defaults (no tokens set)",
|
|
192
|
+
render: renderWith(),
|
|
193
|
+
parameters: {
|
|
194
|
+
docs: { description: { story: "No `--accordion-chat-*` colour tokens set: every colour falls back to the active theme's `--theme-*` slots." } },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const DarkTheme: Story = {
|
|
199
|
+
name: "Dark Theme (tokens only)",
|
|
200
|
+
args: { avatarLabel: "N" },
|
|
201
|
+
render: renderWith(darkTokens),
|
|
202
|
+
parameters: {
|
|
203
|
+
docs: {
|
|
204
|
+
description: {
|
|
205
|
+
story:
|
|
206
|
+
"The same markup with only token overrides: serif questions, gold open state and avatar, light-weight answers on dark bubbles, " +
|
|
207
|
+
"and a question border that turns gold when open. Copy the token set from the story source.",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const monogramSvg = `<svg viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg"><rect width="30" height="30" fill="#2f6f5e"></rect><path d="M8 21 L15 8 L22 21 Z" fill="#dce9e2"></path></svg>`;
|
|
214
|
+
|
|
215
|
+
export const LogoAvatar: Story = {
|
|
216
|
+
name: "Logo Avatar (#avatar slot)",
|
|
217
|
+
render: renderWith(lightTokens, `${itemsTemplate}<template #avatar>${monogramSvg}</template>`),
|
|
218
|
+
parameters: {
|
|
219
|
+
docs: { description: { story: "A group-wide `#avatar` slot (here an inline SVG logo; a `NuxtImg` works the same) fills the circle beside every answer, overriding `avatarLabel`." } },
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const IconAvatar: Story = {
|
|
224
|
+
name: "Icon Avatar (#avatar slot)",
|
|
225
|
+
render: renderWith(lightTokens, `${itemsTemplate}<template #avatar><Icon name="lucide:sparkles" style="font-size: 1.6rem;" /></template>`),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const NoAvatar: Story = {
|
|
229
|
+
name: "No Avatar",
|
|
230
|
+
args: { avatarLabel: "" },
|
|
231
|
+
render: renderWith(lightTokens),
|
|
232
|
+
parameters: {
|
|
233
|
+
docs: { description: { story: "No avatar slot and an empty `avatarLabel`: no avatar column, and the answer bubble sits flush left. `showAvatar=false` does the same even when a slot is filled." } },
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const PerPanelAvatar: Story = {
|
|
238
|
+
name: "Per-Panel Avatar Override",
|
|
239
|
+
render: renderWith(lightTokens, `${itemsTemplate}<template #item-1-avatar><Icon name="lucide:shirt" style="font-size: 1.6rem;" /></template>`),
|
|
240
|
+
parameters: {
|
|
241
|
+
docs: { description: { story: "`item-1-avatar` replaces the avatar for the second answer only; the others keep `avatarLabel`." } },
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const ExclusiveOpen: Story = {
|
|
246
|
+
name: "Exclusive Open (shared name)",
|
|
247
|
+
args: { name: "faq" },
|
|
248
|
+
render: renderWith(lightTokens),
|
|
249
|
+
parameters: {
|
|
250
|
+
docs: { description: { story: "With `name` set, opening one question closes the others (native `<details name>` grouping)." } },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const RichContent: Story = {
|
|
255
|
+
name: "Rich Answer Content",
|
|
256
|
+
render: renderWith(
|
|
257
|
+
lightTokens,
|
|
258
|
+
`
|
|
259
|
+
<template #item-0-summary>What does a consultation include?</template>
|
|
260
|
+
<template #item-0-content>
|
|
261
|
+
<p style="margin: 0 0 0.8rem;">A 30-minute chat about your goals, then a written plan. It covers:</p>
|
|
262
|
+
<ol style="margin: 0 0 0.8rem; padding-inline-start: 1.8rem;"><li>Your history and routine</li><li>A patch test</li><li>Pricing for the full course</li></ol>
|
|
263
|
+
<p style="margin: 0;"><a href="#">Book a consultation</a> or call <strong>01234 567890</strong>.</p>
|
|
264
|
+
</template>
|
|
265
|
+
<template #item-1-summary>Where are you?</template>
|
|
266
|
+
<template #item-1-content>
|
|
267
|
+
<p style="margin: 0 0 0.8rem;">12 High Street, Northtown. Here's the studio:</p>
|
|
268
|
+
<img src="/images/rotating-carousel/image-1.webp" alt="The Northtown studio" width="600" height="400" style="border-radius: 1rem; display: block;">
|
|
269
|
+
</template>
|
|
270
|
+
`
|
|
271
|
+
),
|
|
272
|
+
parameters: {
|
|
273
|
+
docs: { description: { story: "Answers take any markup: paragraphs, lists, links, images (capped to the bubble width)." } },
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const StressTest: Story = {
|
|
278
|
+
name: "Stress Test (Worst-Case Data)",
|
|
279
|
+
args: { avatarLabel: "Alexandra" },
|
|
280
|
+
render: (args) => ({
|
|
281
|
+
components: { AccordionChat },
|
|
282
|
+
setup() {
|
|
283
|
+
const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
|
|
284
|
+
const longUrl = "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(4);
|
|
285
|
+
const longCopy = "Unsere Behandlungen werden individuell auf Ihre Bedürfnisse abgestimmt, damit Sie sich rundum wohlfühlen. ".repeat(6);
|
|
286
|
+
const many = Array.from({ length: 12 }, (_, i) => ({
|
|
287
|
+
question: `Question ${i + 6}`,
|
|
288
|
+
summarySlot: `item-${i + 6}-summary`,
|
|
289
|
+
contentSlot: `item-${i + 6}-content`,
|
|
290
|
+
}));
|
|
291
|
+
return { ...useStorySetup(args), longUnbroken, longUrl, longCopy, many, lightTokens };
|
|
292
|
+
},
|
|
293
|
+
template: `
|
|
294
|
+
<div style="padding: 1.6rem; display: grid; gap: 3.2rem;" :style="[lightTokens, tokenStyle]">
|
|
295
|
+
<AccordionChat v-bind="componentArgs">
|
|
296
|
+
<template #item-0-summary>{{ longUnbroken }}</template>
|
|
297
|
+
<template #item-0-content><p style="margin: 0;">{{ longUrl }}</p></template>
|
|
298
|
+
<template #item-1-summary>{{ longCopy.slice(0, 200) }}</template>
|
|
299
|
+
<template #item-1-content><p style="margin: 0;">{{ longCopy }}</p></template>
|
|
300
|
+
<template #item-2-summary>🎉 Emoji first, then <b>html</b> as text</template>
|
|
301
|
+
<template #item-2-content><img src="/images/banners/banner-ginger.webp" alt="Wide banner, wider than the bubble" width="1600" height="400"></template>
|
|
302
|
+
<template #item-3-summary>A</template>
|
|
303
|
+
<template #item-4-summary>No content slot: opens to nothing</template>
|
|
304
|
+
<template v-for="item in many" :key="item.summarySlot" #[item.summarySlot]>{{ item.question }}</template>
|
|
305
|
+
<template v-for="item in many" :key="item.contentSlot" #[item.contentSlot]><p style="margin: 0;">Answer to {{ item.question }}.</p></template>
|
|
306
|
+
</AccordionChat>
|
|
307
|
+
|
|
308
|
+
<div dir="rtl">
|
|
309
|
+
<AccordionChat v-bind="componentArgs" avatar-label="👩🔬">
|
|
310
|
+
<template #item-0-summary>سؤال طويل من اليمين إلى اليسار للتحقق من الاتجاه</template>
|
|
311
|
+
<template #item-0-content><p style="margin: 0;">إجابة قصيرة. يجب أن تنعكس زوايا الفقاعات.</p></template>
|
|
312
|
+
</AccordionChat>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
`,
|
|
316
|
+
}),
|
|
317
|
+
decorators: [
|
|
318
|
+
(story, context) => ({
|
|
319
|
+
components: { story, CanvasSwitcher },
|
|
320
|
+
setup() {
|
|
321
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
322
|
+
return { canvasName };
|
|
323
|
+
},
|
|
324
|
+
template: `
|
|
325
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
326
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
327
|
+
</div>
|
|
328
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
329
|
+
<story />
|
|
330
|
+
</div>
|
|
331
|
+
`,
|
|
332
|
+
}),
|
|
333
|
+
],
|
|
334
|
+
parameters: {
|
|
335
|
+
initialCanvas: "mobileCanvas",
|
|
336
|
+
docs: {
|
|
337
|
+
story: { inline: false, height: "760px" },
|
|
338
|
+
description: {
|
|
339
|
+
story:
|
|
340
|
+
"Open every question at each canvas width. Long unbroken words and URLs wrap inside both bubbles and the icon stays visible; the " +
|
|
341
|
+
"avatar shows \"Al\" for the label \"Alexandra\"; the wide image shrinks to the bubble; the single-letter and no-content panels still " +
|
|
342
|
+
"look like bubbles; 12 more panels follow item-4 after a gap in the indexes; in the RTL group the question sits on the left, the " +
|
|
343
|
+
"answer on the right, the tail corners flip, and the emoji avatar label stays whole.",
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
4
|
+
import { defineComponent, nextTick, ref } from "vue";
|
|
5
|
+
import AccordionChat from "../AccordionChat.vue";
|
|
6
|
+
|
|
7
|
+
const twoItems = {
|
|
8
|
+
"item-0-summary": "<span>Question 0</span>",
|
|
9
|
+
"item-0-content": "<p class='answer-0'>Answer 0</p>",
|
|
10
|
+
"item-1-summary": "<span>Question 1</span>",
|
|
11
|
+
"item-1-content": "<p class='answer-1'>Answer 1</p>",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("AccordionChat", () => {
|
|
15
|
+
let wrapper: VueWrapper | undefined;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
wrapper?.unmount();
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ─── Mount and root ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
it("mounts with the accordion-chat root class", async () => {
|
|
25
|
+
wrapper = await mountSuspended(AccordionChat, { slots: twoItems });
|
|
26
|
+
expect(wrapper.classes()).toContain("accordion-chat");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders correct HTML structure", async () => {
|
|
30
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
31
|
+
props: { name: "faq", avatarLabel: "AM" },
|
|
32
|
+
slots: twoItems,
|
|
33
|
+
});
|
|
34
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ─── Panels inferred from slots ───────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
it("renders one panel per item-{n}-summary slot", async () => {
|
|
40
|
+
wrapper = await mountSuspended(AccordionChat, { slots: twoItems });
|
|
41
|
+
expect(wrapper.findAll(".accordion-chat-item")).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders gapped indexes in numeric order and ignores orphan content", async () => {
|
|
45
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
46
|
+
slots: {
|
|
47
|
+
"item-7-summary": "<span>Seven</span>",
|
|
48
|
+
"item-2-summary": "<span>Two</span>",
|
|
49
|
+
"item-4-content": "<p>Orphan</p>",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
expect(wrapper.findAll("summary").map((s) => s.text())).toEqual(["Two", "Seven"]);
|
|
53
|
+
expect(wrapper.text()).not.toContain("Orphan");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("adds a panel when a summary slot appears after mount", async () => {
|
|
57
|
+
const Host = defineComponent({
|
|
58
|
+
components: { AccordionChat },
|
|
59
|
+
setup() {
|
|
60
|
+
const showSecond = ref(false);
|
|
61
|
+
return { showSecond };
|
|
62
|
+
},
|
|
63
|
+
template: `
|
|
64
|
+
<AccordionChat>
|
|
65
|
+
<template #item-0-summary>First</template>
|
|
66
|
+
<template v-if="showSecond" #item-1-summary>Second</template>
|
|
67
|
+
</AccordionChat>
|
|
68
|
+
`,
|
|
69
|
+
});
|
|
70
|
+
wrapper = await mountSuspended(Host);
|
|
71
|
+
expect(wrapper.findAll(".accordion-chat-item")).toHaveLength(1);
|
|
72
|
+
(wrapper.vm as unknown as { showSecond: boolean }).showSecond = true;
|
|
73
|
+
await nextTick();
|
|
74
|
+
expect(wrapper.findAll(".accordion-chat-item")).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── Bubbles ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("puts the summary in the question bubble and the content in the answer bubble", async () => {
|
|
80
|
+
wrapper = await mountSuspended(AccordionChat, { slots: twoItems });
|
|
81
|
+
const second = wrapper.findAll(".accordion-chat-item")[1]!;
|
|
82
|
+
expect(second.find("summary").text()).toContain("Question 1");
|
|
83
|
+
expect(second.find(".accordion-chat-bubble .answer-1").exists()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders no answer bubble for a panel without a content slot", async () => {
|
|
87
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
88
|
+
props: { avatarLabel: "A" },
|
|
89
|
+
slots: { "item-0-summary": "<span>No answer</span>" },
|
|
90
|
+
});
|
|
91
|
+
expect(wrapper.find(".accordion-chat-answer").exists()).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("shows the default plus icon, and an item-{n}-icon slot replaces it", async () => {
|
|
95
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
96
|
+
slots: { ...twoItems, "item-1-icon": "<span class='custom-icon'>?</span>" },
|
|
97
|
+
});
|
|
98
|
+
const [first, second] = wrapper.findAll(".accordion-chat-item");
|
|
99
|
+
expect(first!.find(".accordion-chat-icon").attributes("aria-hidden")).toBe("true");
|
|
100
|
+
expect(second!.find(".accordion-chat-icon").exists()).toBe(false);
|
|
101
|
+
expect(second!.find(".custom-icon").exists()).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ─── Avatar precedence ────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
it("uses the avatarLabel when no avatar slot is given", async () => {
|
|
107
|
+
wrapper = await mountSuspended(AccordionChat, { props: { avatarLabel: "S" }, slots: twoItems });
|
|
108
|
+
expect(wrapper.findAll(".accordion-chat-avatar").map((a) => a.text())).toEqual(["S", "S"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("prefers the group #avatar slot over the label", async () => {
|
|
112
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
113
|
+
props: { avatarLabel: "S" },
|
|
114
|
+
slots: { ...twoItems, avatar: "<span class='group-avatar'>G</span>" },
|
|
115
|
+
});
|
|
116
|
+
expect(wrapper.findAll(".accordion-chat-avatar .group-avatar")).toHaveLength(2);
|
|
117
|
+
expect(wrapper.find(".accordion-chat-avatar").text()).toBe("G");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("prefers a per-panel avatar slot over the group slot", async () => {
|
|
121
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
122
|
+
slots: {
|
|
123
|
+
...twoItems,
|
|
124
|
+
avatar: "<span class='group-avatar'>G</span>",
|
|
125
|
+
"item-1-avatar": "<span class='panel-avatar'>P</span>",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const avatars = wrapper.findAll(".accordion-chat-avatar");
|
|
129
|
+
expect(avatars[0]!.find(".group-avatar").exists()).toBe(true);
|
|
130
|
+
expect(avatars[1]!.find(".panel-avatar").exists()).toBe(true);
|
|
131
|
+
expect(avatars[1]!.find(".group-avatar").exists()).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renders no avatar column when there is no slot and no label", async () => {
|
|
135
|
+
wrapper = await mountSuspended(AccordionChat, { slots: twoItems });
|
|
136
|
+
expect(wrapper.find(".accordion-chat-avatar").exists()).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("only gives an avatar to the panel that has its own avatar slot", async () => {
|
|
140
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
141
|
+
slots: { ...twoItems, "item-0-avatar": "<span>P</span>" },
|
|
142
|
+
});
|
|
143
|
+
const [first, second] = wrapper.findAll(".accordion-chat-item");
|
|
144
|
+
expect(first!.find(".accordion-chat-avatar").exists()).toBe(true);
|
|
145
|
+
expect(second!.find(".accordion-chat-avatar").exists()).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("removes the avatar column when showAvatar is false", async () => {
|
|
149
|
+
wrapper = await mountSuspended(AccordionChat, {
|
|
150
|
+
props: { avatarLabel: "S", showAvatar: false },
|
|
151
|
+
slots: { ...twoItems, avatar: "<span>G</span>" },
|
|
152
|
+
});
|
|
153
|
+
expect(wrapper.find(".accordion-chat-avatar").exists()).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("hides the avatar from assistive tech", async () => {
|
|
157
|
+
wrapper = await mountSuspended(AccordionChat, { props: { avatarLabel: "S" }, slots: twoItems });
|
|
158
|
+
expect(wrapper.find(".accordion-chat-avatar").attributes("aria-hidden")).toBe("true");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it.each([
|
|
162
|
+
["Alexandra", "Al"],
|
|
163
|
+
[" Sam ", "Sa"],
|
|
164
|
+
["👩🔬 Lab", "👩🔬 "],
|
|
165
|
+
["N", "N"],
|
|
166
|
+
])("trims avatarLabel %j to two characters", async (avatarLabel, expected) => {
|
|
167
|
+
wrapper = await mountSuspended(AccordionChat, { props: { avatarLabel }, slots: twoItems });
|
|
168
|
+
expect(wrapper.find(".accordion-chat-avatar").text()).toBe(expected.trim());
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── Panel behaviour from ExpandingPanelClassic ───────────────────────────
|
|
172
|
+
|
|
173
|
+
it("gives every panel's <details> the shared name", async () => {
|
|
174
|
+
wrapper = await mountSuspended(AccordionChat, { props: { name: "faq" }, slots: twoItems });
|
|
175
|
+
expect(wrapper.findAll("details").map((d) => d.attributes("name"))).toEqual(["faq", "faq"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps the panel's aria-expanded and unique aria-controls pairing", async () => {
|
|
179
|
+
wrapper = await mountSuspended(AccordionChat, { props: { name: "faq" }, slots: twoItems });
|
|
180
|
+
const summaries = wrapper.findAll("summary");
|
|
181
|
+
expect(summaries.map((s) => s.attributes("aria-expanded"))).toEqual(["false", "false"]);
|
|
182
|
+
const ids = wrapper.findAll("[id]").map((el) => el.attributes("id"));
|
|
183
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
184
|
+
for (const summary of summaries) {
|
|
185
|
+
const region = wrapper.find(`[id="${summary.attributes("aria-controls")}"]`);
|
|
186
|
+
expect(region.attributes("aria-labelledby")).toBe(summary.attributes("id"));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("toggles aria-expanded when a question is clicked", async () => {
|
|
191
|
+
wrapper = await mountSuspended(AccordionChat, { slots: twoItems });
|
|
192
|
+
await wrapper.find("summary").trigger("click");
|
|
193
|
+
expect(wrapper.find("summary").attributes("aria-expanded")).toBe("true");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ─── styleClassPassthrough ────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
it("applies and updates styleClassPassthrough classes on the root", async () => {
|
|
199
|
+
wrapper = await mountSuspended(AccordionChat, { props: { styleClassPassthrough: ["faq"] } });
|
|
200
|
+
expect(wrapper.classes()).toContain("faq");
|
|
201
|
+
await wrapper.setProps({ styleClassPassthrough: ["help"] });
|
|
202
|
+
expect(wrapper.classes()).not.toContain("faq");
|
|
203
|
+
expect(wrapper.classes()).toContain("help");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`AccordionChat > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="accordion-chat">
|
|
5
|
+
<div class="expanding-panel-classic accordion-chat-item">
|
|
6
|
+
<details class="expanding-panel-classic-details" name="faq">
|
|
7
|
+
<summary id="v-0-0-1-trigger" class="expanding-panel-classic-summary" aria-controls="v-0-0-1-content" aria-expanded="false">
|
|
8
|
+
<div class="label-wrapper"><span>Question 0</span></div>
|
|
9
|
+
<div class="icon-wrapper"><span class="accordion-chat-icon" aria-hidden="true"><span class="iconify i-lucide:plus accordion-chat-icon-glyph" aria-hidden="true"></span></span></div>
|
|
10
|
+
</summary>
|
|
11
|
+
</details>
|
|
12
|
+
<div id="v-0-0-1-content" class="expanding-panel-classic-content" aria-labelledby="v-0-0-1-trigger" role="region">
|
|
13
|
+
<div class="inner">
|
|
14
|
+
<div class="accordion-chat-answer"><span class="accordion-chat-avatar" aria-hidden="true">AM</span>
|
|
15
|
+
<div class="accordion-chat-bubble">
|
|
16
|
+
<p class="answer-0">Answer 0</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="expanding-panel-classic accordion-chat-item">
|
|
23
|
+
<details class="expanding-panel-classic-details" name="faq">
|
|
24
|
+
<summary id="v-0-0-3-trigger" class="expanding-panel-classic-summary" aria-controls="v-0-0-3-content" aria-expanded="false">
|
|
25
|
+
<div class="label-wrapper"><span>Question 1</span></div>
|
|
26
|
+
<div class="icon-wrapper"><span class="accordion-chat-icon" aria-hidden="true"><span class="iconify i-lucide:plus accordion-chat-icon-glyph" aria-hidden="true"></span></span></div>
|
|
27
|
+
</summary>
|
|
28
|
+
</details>
|
|
29
|
+
<div id="v-0-0-3-content" class="expanding-panel-classic-content" aria-labelledby="v-0-0-3-trigger" role="region">
|
|
30
|
+
<div class="inner">
|
|
31
|
+
<div class="accordion-chat-answer"><span class="accordion-chat-avatar" aria-hidden="true">AM</span>
|
|
32
|
+
<div class="accordion-chat-bubble">
|
|
33
|
+
<p class="answer-1">Answer 1</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>"
|
|
40
|
+
`;
|