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
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="
|
|
2
|
+
<div class="accordion-group" :class="elementClasses">
|
|
3
3
|
<component
|
|
4
4
|
:is="panelComponent"
|
|
5
|
-
v-for="
|
|
6
|
-
:key="
|
|
5
|
+
v-for="index in panelIndexes()"
|
|
6
|
+
:key="index"
|
|
7
7
|
:name
|
|
8
8
|
:animation-duration="animationDuration"
|
|
9
|
-
|
|
10
|
-
:style-class-passthrough="['accordian-item']"
|
|
9
|
+
:style-class-passthrough="['accordion-group-item']"
|
|
11
10
|
>
|
|
12
11
|
<template #summary>
|
|
13
|
-
<slot :name="`
|
|
12
|
+
<slot :name="`item-${index}-summary`"></slot>
|
|
14
13
|
</template>
|
|
15
|
-
<template #icon>
|
|
16
|
-
<slot :name="`
|
|
14
|
+
<template v-if="slots[`item-${index}-icon`]" #icon>
|
|
15
|
+
<slot :name="`item-${index}-icon`"></slot>
|
|
17
16
|
</template>
|
|
18
17
|
<template #content>
|
|
19
|
-
<slot :name="`
|
|
18
|
+
<slot :name="`item-${index}-content`"></slot>
|
|
20
19
|
</template>
|
|
21
20
|
</component>
|
|
22
21
|
</div>
|
|
@@ -28,36 +27,49 @@ import ExpandingPanelClassic from "../expanding-panel-classic/ExpandingPanelClas
|
|
|
28
27
|
|
|
29
28
|
interface Props {
|
|
30
29
|
name?: string;
|
|
31
|
-
itemCount?: number;
|
|
32
30
|
animationDuration?: number;
|
|
33
31
|
variant?: "modern" | "classic";
|
|
34
32
|
styleClassPassthrough?: string | string[];
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
//
|
|
38
|
-
// ::details-content, which WebKit (Safari/iOS) doesn't support (see ExpandingPanel's own dev-mode
|
|
39
|
-
// warning and CLAUDE.md pitfall #19). "modern" is opt-in for consumers who've confirmed it works
|
|
40
|
-
// for their audience, not the safe default.
|
|
35
|
+
// "classic" by default: "modern" relies on ::details-content, which WebKit lacks (pitfall #19).
|
|
41
36
|
const props = withDefaults(defineProps<Props>(), {
|
|
42
37
|
name: undefined,
|
|
43
|
-
itemCount: 0,
|
|
44
38
|
animationDuration: 300,
|
|
45
39
|
variant: "classic",
|
|
46
40
|
styleClassPassthrough: () => [],
|
|
47
41
|
});
|
|
48
42
|
|
|
49
|
-
const
|
|
43
|
+
const slots = useSlots();
|
|
44
|
+
const SUMMARY_SLOT = /^item-(\d+)-summary$/;
|
|
45
|
+
|
|
46
|
+
const panelIndexes = () =>
|
|
47
|
+
Object.keys(slots)
|
|
48
|
+
.map((slotName) => SUMMARY_SLOT.exec(slotName)?.[1])
|
|
49
|
+
.filter((index): index is string => index !== undefined)
|
|
50
|
+
.map(Number)
|
|
51
|
+
.sort((a, b) => a - b);
|
|
52
|
+
|
|
50
53
|
const animationDurationStr = computed(() => `${props.animationDuration}ms`);
|
|
51
54
|
const panelComponent = computed(() => (props.variant === "classic" ? ExpandingPanelClassic : ExpandingPanel));
|
|
55
|
+
|
|
56
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
57
|
+
|
|
58
|
+
watch(
|
|
59
|
+
() => props.styleClassPassthrough,
|
|
60
|
+
() => {
|
|
61
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
62
|
+
}
|
|
63
|
+
);
|
|
52
64
|
</script>
|
|
53
65
|
|
|
54
66
|
<style lang="css">
|
|
55
67
|
@layer components {
|
|
56
|
-
.
|
|
57
|
-
max-
|
|
58
|
-
margin:
|
|
68
|
+
.accordion-group {
|
|
69
|
+
max-inline-size: var(--accordion-group-max-width, 60rem);
|
|
70
|
+
margin-inline: auto;
|
|
59
71
|
|
|
60
|
-
.
|
|
72
|
+
.accordion-group-item {
|
|
61
73
|
&.expanding-panel,
|
|
62
74
|
&.expanding-panel-classic {
|
|
63
75
|
transition:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# AccordionGroup — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
> Renamed 2026-10-20: `AccordianCore` → `AccordionGroup`, root class `.display-accordian` →
|
|
4
|
+
> `.accordion-group`, panel class `.accordian-item` → `.accordion-group-item`.
|
|
5
|
+
|
|
6
|
+
## Public token API
|
|
7
|
+
|
|
8
|
+
| Token | Default | Controls |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `--accordion-group-max-width` | `60rem` | Maximum width of the group (it centres itself with `margin-inline: auto`). Use `100%` to fill the container. |
|
|
11
|
+
|
|
12
|
+
> Changed 2026-10-20: the width was a hardcoded `max-width: 600px`; it's now this token, same
|
|
13
|
+
> default.
|
|
14
|
+
|
|
15
|
+
Everything else (summary padding and gap, icon size, colours, borders) belongs to the panel
|
|
16
|
+
component the group renders, so set those panels' tokens; they reach every panel in the group:
|
|
17
|
+
|
|
18
|
+
- `variant="classic"` (default): `ExpandingPanelClassic`, see
|
|
19
|
+
[../expanding-panel-classic/CONSUMER-STYLING.md](../expanding-panel-classic/CONSUMER-STYLING.md)
|
|
20
|
+
- `variant="modern"`: `ExpandingPanel`, see
|
|
21
|
+
[../expanding-panel/CONSUMER-STYLING.md](../expanding-panel/CONSUMER-STYLING.md)
|
|
22
|
+
|
|
23
|
+
Private tokens: none. The animation duration reaches the CSS through `v-bind()` from the
|
|
24
|
+
`animationDuration` prop.
|
|
25
|
+
|
|
26
|
+
## State hooks
|
|
27
|
+
|
|
28
|
+
| Selector | Meaning |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `.accordion-group` | Root element. |
|
|
31
|
+
| `.accordion-group-item` | Every panel in the group (added through each panel's `style-class-passthrough`). |
|
|
32
|
+
| `.accordion-group-item.expanding-panel-classic` | A panel, `variant="classic"`. |
|
|
33
|
+
| `.accordion-group-item.expanding-panel` | A panel, `variant="modern"`. |
|
|
34
|
+
|
|
35
|
+
The panel root class depends on `variant`, so a selector written for one variant won't match the
|
|
36
|
+
other.
|
|
37
|
+
|
|
38
|
+
## Local overrides
|
|
39
|
+
|
|
40
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
41
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
42
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
43
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
44
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
45
|
+
|
|
46
|
+
## Recipe: dividers between panels
|
|
47
|
+
|
|
48
|
+
```css
|
|
49
|
+
.faq-section {
|
|
50
|
+
--accordion-group-max-width: 72rem;
|
|
51
|
+
|
|
52
|
+
.accordion-group-item:not(:last-child) {
|
|
53
|
+
border-block-end: 0.1rem solid var(--theme-border);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Class passthrough
|
|
59
|
+
|
|
60
|
+
`style-class-passthrough` adds classes to the root `.accordion-group` (reactive after mount). The
|
|
61
|
+
panels always get `.accordion-group-item`; there's no per-panel passthrough.
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- `contentIsOnTop` isn't exposed: it's for a single panel overlaying the content after it, not
|
|
66
|
+
stacked panels. Use `ExpandingPanel`/`ExpandingPanelClassic` directly for that.
|
package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import AccordionGroup from "../AccordionGroup.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
|
+
variant?: "modern" | "classic";
|
|
11
|
+
panelCount?: number;
|
|
12
|
+
maxWidth?: string;
|
|
13
|
+
styleClassPassthrough?: string | string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const meta: Meta<StoryArgs> = {
|
|
17
|
+
title: "Molecules/Expandable/AccordionGroup",
|
|
18
|
+
component: AccordionGroup,
|
|
19
|
+
argTypes: {
|
|
20
|
+
name: {
|
|
21
|
+
control: { type: "text" },
|
|
22
|
+
description:
|
|
23
|
+
"Shared `name` on every panel's `<details>`, so the browser keeps only one open at a time. Leave empty for independent panels.",
|
|
24
|
+
},
|
|
25
|
+
animationDuration: {
|
|
26
|
+
control: { type: "number", min: 0, step: 50 },
|
|
27
|
+
description: "Open/close animation duration in milliseconds, passed to every panel.",
|
|
28
|
+
},
|
|
29
|
+
variant: {
|
|
30
|
+
control: { type: "inline-radio" },
|
|
31
|
+
options: ["classic", "modern"],
|
|
32
|
+
description:
|
|
33
|
+
"`classic` (ExpandingPanelClassic, default, works in every browser) or `modern` (ExpandingPanel, needs `::details-content`, so not WebKit).",
|
|
34
|
+
},
|
|
35
|
+
panelCount: {
|
|
36
|
+
control: { type: "range", min: 0, max: 8, step: 1 },
|
|
37
|
+
description: "**Story control, not a prop.** How many `item-{n}-summary` slots the story fills; the component counts them itself.",
|
|
38
|
+
table: { category: "Slot content (story only)" },
|
|
39
|
+
},
|
|
40
|
+
maxWidth: {
|
|
41
|
+
control: { type: "select" },
|
|
42
|
+
options: ["40rem", "60rem", "100%"],
|
|
43
|
+
description:
|
|
44
|
+
"**Story control, not a prop.** Sets `--accordion-group-max-width`. In your CSS: `.faq { --accordion-group-max-width: 80rem; }`",
|
|
45
|
+
table: { category: "CSS tokens (story only, set in your CSS)" },
|
|
46
|
+
},
|
|
47
|
+
styleClassPassthrough: { table: { disable: true } },
|
|
48
|
+
},
|
|
49
|
+
args: {
|
|
50
|
+
name: "",
|
|
51
|
+
animationDuration: 300,
|
|
52
|
+
variant: "classic",
|
|
53
|
+
panelCount: 3,
|
|
54
|
+
maxWidth: "60rem",
|
|
55
|
+
},
|
|
56
|
+
parameters: {
|
|
57
|
+
docs: {
|
|
58
|
+
description: {
|
|
59
|
+
component:
|
|
60
|
+
"A group of expanding panels. Each panel is defined by `item-{n}-summary`, `item-{n}-content` and an optional `item-{n}-icon` slot; " +
|
|
61
|
+
"the group renders one panel per `item-{n}-summary` slot, in numeric order, so there's no count to keep in sync. " +
|
|
62
|
+
"Pass `name` to make it exclusive (one panel open at a time). `variant` picks the panel implementation, `classic` by default " +
|
|
63
|
+
"because `modern` relies on `::details-content`, which WebKit lacks.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
type Story = StoryObj<StoryArgs>;
|
|
71
|
+
|
|
72
|
+
const faqs = [
|
|
73
|
+
{ q: "How long does a first appointment take?", a: "Allow about an hour, including a short consultation before we start." },
|
|
74
|
+
{ q: "Can I change or cancel a booking?", a: "Yes, up to 24 hours before your appointment, from the link in your confirmation email." },
|
|
75
|
+
{ q: "Do you offer gift vouchers?", a: "Vouchers are available in any amount and are valid for twelve months." },
|
|
76
|
+
{ q: "Is there parking nearby?", a: "There's free on-street parking on Northtown Road and a pay car park two minutes away." },
|
|
77
|
+
{ q: "Which payment methods do you accept?", a: "Card, contactless and cash." },
|
|
78
|
+
{ q: "Do I need to bring anything?", a: "Just yourself. Wear something comfortable." },
|
|
79
|
+
{ q: "Is the studio accessible?", a: "Everything is on the ground floor, with step-free access from the street." },
|
|
80
|
+
{ q: "Can I bring a friend?", a: "Of course, there's a seating area in reception." },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function useStorySetup(args: StoryArgs) {
|
|
84
|
+
const componentArgs = computed(() => ({
|
|
85
|
+
name: args.name || undefined,
|
|
86
|
+
animationDuration: args.animationDuration,
|
|
87
|
+
variant: args.variant,
|
|
88
|
+
}));
|
|
89
|
+
const tokenStyle = computed(() => ({ "--accordion-group-max-width": args.maxWidth }));
|
|
90
|
+
const items = computed(() => faqs.slice(0, args.panelCount ?? 3));
|
|
91
|
+
return { componentArgs, tokenStyle, items };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const faqTemplate = `
|
|
95
|
+
<AccordionGroup v-bind="componentArgs" :style="tokenStyle">
|
|
96
|
+
<template v-for="(item, i) in items" :key="i" #[\`item-\${i}-summary\`]>{{ item.q }}</template>
|
|
97
|
+
<template v-for="(item, i) in items" :key="'c' + i" #[\`item-\${i}-content\`]><p style="margin: 0; padding-block: 0.8rem;">{{ item.a }}</p></template>
|
|
98
|
+
</AccordionGroup>
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
export const Default: Story = {
|
|
102
|
+
render: (args) => ({
|
|
103
|
+
components: { AccordionGroup },
|
|
104
|
+
setup() {
|
|
105
|
+
return useStorySetup(args);
|
|
106
|
+
},
|
|
107
|
+
template: `<div style="padding: 2.4rem;">${faqTemplate}</div>`,
|
|
108
|
+
}),
|
|
109
|
+
parameters: {
|
|
110
|
+
docs: { description: { story: "Independent panels: any number can be open. Change the panel count control to add or remove `item-{n}-summary` slots." } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const ExclusiveOpen: Story = {
|
|
115
|
+
name: "Exclusive Open (shared name)",
|
|
116
|
+
args: { name: "faq", panelCount: 4 },
|
|
117
|
+
render: Default.render,
|
|
118
|
+
parameters: {
|
|
119
|
+
docs: { description: { story: "With `name` set, opening one panel closes the others (native `<details name>` grouping). Each panel still gets its own ids." } },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const ModernVariant: Story = {
|
|
124
|
+
name: "Modern Variant",
|
|
125
|
+
args: { variant: "modern" },
|
|
126
|
+
render: Default.render,
|
|
127
|
+
parameters: {
|
|
128
|
+
docs: { description: { story: "`variant=\"modern\"` renders `ExpandingPanel`, animated with `::details-content`. It doesn't animate in WebKit (Safari, every iOS browser)." } },
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const CustomIcons: Story = {
|
|
133
|
+
name: "Custom Icons",
|
|
134
|
+
render: (args) => ({
|
|
135
|
+
components: { AccordionGroup },
|
|
136
|
+
setup() {
|
|
137
|
+
return useStorySetup(args);
|
|
138
|
+
},
|
|
139
|
+
template: `
|
|
140
|
+
<div style="padding: 2.4rem;">
|
|
141
|
+
<AccordionGroup v-bind="componentArgs" :style="tokenStyle">
|
|
142
|
+
<template #item-0-summary>Default caret (no icon slot)</template>
|
|
143
|
+
<template #item-0-content><p style="margin: 0; padding-block: 0.8rem;">This panel keeps the panel's own caret.</p></template>
|
|
144
|
+
<template #item-1-summary>Custom icon</template>
|
|
145
|
+
<template #item-1-icon><Icon name="lucide:plus" /></template>
|
|
146
|
+
<template #item-1-content><p style="margin: 0; padding-block: 0.8rem;">This panel's caret is replaced by its <code>item-1-icon</code> slot.</p></template>
|
|
147
|
+
</AccordionGroup>
|
|
148
|
+
</div>
|
|
149
|
+
`,
|
|
150
|
+
}),
|
|
151
|
+
parameters: {
|
|
152
|
+
docs: { description: { story: "`item-{n}-icon` is optional per panel. Without it the panel keeps its default caret." } },
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const SlowAnimation: Story = {
|
|
157
|
+
name: "Slow Animation (800ms)",
|
|
158
|
+
args: { animationDuration: 800 },
|
|
159
|
+
render: Default.render,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const NoPanels: Story = {
|
|
163
|
+
name: "No Panels",
|
|
164
|
+
args: { panelCount: 0 },
|
|
165
|
+
render: Default.render,
|
|
166
|
+
parameters: {
|
|
167
|
+
docs: { description: { story: "With no `item-{n}-summary` slots the group renders an empty wrapper." } },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const StressTest: Story = {
|
|
172
|
+
name: "Stress Test (Worst-Case Data)",
|
|
173
|
+
args: { name: "stress", maxWidth: "100%" },
|
|
174
|
+
render: (args) => ({
|
|
175
|
+
components: { AccordionGroup },
|
|
176
|
+
setup() {
|
|
177
|
+
const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
|
|
178
|
+
const longUrl = "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(4);
|
|
179
|
+
const longCopy =
|
|
180
|
+
"Unsere Behandlungen werden individuell auf Ihre Bedürfnisse abgestimmt, damit Sie sich rundum wohlfühlen. ".repeat(8);
|
|
181
|
+
return { ...useStorySetup(args), longUnbroken, longUrl, longCopy };
|
|
182
|
+
},
|
|
183
|
+
template: `
|
|
184
|
+
<div style="padding: 1.6rem;">
|
|
185
|
+
<AccordionGroup v-bind="componentArgs" :style="tokenStyle">
|
|
186
|
+
<template #item-0-summary>{{ longUnbroken }}</template>
|
|
187
|
+
<template #item-0-content><p style="margin: 0; padding-block: 0.8rem;">{{ longUrl }}</p></template>
|
|
188
|
+
|
|
189
|
+
<template #item-1-summary>{{ longCopy.slice(0, 220) }}</template>
|
|
190
|
+
<template #item-1-content><p style="margin: 0; padding-block: 0.8rem;">{{ longCopy }}</p></template>
|
|
191
|
+
|
|
192
|
+
<template #item-2-summary>🎉 Emoji first, then <b>html</b> as text</template>
|
|
193
|
+
<template #item-2-content><p style="margin: 0; padding-block: 0.8rem;"><script>alert(1)</script></p></template>
|
|
194
|
+
|
|
195
|
+
<template #item-3-summary><span dir="rtl">سؤال طويل من اليمين إلى اليسار للتحقق من الاتجاه</span></template>
|
|
196
|
+
<template #item-3-content><p dir="rtl" style="margin: 0; padding-block: 0.8rem;">إجابة قصيرة.</p></template>
|
|
197
|
+
|
|
198
|
+
<template #item-4-summary>A</template>
|
|
199
|
+
<template #item-4-content></template>
|
|
200
|
+
|
|
201
|
+
<template #item-9-summary>Gapped index (item-9 after item-4)</template>
|
|
202
|
+
<template #item-9-content><p style="margin: 0; padding-block: 0.8rem;">Indexes needn't be contiguous; panels render in numeric order.</p></template>
|
|
203
|
+
|
|
204
|
+
<template #item-7-content><p>Orphan content with no summary: never rendered.</p></template>
|
|
205
|
+
</AccordionGroup>
|
|
206
|
+
</div>
|
|
207
|
+
`,
|
|
208
|
+
}),
|
|
209
|
+
decorators: [
|
|
210
|
+
(story, context) => ({
|
|
211
|
+
components: { story, CanvasSwitcher },
|
|
212
|
+
setup() {
|
|
213
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
214
|
+
return { canvasName };
|
|
215
|
+
},
|
|
216
|
+
template: `
|
|
217
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
218
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
219
|
+
</div>
|
|
220
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
221
|
+
<story />
|
|
222
|
+
</div>
|
|
223
|
+
`,
|
|
224
|
+
}),
|
|
225
|
+
],
|
|
226
|
+
parameters: {
|
|
227
|
+
initialCanvas: "mobileCanvas",
|
|
228
|
+
docs: {
|
|
229
|
+
story: { inline: false, height: "720px" },
|
|
230
|
+
description: {
|
|
231
|
+
story:
|
|
232
|
+
"Open each panel at every canvas width. Long unbroken words and URLs should wrap inside the summary and content, the caret should " +
|
|
233
|
+
"stay visible beside a three-line summary, emoji and HTML-like text render as text, the RTL summary reads right to left, the empty " +
|
|
234
|
+
"content panel opens to nothing, item-9 follows item-4, and the orphan item-7 content never appears.",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { defineComponent, nextTick, ref } from "vue";
|
|
4
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
5
|
+
import AccordionGroup from "../AccordionGroup.vue";
|
|
6
|
+
|
|
7
|
+
type Wrapper = VueWrapper;
|
|
8
|
+
|
|
9
|
+
const threeItems = {
|
|
10
|
+
"item-0-summary": "<span>Summary 0</span>",
|
|
11
|
+
"item-0-content": "<p>Content 0</p>",
|
|
12
|
+
"item-1-summary": "<span>Summary 1</span>",
|
|
13
|
+
"item-1-content": "<p>Content 1</p>",
|
|
14
|
+
"item-2-summary": "<span>Summary 2</span>",
|
|
15
|
+
"item-2-content": "<p>Content 2</p>",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("AccordionGroup", () => {
|
|
19
|
+
let wrapper: Wrapper | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
wrapper?.unmount();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ─── Mount and root ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
it("mounts without error", async () => {
|
|
29
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
30
|
+
expect(wrapper.vm).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders correct HTML structure", async () => {
|
|
34
|
+
wrapper = await mountSuspended(AccordionGroup, {
|
|
35
|
+
props: { name: "faq" },
|
|
36
|
+
slots: { ...threeItems, "item-1-icon": "<span>+</span>" },
|
|
37
|
+
});
|
|
38
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("has the accordion-group root class", async () => {
|
|
42
|
+
wrapper = await mountSuspended(AccordionGroup);
|
|
43
|
+
expect(wrapper.classes()).toContain("accordion-group");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── Panel count inferred from slots ──────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
it("renders one panel per item-{n}-summary slot", async () => {
|
|
49
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
50
|
+
expect(wrapper.findAll(".accordion-group-item")).toHaveLength(3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders no panels when no summary slots are given", async () => {
|
|
54
|
+
wrapper = await mountSuspended(AccordionGroup);
|
|
55
|
+
expect(wrapper.findAll(".accordion-group-item")).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores icon and content slots that have no matching summary", async () => {
|
|
59
|
+
wrapper = await mountSuspended(AccordionGroup, {
|
|
60
|
+
slots: { "item-0-summary": "<span>Only</span>", "item-5-content": "<p>Orphan</p>", "item-6-icon": "<span>!</span>" },
|
|
61
|
+
});
|
|
62
|
+
expect(wrapper.findAll(".accordion-group-item")).toHaveLength(1);
|
|
63
|
+
expect(wrapper.text()).not.toContain("Orphan");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders gapped indexes in numeric order", async () => {
|
|
67
|
+
wrapper = await mountSuspended(AccordionGroup, {
|
|
68
|
+
slots: {
|
|
69
|
+
"item-10-summary": "<span>Ten</span>",
|
|
70
|
+
"item-2-summary": "<span>Two</span>",
|
|
71
|
+
"item-0-summary": "<span>Zero</span>",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const summaries = wrapper.findAll(".accordion-group-item summary").map((s) => s.text());
|
|
75
|
+
expect(summaries).toEqual(["Zero", "Two", "Ten"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("adds a panel when a summary slot appears after mount", async () => {
|
|
79
|
+
const Host = defineComponent({
|
|
80
|
+
components: { AccordionGroup },
|
|
81
|
+
setup() {
|
|
82
|
+
const showSecond = ref(false);
|
|
83
|
+
return { showSecond };
|
|
84
|
+
},
|
|
85
|
+
template: `
|
|
86
|
+
<AccordionGroup>
|
|
87
|
+
<template #item-0-summary>First</template>
|
|
88
|
+
<template v-if="showSecond" #item-1-summary>Second</template>
|
|
89
|
+
</AccordionGroup>
|
|
90
|
+
`,
|
|
91
|
+
});
|
|
92
|
+
wrapper = await mountSuspended(Host);
|
|
93
|
+
expect(wrapper.findAll(".accordion-group-item")).toHaveLength(1);
|
|
94
|
+
(wrapper.vm as unknown as { showSecond: boolean }).showSecond = true;
|
|
95
|
+
await nextTick();
|
|
96
|
+
expect(wrapper.findAll(".accordion-group-item")).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── Slot content ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
it("places summary and content in the matching panel", async () => {
|
|
102
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
103
|
+
const second = wrapper.findAll(".accordion-group-item")[1]!;
|
|
104
|
+
expect(second.find("summary").text()).toContain("Summary 1");
|
|
105
|
+
expect(second.text()).toContain("Content 1");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps the panel's default caret when no icon slot is given", async () => {
|
|
109
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
110
|
+
expect(wrapper.findAll(".accordion-group-item .icon-wrapper svg, .accordion-group-item .icon-wrapper .icon")).not.toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("replaces the caret with the item's icon slot", async () => {
|
|
114
|
+
wrapper = await mountSuspended(AccordionGroup, {
|
|
115
|
+
slots: { "item-0-summary": "<span>S</span>", "item-0-icon": "<span class='custom-icon'>+</span>" },
|
|
116
|
+
});
|
|
117
|
+
const iconWrapper = wrapper.find(".accordion-group-item .icon-wrapper");
|
|
118
|
+
expect(iconWrapper.find(".custom-icon").exists()).toBe(true);
|
|
119
|
+
expect(iconWrapper.find(".icon").exists()).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── Props forwarded to panels ────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
it("renders ExpandingPanelClassic by default", async () => {
|
|
125
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
126
|
+
expect(wrapper.findAll(".accordion-group-item.expanding-panel-classic")).toHaveLength(3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders ExpandingPanel when variant='modern'", async () => {
|
|
130
|
+
wrapper = await mountSuspended(AccordionGroup, { props: { variant: "modern" }, slots: threeItems });
|
|
131
|
+
expect(wrapper.findAll(".accordion-group-item.expanding-panel")).toHaveLength(3);
|
|
132
|
+
expect(wrapper.findAll(".expanding-panel-classic")).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("gives every panel's <details> the shared name", async () => {
|
|
136
|
+
wrapper = await mountSuspended(AccordionGroup, { props: { name: "faq" }, slots: threeItems });
|
|
137
|
+
const names = wrapper.findAll("details").map((d) => d.attributes("name"));
|
|
138
|
+
expect(names).toEqual(["faq", "faq", "faq"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("gives each panel unique ids even when they share a name", async () => {
|
|
142
|
+
wrapper = await mountSuspended(AccordionGroup, { props: { name: "faq" }, slots: threeItems });
|
|
143
|
+
const ids = wrapper.findAll("[id]").map((el) => el.attributes("id"));
|
|
144
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
145
|
+
for (const summary of wrapper.findAll("summary")) {
|
|
146
|
+
const contentId = summary.attributes("aria-controls")!;
|
|
147
|
+
expect(wrapper.find(`[id="${contentId}"]`).attributes("aria-labelledby")).toBe(summary.attributes("id"));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("passes only declared props to the panels (no stray attributes)", async () => {
|
|
152
|
+
wrapper = await mountSuspended(AccordionGroup, { slots: threeItems });
|
|
153
|
+
const panel = wrapper.find(".accordion-group-item");
|
|
154
|
+
expect(panel.attributes("icon-size")).toBeUndefined();
|
|
155
|
+
expect(panel.attributes("animation-duration")).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── styleClassPassthrough ────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
it("applies styleClassPassthrough classes to the root", async () => {
|
|
161
|
+
wrapper = await mountSuspended(AccordionGroup, { props: { styleClassPassthrough: ["faq", "compact"] } });
|
|
162
|
+
expect(wrapper.classes()).toContain("faq");
|
|
163
|
+
expect(wrapper.classes()).toContain("compact");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("updates classes when styleClassPassthrough changes", async () => {
|
|
167
|
+
wrapper = await mountSuspended(AccordionGroup, { props: { styleClassPassthrough: ["faq"] } });
|
|
168
|
+
await wrapper.setProps({ styleClassPassthrough: ["compact"] });
|
|
169
|
+
expect(wrapper.classes()).not.toContain("faq");
|
|
170
|
+
expect(wrapper.classes()).toContain("compact");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`AccordionGroup > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="accordion-group">
|
|
5
|
+
<div class="expanding-panel-classic accordion-group-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>Summary 0</span></div>
|
|
9
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></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
|
+
<p>Content 0</p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="expanding-panel-classic accordion-group-item">
|
|
19
|
+
<details class="expanding-panel-classic-details" name="faq">
|
|
20
|
+
<summary id="v-0-0-3-trigger" class="expanding-panel-classic-summary" aria-controls="v-0-0-3-content" aria-expanded="false">
|
|
21
|
+
<div class="label-wrapper"><span>Summary 1</span></div>
|
|
22
|
+
<div class="icon-wrapper"><span>+</span></div>
|
|
23
|
+
</summary>
|
|
24
|
+
</details>
|
|
25
|
+
<div id="v-0-0-3-content" class="expanding-panel-classic-content" aria-labelledby="v-0-0-3-trigger" role="region">
|
|
26
|
+
<div class="inner">
|
|
27
|
+
<p>Content 1</p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="expanding-panel-classic accordion-group-item">
|
|
32
|
+
<details class="expanding-panel-classic-details" name="faq">
|
|
33
|
+
<summary id="v-0-0-5-trigger" class="expanding-panel-classic-summary" aria-controls="v-0-0-5-content" aria-expanded="false">
|
|
34
|
+
<div class="label-wrapper"><span>Summary 2</span></div>
|
|
35
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></div>
|
|
36
|
+
</summary>
|
|
37
|
+
</details>
|
|
38
|
+
<div id="v-0-0-5-content" class="expanding-panel-classic-content" aria-labelledby="v-0-0-5-trigger" role="region">
|
|
39
|
+
<div class="inner">
|
|
40
|
+
<p>Content 2</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>"
|
|
45
|
+
`;
|
|
@@ -93,6 +93,7 @@ visual style, pass a modifier class:
|
|
|
93
93
|
|
|
94
94
|
## Notes
|
|
95
95
|
|
|
96
|
+
- Long unbroken summaries wrap (`overflow-wrap: anywhere` on the root, summary track `minmax(0, 1fr) auto`) rather than pushing the icon out of the clipped summary. Changed 2026-10-20.
|
|
96
97
|
- `--expanding-panel-content-gap` and `--expanding-panel-content-z-index` only take effect when
|
|
97
98
|
`contentIsOnTop` is `true` — they're no-ops for the default (in-flow) layout.
|
|
98
99
|
- `background-color`, `padding`, `border`, and shadow are all safe to set directly on
|