srcdev-nuxt-components 9.6.18 → 9.6.20
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/ids-from-useid.md +3 -2
- package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +11 -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/auto-grid.md +3 -1
- package/.claude/skills/components/capture-qr-code.md +9 -5
- package/.claude/skills/components/carousel-flip.md +41 -30
- package/.claude/skills/components/column-flow-grid.md +2 -0
- package/.claude/skills/components/dashboard-quad-grid.md +7 -2
- package/.claude/skills/components/dashboard-stats-grid.md +4 -0
- package/.claude/skills/components/decode-qr-code.md +7 -2
- package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
- package/.claude/skills/components/deep-expanding-menu.md +22 -21
- package/.claude/skills/components/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/grid-stack.md +2 -0
- package/.claude/skills/components/layout-grid-by-cols.md +2 -0
- package/.claude/skills/components/masonry-grid.md +9 -1
- package/.claude/skills/components/page-row.md +3 -0
- package/.claude/skills/components/samaritan-prompt.md +24 -8
- package/.claude/skills/components/site-header.md +16 -2
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +16 -14
- package/.claude/skills/storybook-add-story.md +33 -0
- package/.claude/skills/testing-add-unit-test.md +26 -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-capture-decode-qr-code.code-snippets +11 -2
- package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
- package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
- package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
- package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
- package/.vscode/srcdev-component-site-header.code-snippets +13 -1
- 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/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
- package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
- package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
- package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
- package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
- package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
- package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
- package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
- package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
- package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
- package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
- package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
- package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
- package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
- package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
- package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
- package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
- package/app/components/01.atoms/page-row/PageRow.vue +16 -15
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
- package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
- 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/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
- package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
- package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
- package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
- package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
- package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
- package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
- package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
- package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
- package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
- package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
- package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
- package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
- package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
- 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/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
- package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
- package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
- package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
- package/app/types/components/qr-code.d.ts +12 -0
- package/app/utils/graphemes.ts +8 -0
- 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/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
- package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
- package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
- package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
- package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
- package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
- package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- 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/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
|
@@ -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
|
+
`;
|
|
@@ -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
|
+
};
|