Skip to content

Commit 7093550

Browse files
new collapsible
1 parent 411f200 commit 7093550

File tree

14 files changed

+282
-66
lines changed

14 files changed

+282
-66
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Locator, expect, Page } from '@playwright/test';
2+
type OpenKeys = 'Space' | 'Enter';
3+
export type DriverLocator = Locator | Page;
4+
5+
export function createTestDriver<T extends DriverLocator>(locator: T) {
6+
const getRoot = () => {
7+
return locator.locator('[data-collapsible]');
8+
};
9+
10+
const getTrigger = () => {
11+
return getRoot().getByRole('button');
12+
};
13+
14+
const getContent = () => {
15+
return getRoot().locator('[data-collapsible-content]');
16+
};
17+
18+
const openCollapsible = async (key: OpenKeys | 'click') => {
19+
await getTrigger().focus();
20+
21+
if (key !== 'click') {
22+
await getTrigger().press(key);
23+
} else {
24+
await getTrigger().click();
25+
}
26+
27+
// should be open initially
28+
await expect(getContent()).toBeVisible();
29+
};
30+
31+
return {
32+
...locator,
33+
locator,
34+
getRoot,
35+
getTrigger,
36+
getContent,
37+
openCollapsible,
38+
};
39+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect, test, type Page } from '@playwright/test';
2+
import { createTestDriver } from './collapsible.driver';
3+
async function setup(page: Page, selector: string) {
4+
await page.goto('/docs/headless/collapsible');
5+
6+
const driver = createTestDriver(page.getByTestId(selector));
7+
8+
const { getRoot, getTrigger, getContent, openCollapsible } = driver;
9+
10+
return {
11+
driver,
12+
getRoot,
13+
getTrigger,
14+
getContent,
15+
openCollapsible,
16+
};
17+
}
18+
19+
test.describe('Mouse Behavior', () => {
20+
test(`GIVEN a hero collapsible
21+
WHEN clicking on the trigger
22+
THEN the content should be visible
23+
AND aria-expanded is true`, async ({ page }) => {
24+
const { getTrigger, getContent } = await setup(page, 'collapsible-hero-test');
25+
26+
await getTrigger().click();
27+
28+
await expect(getContent()).toBeVisible();
29+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'true');
30+
});
31+
32+
test(`GIVEN an open hero collapsible
33+
WHEN clicking on the trigger
34+
THEN the content should be hidden
35+
AND aria-expanded is false`, async ({ page }) => {
36+
const { getTrigger, getContent, openCollapsible } = await setup(
37+
page,
38+
'collapsible-hero-test',
39+
);
40+
await openCollapsible('click');
41+
42+
await getTrigger().click();
43+
44+
await expect(getContent()).toBeHidden();
45+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'false');
46+
});
47+
});
48+
49+
test.describe('Keyboard Behavior', () => {
50+
test(`GIVEN a hero collapsible
51+
WHEN pressing the space key
52+
THEN the content should be visible
53+
AND aria-expanded is true`, async ({ page }) => {
54+
const { getTrigger, getContent } = await setup(page, 'collapsible-hero-test');
55+
56+
await getTrigger().press('Space');
57+
58+
await expect(getContent()).toBeVisible();
59+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'true');
60+
});
61+
62+
test(`GIVEN an open hero collapsible
63+
WHEN pressing the space key
64+
THEN the content should be hidden
65+
AND aria-expanded is false`, async ({ page }) => {
66+
const { getTrigger, getContent, openCollapsible } = await setup(
67+
page,
68+
'collapsible-hero-test',
69+
);
70+
await openCollapsible('Space');
71+
72+
await getTrigger().press('Space');
73+
74+
await expect(getContent()).toBeHidden();
75+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'false');
76+
});
77+
78+
test(`GIVEN a hero collapsible
79+
WHEN pressing the enter key
80+
THEN the content should be visible
81+
AND aria-expanded is true`, async ({ page }) => {
82+
const { getTrigger, getContent } = await setup(page, 'collapsible-hero-test');
83+
84+
await getTrigger().press('Enter');
85+
86+
await expect(getContent()).toBeVisible();
87+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'true');
88+
});
89+
90+
test(`GIVEN an open hero collapsible
91+
WHEN pressing the enter key
92+
THEN the content should be hidden
93+
AND aria-expanded is false`, async ({ page }) => {
94+
const { getTrigger, getContent, openCollapsible } = await setup(
95+
page,
96+
'collapsible-hero-test',
97+
);
98+
await openCollapsible('Enter');
99+
100+
await getTrigger().press('Enter');
101+
102+
await expect(getContent()).toBeHidden();
103+
await expect(getTrigger()).toHaveAttribute('aria-expanded', 'false');
104+
});
105+
});
106+
107+
test.describe('Aria', () => {
108+
test(`GIVEN a collapsible with aria-controls
109+
WHEN a collapsible is rendered
110+
THEN the trigger's aria-controls should equal the content's id`, async ({
111+
page,
112+
}) => {
113+
const { getTrigger, getContent, openCollapsible } = await setup(
114+
page,
115+
'collapsible-hero-test',
116+
);
117+
await openCollapsible('Enter');
118+
119+
const contentId = await getContent().getAttribute('id');
120+
121+
await expect(getTrigger()).toHaveAttribute('aria-controls', `${contentId}`);
122+
});
123+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { component$ } from '@builder.io/qwik';
2+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3+
import './collapsible.css';
4+
import SVG from './svg';
5+
6+
export default component$(() => {
7+
return (
8+
<Collapsible class="collapsible">
9+
<CollapsibleTrigger class="collapsible-trigger">
10+
<span>Trigger</span>
11+
<SVG class="collapsible-transition" />
12+
</CollapsibleTrigger>
13+
<CollapsibleContent class="collapsible-animation collapsible-content">
14+
Content
15+
</CollapsibleContent>
16+
</Collapsible>
17+
);
18+
});

apps/website/src/routes/docs/headless/collapsible/examples/collapsible.css

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
.collapsible {
2+
width: 10rem;
3+
}
4+
5+
.collapsible-trigger {
6+
width: 100%;
7+
border: 2px dotted hsla(var(--primary) / 1);
8+
border-radius: calc(var(--border-radius) / 2);
9+
padding: 0.5rem;
10+
display: flex;
11+
justify-content: space-between;
12+
align-items: center;
13+
}
14+
15+
.collapsible-trigger[data-state='open'] svg {
16+
transform: rotate(180deg);
17+
}
18+
19+
.collapsible-content {
20+
width: 100%;
21+
background-color: hsl(var(--background));
22+
padding: 0.5rem;
23+
border: 2px dotted hsla(var(--foreground) / 0.6);
24+
border-radius: calc(var(--border-radius) / 2);
25+
max-width: var(--select-width);
26+
color: hsl(var(--foreground));
27+
overflow: hidden;
28+
}
29+
30+
/* animations only */
31+
.collapsible-transition {
32+
transition: transform 500ms ease;
33+
}
34+
135
@keyframes collapsible-open {
236
0% {
337
height: 0;
@@ -16,14 +50,10 @@
1650
}
1751
}
1852

19-
.animation[data-state='open'] {
53+
.collapsible-animation[data-state='open'] {
2054
animation: 550ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-open;
2155
}
2256

23-
.animation[data-state='closed'] {
57+
.collapsible-animation[data-state='closed'] {
2458
animation: 350ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-closed;
2559
}
26-
27-
.animation {
28-
overflow: hidden;
29-
}

apps/website/src/routes/docs/headless/collapsible/examples/hero.tsx

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
1-
import { component$ } from '@builder.io/qwik';
1+
import { component$, useStyles$ } from '@builder.io/qwik';
22
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3-
import { DoubleChevron } from '~/components/icons/double-chevron';
4-
import './collapsible.css';
3+
import styles from './collapsible.css?inline';
4+
import SVG from './svg';
55

66
export default component$(() => {
7+
useStyles$(styles);
8+
79
return (
8-
<Collapsible class="flex flex-col">
9-
<div class="mb-2 flex items-center justify-between px-2">
10-
<span class="w-fit">
11-
<strong>@thejackshelton</strong> starred 3 repositories
12-
</span>
13-
<CollapsibleTrigger class="rounded-base mx-2 mr-0 bg-slate-700 p-1 text-white shadow-lg dark:bg-slate-800">
14-
<DoubleChevron class="size-4" />
15-
</CollapsibleTrigger>
16-
</div>
17-
<div class="rounded-base mx-2 mb-2 bg-slate-700 p-2 text-white shadow-md dark:bg-slate-800">
18-
@qwik-ui/headless
19-
</div>
20-
<CollapsibleContent class="animation">
21-
<div class="rounded-base mx-2 mb-2 bg-slate-700 p-2 text-white shadow-md dark:bg-slate-800">
22-
@builder.io/qwik
23-
</div>
24-
<div class="rounded-base mx-2 bg-slate-700 p-2 text-white shadow-md dark:bg-slate-800">
25-
@qwikdev/astro
26-
</div>
27-
<div class="p-2"></div>
10+
<Collapsible class="collapsible">
11+
<CollapsibleTrigger class="collapsible-trigger">
12+
<span>Trigger</span>
13+
<SVG />
14+
</CollapsibleTrigger>
15+
<CollapsibleContent class="test-animation collapsible-content">
16+
Content
2817
</CollapsibleContent>
2918
</Collapsible>
3019
);

apps/website/src/routes/docs/headless/collapsible/examples/programmatic.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export default component$(() => {
1010
<p>state: {openSig.value ? 'true' : 'false'}</p>
1111
<Collapsible bind:isOpen={openSig}>
1212
<CollapsibleTrigger>I am trigger 1!</CollapsibleTrigger>
13-
<CollapsibleContent class="animation">I am the content 1!</CollapsibleContent>
13+
<CollapsibleContent class="collapsible-content">
14+
I am the content 1!
15+
</CollapsibleContent>
1416
</Collapsible>
1517
<button
1618
class="rounded-base bg-slate-500 px-2 py-3 text-white"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { PropsOf } from '@builder.io/qwik';
2+
3+
export default function SVG(props: PropsOf<'svg'>) {
4+
return (
5+
<svg
6+
xmlns="http://www.w3.org/2000/svg"
7+
width="1em"
8+
height="1em"
9+
viewBox="0 0 1024 1024"
10+
{...props}
11+
>
12+
<path
13+
fill="currentColor"
14+
d="M831.872 340.864L512 652.672L192.128 340.864a30.592 30.592 0 0 0-42.752 0a29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728a30.592 30.592 0 0 0-42.752 0z"
15+
></path>
16+
</svg>
17+
);
18+
}

apps/website/src/routes/docs/headless/collapsible/examples/uncontrolled.tsx

Whitespace-only changes.

apps/website/src/routes/docs/headless/collapsible/index.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { Note } from '~/components/note/note';
88

99
An interactive component which expands/collapses a panel.
1010

11-
<Showcase name="hero" />
11+
<div data-testid="collapsible-hero-test">
12+
<Showcase name="hero" />
13+
</div>
14+
15+
## Animation
16+
17+
<Showcase name="animation" />
1218

1319
Follows the WAI-ARIA disclosure pattern.
1420

packages/kit-headless/src/components/collapsible/collapsible-content.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const CollapsibleContent = component$((props: CollapsibleContentProps) =>
1515
const context = useContext(collapsibleContextId);
1616
const isHiddenSig = useSignal<boolean>(false);
1717
const isAnimatedSig = useSignal<boolean>(false);
18+
const contentId = `${context.itemId}-content`;
1819

1920
const hideContent$ = $(() => {
2021
if (!context.isOpenSig.value) {
@@ -53,6 +54,8 @@ export const CollapsibleContent = component$((props: CollapsibleContentProps) =>
5354
<div
5455
{...props}
5556
ref={context.contentRef}
57+
id={contentId}
58+
data-collapsible-content
5659
data-state={
5760
context.initialStateSig.value
5861
? 'initial'
@@ -64,7 +67,9 @@ export const CollapsibleContent = component$((props: CollapsibleContentProps) =>
6467
onTransitionEnd$={[hideContent$, props.onTransitionEnd$]}
6568
hidden={isAnimatedSig.value ? isHiddenSig.value : !context.isOpenSig.value}
6669
>
67-
<Slot />
70+
<div ref={context.contentChildRef}>
71+
<Slot />
72+
</div>
6873
</div>
6974
);
7075
});

0 commit comments

Comments
 (0)