Skip to content

Commit 703eccf

Browse files
feat: implement collapsible component (#1141)
* feat: implement base collapsible component * feat: add interactive tests * test: reduce interaction test complexity * style: code format * style: code format * update snapshots * chore: add changeset * feat: add transition aniamtion keyframes * fix: add `keepMounted` prop to prevent animation removal * test: add unit tests * tests: refine unit tests * style: code format * test: fix failing interactive test * chore: add changeset * refactor: change story name
1 parent 9df6a45 commit 703eccf

12 files changed

Lines changed: 514 additions & 0 deletions

.changeset/fair-eels-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopware-ag/meteor-component-library": minor
3+
---
4+
5+
Add `mt-collapsible` component to component-library

.changeset/late-months-jam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopware-ag/meteor-component-library": minor
3+
---
4+
5+
Add `mt-collapsible` component to component-library
4.19 KB
Loading
7.41 KB
Loading
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<template>
2+
<CollapsibleContent
3+
class="mt-collapsible-content"
4+
:as="as"
5+
:as-child="asChild"
6+
:force-mount="forceMount"
7+
@content-found="() => emits('contentFound')"
8+
>
9+
<slot />
10+
</CollapsibleContent>
11+
</template>
12+
13+
<script setup lang="ts">
14+
import { CollapsibleContent } from "reka-ui";
15+
16+
withDefaults(
17+
defineProps<{
18+
as?: string | object;
19+
asChild?: boolean;
20+
forceMount?: boolean;
21+
}>(),
22+
{
23+
as: "div",
24+
asChild: false,
25+
forceMount: false,
26+
},
27+
);
28+
29+
const emits = defineEmits<{
30+
contentFound: [];
31+
}>();
32+
</script>
33+
34+
<style>
35+
.mt-collapsible-content {
36+
overflow: hidden;
37+
}
38+
39+
.mt-collapsible-content[data-state="open"] {
40+
animation: mt-collapsible-slide-down 300ms ease-out;
41+
}
42+
43+
.mt-collapsible-content[data-state="closed"] {
44+
animation: mt-collapsible-slide-up 300ms ease-out;
45+
}
46+
47+
@keyframes mt-collapsible-slide-down {
48+
from {
49+
height: 0;
50+
}
51+
to {
52+
height: var(--reka-collapsible-content-height);
53+
}
54+
}
55+
56+
@keyframes mt-collapsible-slide-up {
57+
from {
58+
height: var(--reka-collapsible-content-height);
59+
}
60+
to {
61+
height: 0;
62+
}
63+
}
64+
65+
@media (prefers-reduced-motion: reduce) {
66+
.mt-collapsible-content[data-state="open"],
67+
.mt-collapsible-content[data-state="closed"] {
68+
animation: none;
69+
}
70+
}
71+
</style>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<template>
2+
<CollapsibleTrigger class="mt-collapsible-trigger" :as="as" :as-child="asChild">
3+
<slot />
4+
</CollapsibleTrigger>
5+
</template>
6+
7+
<script setup lang="ts">
8+
import { CollapsibleTrigger } from "reka-ui";
9+
10+
withDefaults(
11+
defineProps<{
12+
as?: string | object;
13+
asChild?: boolean;
14+
}>(),
15+
{
16+
as: "button",
17+
asChild: false,
18+
},
19+
);
20+
</script>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { within, userEvent, expect, waitFor } from "@storybook/test";
2+
3+
import MtButton from "../../form/mt-button/mt-button.vue";
4+
import MtCollapsible from "./mt-collapsible.vue";
5+
import MtCollapsibleTrigger from "./mt-collapsible-trigger.vue";
6+
import MtCollapsibleContent from "./mt-collapsible-content.vue";
7+
8+
import meta, { type MtCollapsibleMeta, type MtCollapsibleStory } from "./mt-collapsible.stories";
9+
10+
export default {
11+
...meta,
12+
title: "Components/mt-collapsible/Interaction tests",
13+
tags: ["!autodocs"],
14+
} as MtCollapsibleMeta;
15+
16+
const sharedComponents = {
17+
MtCollapsible,
18+
MtCollapsibleTrigger,
19+
MtCollapsibleContent,
20+
MtButton,
21+
};
22+
23+
export const VisualTestStartsClosed: MtCollapsibleStory = {
24+
name: "Should start closed and open on trigger click",
25+
render: () => ({
26+
components: sharedComponents,
27+
template: `
28+
<mt-collapsible>
29+
<mt-collapsible-trigger as-child>
30+
<mt-button variant="primary">Toggle content</mt-button>
31+
</mt-collapsible-trigger>
32+
33+
<mt-collapsible-content>
34+
<p style="margin-top: 8px; font-size: var(--font-size-xs);">This content is revealed and hidden by the trigger above.</p>
35+
</mt-collapsible-content>
36+
</mt-collapsible>
37+
`,
38+
}),
39+
play: async ({ canvasElement }) => {
40+
const canvas = within(canvasElement);
41+
const toggleButton = canvas.getByRole("button", { name: "Toggle content" });
42+
const content = canvas.getByText("This content is revealed and hidden by the trigger above.");
43+
44+
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
45+
expect(content).not.toBeVisible();
46+
47+
await userEvent.click(toggleButton);
48+
49+
await waitFor(() => {
50+
expect(toggleButton).toHaveAttribute("aria-expanded", "true");
51+
expect(content).toBeVisible();
52+
});
53+
},
54+
};
55+
56+
export const VisualTestDisabled: MtCollapsibleStory = {
57+
name: "Should not toggle when disabled",
58+
render: () => ({
59+
components: sharedComponents,
60+
template: `
61+
<mt-collapsible disabled>
62+
<mt-collapsible-trigger as-child>
63+
<mt-button variant="primary" disabled>Toggle content</mt-button>
64+
</mt-collapsible-trigger>
65+
66+
<mt-collapsible-content>
67+
<p style="margin-top: 8px; font-size: var(--font-size-xs);">This content is revealed and hidden by the trigger above.</p>
68+
</mt-collapsible-content>
69+
</mt-collapsible>
70+
`,
71+
}),
72+
play: async ({ canvasElement }) => {
73+
const canvas = within(canvasElement);
74+
const toggleButton = canvas.getByRole("button", { name: "Toggle content" });
75+
const content = canvas.getByText("This content is revealed and hidden by the trigger above.");
76+
77+
await userEvent.click(toggleButton);
78+
79+
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
80+
expect(content).not.toBeVisible();
81+
},
82+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ArgTypes, Canvas, Meta } from "@storybook/blocks";
2+
3+
import * as CollapsibleStories from "./mt-collapsible.stories";
4+
import ComponentPageHeader from "../../_docs/component-page-header.js";
5+
6+
<Meta of={CollapsibleStories} />
7+
8+
<ComponentPageHeader
9+
title="Collapsible"
10+
tagName="mt-collapsible"
11+
packageImports={[
12+
"MtCollapsible",
13+
"MtCollapsibleTrigger",
14+
"MtCollapsibleContent",
15+
]}
16+
sourcePath="packages/component-library/src/components/layout/mt-collapsible"
17+
>
18+
**Collapsible** is an interactive component that expands and collapses related
19+
content behind a trigger. Use it to keep secondary information available
20+
without crowding the surrounding layout.
21+
</ComponentPageHeader>
22+
23+
## Examples
24+
25+
### Basic
26+
27+
<Canvas of={CollapsibleStories.Default} />
28+
29+
### Disabled
30+
31+
<Canvas of={CollapsibleStories.Disabled} />
32+
33+
## Anatomy
34+
35+
**Collapsible** is built from three companion exports that work together:
36+
37+
- `mt-collapsible` is the root container that owns the open and closed state.
38+
- `mt-collapsible-trigger` is the interactive element that toggles the open state. It renders as a `button` by default.
39+
- `mt-collapsible-content` is the region that is shown or hidden when the state changes.
40+
41+
## API reference
42+
43+
<ArgTypes of={CollapsibleStories} />
44+
45+
## Behavior notes
46+
47+
- **Collapsible** is a compound pattern. You always compose `mt-collapsible` with `mt-collapsible-trigger` and `mt-collapsible-content`.
48+
- The open state can be controlled externally with `v-model:open` or left uncontrolled with `default-open`.
49+
- When `disabled`, the trigger no longer toggles the content and the data attributes reflect the disabled state for styling.
50+
- The content slides open and closed over 300 ms by default, using the `--reka-collapsible-content-height` CSS variable that the component exposes on its content element. The animation respects `prefers-reduced-motion`.
51+
- `keep-mounted` defaults to `true` so the closed content stays in the DOM (it's hidden with `hidden="until-found"`, which keeps it discoverable to the browser's find-in-page feature) and the slide animation can play. Set `keep-mounted="false"` when the closed subtree is too expensive to leave mounted; the open/close animation will not play in that case.
52+
53+
## Accessibility notes
54+
55+
- The trigger should always have a clear accessible name so users understand what content the toggle reveals.
56+
- The trigger and content are linked through `aria-controls` and `aria-expanded`, so assistive technology announces the state change automatically.
57+
- When using a custom element via `as-child`, make sure the rendered element is still focusable and supports keyboard activation.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { userEvent } from "@testing-library/user-event";
2+
import { render, screen } from "@testing-library/vue";
3+
import { waitFor } from "@testing-library/vue";
4+
import MtCollapsible from "./mt-collapsible.vue";
5+
import MtCollapsibleContent from "./mt-collapsible-content.vue";
6+
import MtCollapsibleTrigger from "./mt-collapsible-trigger.vue";
7+
8+
function renderCollapsible(rootProps: Record<string, unknown> = {}) {
9+
return render(MtCollapsible, {
10+
props: rootProps,
11+
slots: {
12+
default: `
13+
<mt-collapsible-trigger>Toggle</mt-collapsible-trigger>
14+
<mt-collapsible-content>Collapsible content</mt-collapsible-content>
15+
`,
16+
},
17+
global: {
18+
components: {
19+
MtCollapsibleTrigger,
20+
MtCollapsibleContent,
21+
},
22+
},
23+
});
24+
}
25+
26+
describe("mt-collapsible", () => {
27+
it("is open by default when `defaultOpen` is true", async () => {
28+
// ARRANGE
29+
renderCollapsible({ defaultOpen: true });
30+
31+
// ASSERT
32+
await waitFor(() => {
33+
expect(screen.getByRole("button", { name: "Toggle" })).toHaveAttribute(
34+
"aria-expanded",
35+
"true",
36+
);
37+
});
38+
expect(screen.getByText("Collapsible content")).toBeVisible();
39+
});
40+
41+
it("does not open when disabled", async () => {
42+
// ARRANGE
43+
renderCollapsible({ disabled: true });
44+
45+
// ACT
46+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
47+
48+
// ASSERT
49+
expect(screen.getByRole("button", { name: "Toggle" })).toBeDisabled();
50+
expect(screen.getByRole("button", { name: "Toggle" })).toHaveAttribute(
51+
"aria-expanded",
52+
"false",
53+
);
54+
expect(screen.getByText("Collapsible content")).not.toBeVisible();
55+
});
56+
57+
it("can be controlled externally with `v-model:open`", async () => {
58+
// ARRANGE
59+
render({
60+
components: {
61+
MtCollapsible,
62+
MtCollapsibleTrigger,
63+
MtCollapsibleContent,
64+
},
65+
data() {
66+
return {
67+
open: false,
68+
};
69+
},
70+
template: `
71+
<mt-collapsible :open="open" @update:open="(value) => (open = value)">
72+
<mt-collapsible-trigger>Toggle</mt-collapsible-trigger>
73+
<mt-collapsible-content>Collapsible content</mt-collapsible-content>
74+
</mt-collapsible>
75+
`,
76+
});
77+
78+
// ACT
79+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
80+
81+
// ASSERT
82+
expect(screen.getByText("Collapsible content")).toBeVisible();
83+
84+
// ACT
85+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
86+
87+
// ASSERT
88+
expect(screen.getByText("Collapsible content")).not.toBeVisible();
89+
});
90+
91+
it("keeps content mounted when `keepMounted` is true", () => {
92+
// ARRANGE
93+
const { container } = renderCollapsible();
94+
95+
// ASSERT
96+
const content = container.querySelector(".mt-collapsible-content");
97+
expect(content).toBeInTheDocument();
98+
});
99+
100+
it("unmounts content when `keepMounted` is false", () => {
101+
// ARRANGE
102+
const { container } = renderCollapsible({ keepMounted: false });
103+
const content = container.querySelector(".mt-collapsible-content");
104+
105+
// ASSERT
106+
expect(content).toHaveAttribute("hidden", "");
107+
expect(screen.queryByText("Collapsible content")).not.toBeInTheDocument();
108+
});
109+
});

0 commit comments

Comments
 (0)