Skip to content

Commit b383f10

Browse files
committed
feat(Collapsible): enhance animation handling and add accessibility features
1 parent 83f5c22 commit b383f10

File tree

6 files changed

+174
-12
lines changed

6 files changed

+174
-12
lines changed

packages/react-ui/src/components/collapsible/collapsible-content.tsx

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type CSSProperties, useState } from "react";
1+
import { type CSSProperties, useEffect, useState } from "react";
22
import { useMergeRefs } from "../../hooks";
33
import type { EmptyObject, PrimitiveProps } from "../../primitives";
44
import { tx } from "../../utils";
@@ -7,10 +7,22 @@ import { useCollapsibleContent } from "./collapsible-context";
77
export const CollapsibleContent = (props: PrimitiveProps<"div", EmptyObject, "id">) => {
88
const { children, ref, ...rest } = props;
99

10-
const { id, mounted, status, setElement } = useCollapsibleContent();
10+
const { id, open, mounted, status, setElement } = useCollapsibleContent();
1111

1212
const [height, setHeight] = useState<number>();
1313

14+
const [skipAnimation, setSkipAnimation] = useState(open || mounted);
15+
16+
useEffect(() => {
17+
const raf = requestAnimationFrame(() => {
18+
setSkipAnimation(false);
19+
});
20+
21+
return () => {
22+
cancelAnimationFrame(raf);
23+
};
24+
}, []);
25+
1426
const elemRef = (node: HTMLDivElement) => {
1527
if (node) {
1628
setHeight(node.getBoundingClientRect().height);
@@ -31,7 +43,11 @@ export const CollapsibleContent = (props: PrimitiveProps<"div", EmptyObject, "id
3143
"--hv": height ? `${height}px` : undefined,
3244
} as CSSProperties
3345
}
34-
className={tx("duration-(--dv) overflow-hidden transition-[height]", status == "open" ? "h-(--hv)" : "h-0")}
46+
className={tx(
47+
"overflow-hidden",
48+
!skipAnimation && "duration-(--dv) overflow-hidden transition-[height]",
49+
skipAnimation || status == "open" ? "h-(--hv)" : "h-0",
50+
)}
3551
>
3652
<div id={id} ref={refs} {...rest}>
3753
{children}

packages/react-ui/src/components/collapsible/collapsible-context.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const [CollapsibleTriggerContext, useCollapsibleTrigger] = createSafeCont
1313

1414
export type CollapsibleContentContextValue = {
1515
id: string;
16+
open: boolean;
1617
mounted: boolean;
1718
status: "unmounted" | "initial" | "open" | "close";
1819
duration: number;

packages/react-ui/src/components/collapsible/collapsible-root.tsx

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type CSSProperties, useId } from "react";
22
import { useDisclosure, type UseDisclosureOptions, useElementTransitionStatus } from "../../hooks";
33
import type { PrimitiveProps } from "../../primitives";
4+
import { tx } from "../../utils";
45
import {
56
CollapsibleContentContext,
67
type CollapsibleContentContextValue,
@@ -10,7 +11,7 @@ import {
1011

1112
export type CollapsibleRootProps = UseDisclosureOptions & {
1213
/**
13-
* 是否禁用可折叠
14+
* 是否禁用
1415
* @default false
1516
*/
1617
disabled?: boolean;
@@ -23,7 +24,17 @@ export type CollapsibleRootProps = UseDisclosureOptions & {
2324
};
2425

2526
export const CollapsibleRoot = (props: PrimitiveProps<"div", CollapsibleRootProps>) => {
26-
const { open, defaultOpen = false, onOpenChange, disabled = false, duration = 250, style, children, ...rest } = props;
27+
const {
28+
open,
29+
defaultOpen = false,
30+
onOpenChange,
31+
disabled = false,
32+
duration = 250,
33+
style,
34+
children,
35+
className,
36+
...rest
37+
} = props;
2738

2839
const [openState, { handleToggle }] = useDisclosure({ open, defaultOpen, onOpenChange });
2940

@@ -39,14 +50,19 @@ export const CollapsibleRoot = (props: PrimitiveProps<"div", CollapsibleRootProp
3950
};
4051
const contentContext: CollapsibleContentContextValue = {
4152
id,
53+
open: openState,
4254
mounted: isMounted,
4355
status,
4456
duration,
4557
setElement,
4658
};
4759

4860
return (
49-
<div style={{ ...style, "--dv": `${duration}ms` } as CSSProperties} {...rest}>
61+
<div
62+
style={{ ...style, "--dv": `${duration}ms` } as CSSProperties}
63+
className={tx(disabled && "opacity-60", className)}
64+
{...rest}
65+
>
5066
<CollapsibleTriggerContext value={triggerContext}>
5167
<CollapsibleContentContext value={contentContext}>{children}</CollapsibleContentContext>
5268
</CollapsibleTriggerContext>

packages/react-ui/src/components/collapsible/collapsible-trigger.tsx

+14-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import type { MouseEvent } from "react";
12
import { useButtonProps } from "../../hooks";
23
import { type EmptyObject, Polymorphic, type PolymorphicProps } from "../../primitives";
3-
import { ariaAttr } from "../../utils";
44
import { useCollapsibleTrigger } from "./collapsible-context";
55

66
export const CollapsibleTrigger = (props: PolymorphicProps<"button", EmptyObject, "type" | "disabled">) => {
7-
const { render, tabIndex, children, ...rest } = props;
7+
const { render, tabIndex, children, onClick, ...rest } = props;
88

99
const { id, open, disabled, toggle } = useCollapsibleTrigger();
1010

@@ -14,16 +14,24 @@ export const CollapsibleTrigger = (props: PolymorphicProps<"button", EmptyObject
1414
disabled,
1515
});
1616

17+
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
18+
if (disabled) {
19+
e.preventDefault();
20+
return;
21+
}
22+
23+
onClick?.(e);
24+
toggle();
25+
};
26+
1727
return (
1828
<Polymorphic<"button">
1929
as={"button"}
2030
render={render}
2131
{...buttonProps}
22-
aria-expanded={ariaAttr(open)}
32+
aria-expanded={open}
2333
aria-controls={id}
24-
onClick={() => {
25-
toggle();
26-
}}
34+
onClick={handleClick}
2735
{...rest}
2836
>
2937
{children}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
2+
import { userEvent } from "@testing-library/user-event";
3+
import { afterEach, describe, expect, test } from "vitest";
4+
import { axe } from "vitest-axe";
5+
import { Collapsible, CollapsibleContent, type CollapsibleProps, CollapsibleTrigger } from "../collapsible";
6+
7+
const ComponentUnderTest = (props: CollapsibleProps) => (
8+
<Collapsible {...props}>
9+
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
10+
<CollapsibleContent>Content</CollapsibleContent>
11+
</Collapsible>
12+
);
13+
14+
describe("Collapsible", () => {
15+
afterEach(() => {
16+
cleanup();
17+
});
18+
19+
test("should have no a11y violations", async () => {
20+
const { container } = render(<ComponentUnderTest />);
21+
const results = await axe(container);
22+
23+
expect(results).toHaveNoViolations();
24+
});
25+
26+
test("should toggle", async () => {
27+
render(<ComponentUnderTest />);
28+
29+
expect(screen.queryByText("Content")).not.toBeInTheDocument();
30+
31+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
32+
expect(screen.getByText("Content")).toBeVisible();
33+
34+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
35+
await waitFor(() => expect(screen.queryByText("Content")).not.toBeInTheDocument());
36+
});
37+
38+
test("should be fully controlled (true)", async () => {
39+
render(<ComponentUnderTest open={true} />);
40+
41+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
42+
expect(screen.getByText("Content")).toBeVisible();
43+
});
44+
45+
test("should be fully controlled (false)", async () => {
46+
render(<ComponentUnderTest open={false} />);
47+
48+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
49+
await waitFor(() => expect(screen.queryByText("Content")).not.toBeInTheDocument());
50+
});
51+
});

website/src/routes/docs/_mdx/components/collapsible.mdx

+70
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,42 @@ export default function App() {
4848
4949
## 举例
5050
51+
### 默认打开
52+
53+
```jsx demo
54+
import { SpriteIcon } from "~/components/sprite-icon";
55+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@resolid/react-ui";
56+
57+
export default function App() {
58+
return (
59+
<div className={"flex flex-row justify-center"}>
60+
<Collapsible
61+
defaultOpen
62+
className={"border-bd-normal bg-bg-normal mx-auto w-80 rounded-md border"}
63+
>
64+
<CollapsibleTrigger
65+
className={"group flex w-full flex-row items-center justify-between p-3"}
66+
>
67+
<h6 className={"font-medium"}>Resolid React UI 是什么?</h6>
68+
69+
<SpriteIcon
70+
className={"duration-(--dv) transition-transform group-aria-expanded:rotate-180"}
71+
size={"1.5em"}
72+
name={"angle-down"}
73+
/>
74+
</CollapsibleTrigger>
75+
<CollapsibleContent className={"border-bd-normal border-t p-3"}>
76+
Resolid React UI 是 React 的开源设计系统。使用 React 和 Tailwind CSS
77+
构建。它提供了一组即用型组件,用于构建具有一致外观的 Web 应用程序。
78+
</CollapsibleContent>
79+
</Collapsible>
80+
</div>
81+
);
82+
}
83+
```
84+
85+
### 自定义
86+
5187
```jsx demo
5288
import { SpriteIcon } from "~/components/sprite-icon";
5389
import { Button, Collapsible, CollapsibleTrigger, CollapsibleContent } from "@resolid/react-ui";
@@ -87,6 +123,40 @@ export default function App() {
87123
}
88124
```
89125
126+
### 禁用
127+
128+
```jsx demo
129+
import { SpriteIcon } from "~/components/sprite-icon";
130+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@resolid/react-ui";
131+
132+
export default function App() {
133+
return (
134+
<div className={"flex flex-row justify-center"}>
135+
<Collapsible
136+
disabled
137+
className={"border-bd-normal bg-bg-normal mx-auto w-80 rounded-md border"}
138+
>
139+
<CollapsibleTrigger
140+
className={"group flex w-full flex-row items-center justify-between p-3"}
141+
>
142+
<h6 className={"font-medium"}>Resolid React UI 是什么?</h6>
143+
144+
<SpriteIcon
145+
className={"duration-(--dv) transition-transform group-aria-expanded:rotate-180"}
146+
size={"1.5em"}
147+
name={"angle-down"}
148+
/>
149+
</CollapsibleTrigger>
150+
<CollapsibleContent className={"border-bd-normal border-t p-3"}>
151+
Resolid React UI 是 React 的开源设计系统。使用 React 和 Tailwind CSS
152+
构建。它提供了一组即用型组件,用于构建具有一致外观的 Web 应用程序。
153+
</CollapsibleContent>
154+
</Collapsible>
155+
</div>
156+
);
157+
}
158+
```
159+
90160
## 属性
91161
92162
::PropsTable{file=collapsible/collapsible.tsx}

0 commit comments

Comments
 (0)