Skip to content

refactor: 重构collapse为details实现 #362

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ coverage
es
yarn.lock
package-lock.json
pnpm-lock.yaml
.storybook
.doc

Expand Down
14 changes: 14 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@

& > &-item {
border-top: @borderStyle;
list-style-position: outside;
interpolate-size: allow-keywords;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://developer.mozilla.org/en-US/docs/Web/CSS/interpolate-size#browser_compatibility

浏览器兼容性有点差,这个会没办法在 antd 中使用。

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://developer.mozilla.org/en-US/docs/Web/CSS/interpolate-size#browser_compatibility

浏览器兼容性有点差,这个会没办法在 antd 中使用。

有什么好建议吗

overflow: hidden;

&::details-content {
block-size: 0;
transition: block-size 0.6s, content-visibility 0.6s;
transition-behavior: allow-discrete;
}

&[open]::details-content {
Comment on lines +47 to +53
Copy link
Preview

Copilot AI Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS pseudo-element ::details-content isn't part of the standard and won't target actual <details> content. Consider wrapping the panel body in a dedicated element (e.g., .rc-collapse-content) and applying transitions to that selector instead.

Suggested change
&::details-content {
block-size: 0;
transition: block-size 0.6s, content-visibility 0.6s;
transition-behavior: allow-discrete;
}
&[open]::details-content {
> .rc-collapse-content {
block-size: 0;
transition: block-size 0.6s, content-visibility 0.6s;
transition-behavior: allow-discrete;
}
&[open] > .rc-collapse-content {

Copilot uses AI. Check for mistakes.

block-size: auto;
}
Comment on lines +43 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

使用现代CSS特性实现动画过渡

代码引入了一些新的CSS特性来实现基于<details>元素的动画效果:

  • interpolate-size: allow-keywords; 允许在尺寸关键词(如auto)和具体值之间进行插值动画
  • ::details-content 伪元素选择器用于直接控制details内容区域的样式和动画
  • 使用transition-behavior: allow-discrete;支持离散过渡效果

这些CSS特性是比较新的,需要注意浏览器兼容性问题。与Panel.tsx中的特性检测(CSS.supports('selector(details::details-content)'))相配合,可以实现渐进增强的用户体验。

建议添加一些注释说明这些属性的浏览器兼容性要求,或考虑为不支持这些特性的浏览器提供兼容性样式。

🤖 Prompt for AI Agents (early access)
In assets/index.less around lines 38 to 50, the CSS uses modern features like
interpolate-size, ::details-content, and transition-behavior which have limited
browser support. Add comments explaining the browser compatibility requirements
for these properties and consider adding fallback styles or alternative
approaches for browsers that do not support these features to ensure graceful
degradation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

兼容性如何?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

兼容性如何?

这只是demo实现,ant实现上考虑下css兼容吧

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有方案么,最好提前考虑起来。

Copy link
Author

@IVLIU IVLIU Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有方案么,最好提前考虑起来。

  1. 兼容::detail-content的情况使用Panel的className编写动画,openMotion.motionName传入一个空实现
  2. 不兼容::detail-content的情况则使用openMotion.motionName编写动画

你看下是不是可行,::detail-content兼容性确实比较差

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我尝试引入startTransition,编译报错,需要升级哪里吗?


&:first-child {
border-top: none;
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"devDependencies": {
"@rc-component/father-plugin": "^2.0.1",
"@rc-component/np": "^1.0.3",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
Expand Down
24 changes: 14 additions & 10 deletions src/Collapse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,22 @@ const Collapse = React.forwardRef<HTMLDivElement, CollapseProps>((props, ref) =>
});

const onItemClick = (key: React.Key) =>
setActiveKey(() => {
if (accordion) {
return activeKey[0] === key ? [] : [key];
}
// ? 用于解决react状态与details[open]状态不一致的问题
// ? 具体参考issue https://github.com/facebook/react/issues/15486
React.startTransition(() => {
setActiveKey(() => {
if (accordion) {
return activeKey[0] === key ? [] : [key];
}

const index = activeKey.indexOf(key);
const isActive = index > -1;
if (isActive) {
return activeKey.filter((item) => item !== key);
}
const index = activeKey.indexOf(key);
const isActive = index > -1;
if (isActive) {
return activeKey.filter((item) => item !== key);
}

return [...activeKey, key];
return [...activeKey, key];
});
});

// ======================== Children ========================
Expand Down
116 changes: 83 additions & 33 deletions src/Panel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import CSSMotion from 'rc-motion';
import KeyCode from '@rc-component/util/lib/KeyCode';
import React from 'react';
import React, { useMemo } from 'react';
import type { CollapsePanelProps } from './interface';
import PanelContent from './PanelContent';

const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((props, ref) => {
const CollapsePanel = React.forwardRef<HTMLDetailsElement, CollapsePanelProps>((props, ref) => {
const {
showArrow = true,
headerClass,
Expand All @@ -32,9 +32,19 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop

const ifExtraExist = extra !== null && extra !== undefined && typeof extra !== 'boolean';

// ? 用于判断浏览器是否支持::details-content 否则使用CSSMotion
const supportsDetailsContentSelector = useMemo(
() =>
typeof document !== 'undefined' && typeof document.createElement === 'function'
? CSS.supports('selector(details::details-content)')
: false,
[],
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

浏览器特性检测实现

使用 CSS.supports 检测浏览器对 ::details-content 伪元素的支持是一个优雅的渐进增强方案。代码还考虑了 SSR 场景,通过检查 document 是否存在来防止服务端执行错误。

不过,可能需要考虑在组件初始挂载后处理 SSR 水合差异(hydration mismatch)的问题。

需要验证此实现在 SSR 环境中是否会导致客户端与服务端渲染结果不一致。在服务端 supportsDetailsContentSelector 将始终为 false,而在客户端可能为 true,这可能导致水合错误。


🏁 Script executed:

#!/bin/bash
# 检查项目是否使用了支持 SSR 的框架和相关配置
grep -r "SSR\|server-side rendering\|hydration" --include="*.md" --include="*.js" --include="*.ts" --include="*.tsx" .
# 检查是否有处理 hydration mismatch 的代码
grep -r "suppressHydrationWarning\|useEffect.*hydration\|supportsDetailsContentSelector" --include="*.tsx" --include="*.ts" .

Length of output: 106462


注意 SSR 与水合不一致

目前 supportsDetailsContentSelector 在服务端渲染时恒为 false,而客户端可能为 true,会导致首屏渲染结果差异,引发 React 的水合(hydration)警告。

建议:

  • 将特性检测逻辑从初始渲染中移入 useEffect(或 useLayoutEffect),并通过 useState 保存检测结果;
  • 保持初始渲染(SSR 与客户端首屏)一致——初始值都设为 false,待客户端挂载后再更新为实际支持情况;
  • 如确实无法同步,可在对应元素上添加 suppressHydrationWarning,静默水合差异。

请根据项目的 SSR 配置和业务需求,调整组件实现,确保服务端与客户端渲染的一致性。

🤖 Prompt for AI Agents
In src/Panel.tsx around lines 35 to 42, the supportsDetailsContentSelector value
is determined during render, causing a mismatch between server-side rendering
(always false) and client-side detection (possibly true), which leads to React
hydration warnings. To fix this, initialize supportsDetailsContentSelector state
to false, then move the CSS.supports detection into a useEffect hook that
updates this state after the component mounts on the client. This ensures
consistent initial rendering on both server and client, preventing hydration
issues. Optionally, add suppressHydrationWarning to the affected elements if
minor mismatches remain unavoidable.


const collapsibleProps = {
onClick: () => {
onClick: (e: React.MouseEvent) => {
onItemClick?.(panelKey);
e.stopPropagation();
},
Copy link

@coderabbitai coderabbitai bot Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

阻止 <summary> 默认行为,避免双重状态切换

点击位于 <summary> 内部的元素时,浏览器会先执行 <details> 的默认展开 / 折叠,再执行 React 更新逻辑,导致「闪动」及状态竞争;尤其在受控模式下容易出现不一致。
应同时 preventDefaultstopPropagation,并在之后触发 onItemClick

-    onClick: (e: React.MouseEvent) => {
-      onItemClick?.(panelKey);
-      e.stopPropagation();
-    },
+    onClick: (e: React.MouseEvent) => {
+      e.preventDefault();     // 阻止原生 toggle
+      e.stopPropagation();    // 阻止冒泡
+      onItemClick?.(panelKey);
+    },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onClick: (e: React.MouseEvent) => {
onItemClick?.(panelKey);
e.stopPropagation();
},
onClick: (e: React.MouseEvent) => {
e.preventDefault(); // 阻止原生 toggle
e.stopPropagation(); // 阻止冒泡
onItemClick?.(panelKey);
},
🤖 Prompt for AI Agents
In src/Panel.tsx around lines 35 to 38, the onClick handler inside the <summary>
element only calls e.stopPropagation(), which does not prevent the default
toggle behavior of <details>. To fix the flickering and state conflicts, add
e.preventDefault() before e.stopPropagation(), and then call onItemClick. This
will stop the default expand/collapse action and avoid double state changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看看这个?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@afc163 CodeRabbit 提出的建议是正确的!这个问题很重要,因为在使用原生 <details> 元素的重构版本中:

问题分析:

  • 当前代码只调用了 e.stopPropagation() 但缺少 e.preventDefault()
  • <summary> 的默认行为是切换父级 <details>open 属性
  • 在受控组件中,React 通过 props 管理 open 状态
  • 没有 preventDefault() 会导致原生切换和 React 状态更新同时发生,造成状态竞争和闪烁

修复建议:

onClick: (e: React.MouseEvent) => {
  e.preventDefault();     // 阻止原生 toggle 行为
  e.stopPropagation();    // 阻止事件冒泡
  onItemClick?.(panelKey); // React 控制状态更新
},

这个修复确保了:

  1. 阻止浏览器的默认展开/折叠行为
  2. 完全由 React 控制状态变化
  3. 避免双重切换导致的闪烁和不一致

这个修改对于从 div 实现迁移到原生 <details> 实现的稳定性至关重要。

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看看这个?

已处理

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.keyCode === KeyCode.ENTER || e.which === KeyCode.ENTER) {
Expand Down Expand Up @@ -67,6 +77,8 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop
[`${prefixCls}-item-disabled`]: disabled,
},
className,
// ? 修改为details实现后动画是作用在details元素上 需要将motionName设置在details上
supportsDetailsContentSelector && openMotion?.motionName,
);

const headerClassName = classNames(
Expand All @@ -79,16 +91,78 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop
);

// ======================== HeaderProps ========================
const headerProps: React.HTMLAttributes<HTMLDivElement> = {
const headerProps: React.HTMLAttributes<HTMLElement> = {
Copy link
Preview

Copilot AI Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Since headerProps are spread onto a <summary> element, using React.HTMLAttributes<HTMLSummaryElement> would give you more precise TypeScript support.

Suggested change
const headerProps: React.HTMLAttributes<HTMLElement> = {
const headerProps: React.HTMLAttributes<HTMLSummaryElement> = {

Copilot uses AI. Check for mistakes.

className: headerClassName,
style: styles?.header,
...(['header', 'icon'].includes(collapsible) ? {} : collapsibleProps),
};

// ======================== Render ========================

const leavedClassName = `${prefixCls}-panel-hidden`;
const createPanelContent = (
props: Partial<{
className: string;
style: React.CSSProperties;
motionRef: (node: HTMLDivElement) => void;
}>,
) => {
const { className, style, motionRef } = props;

return (
<PanelContent
ref={motionRef}
prefixCls={prefixCls}
className={className}
classNames={customizeClassNames}
style={style}
styles={styles}
isActive={isActive}
forceRender={forceRender}
role={accordion ? 'tabpanel' : void 0}
>
{children}
</PanelContent>
);
};
let detailsChildren = (
<CSSMotion
visible={isActive}
leavedClassName={leavedClassName}
{...openMotion}
forceRender={forceRender}
removeOnLeave={destroyInactivePanel}
>
{({ className, style }, motionRef) =>
createPanelContent({
className,
style,
motionRef,
})
}
</CSSMotion>
);

// ? 模拟CSSMotion子元素生命周期管理
if (supportsDetailsContentSelector) {
if (isActive) {
detailsChildren = createPanelContent({});
} else if (!destroyInactivePanel && leavedClassName) {
detailsChildren = createPanelContent({
className: leavedClassName,
});
} else if (forceRender || (!destroyInactivePanel && !leavedClassName)) {
detailsChildren = createPanelContent({
style: { display: 'none' },
});
} else {
detailsChildren = null;
}
}

return (
<div {...resetProps} ref={ref} className={collapsePanelClassNames}>
<div {...headerProps}>
<details {...resetProps} ref={ref} className={collapsePanelClassNames} open={isActive}>
<summary {...headerProps}>
{showArrow && iconNode}
<span
className={classNames(`${prefixCls}-title`, customizeClassNames?.title)}
Expand All @@ -98,33 +172,9 @@ const CollapsePanel = React.forwardRef<HTMLDivElement, CollapsePanelProps>((prop
{header}
</span>
{ifExtraExist && <div className={`${prefixCls}-extra`}>{extra}</div>}
</div>
<CSSMotion
visible={isActive}
leavedClassName={`${prefixCls}-panel-hidden`}
{...openMotion}
forceRender={forceRender}
removeOnLeave={destroyInactivePanel}
>
{({ className: motionClassName, style: motionStyle }, motionRef) => {
return (
<PanelContent
ref={motionRef}
prefixCls={prefixCls}
className={motionClassName}
classNames={customizeClassNames}
style={motionStyle}
styles={styles}
isActive={isActive}
forceRender={forceRender}
role={accordion ? 'tabpanel' : void 0}
>
{children}
</PanelContent>
);
}}
</CSSMotion>
</div>
</summary>
{detailsChildren}
</details>
);
});

Expand Down
6 changes: 3 additions & 3 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface ItemType
> {
key?: CollapsePanelProps['panelKey'];
label?: CollapsePanelProps['header'];
ref?: React.RefObject<HTMLDivElement>;
ref?: React.RefObject<HTMLDetailsElement>;
}

export interface CollapseProps {
Expand All @@ -42,15 +42,15 @@ export interface CollapseProps {
}

export type SemanticName = 'header' | 'title' | 'body' | 'icon';
export interface CollapsePanelProps extends React.DOMAttributes<HTMLDivElement> {
export interface CollapsePanelProps extends React.DOMAttributes<HTMLDetailsElement> {
id?: string;
header?: string | React.ReactNode;
prefixCls?: string;
headerClass?: string;
showArrow?: boolean;
className?: string;
classNames?: Partial<Record<SemanticName, string>>;
style?: object;
style?: React.CSSProperties;
styles?: Partial<Record<SemanticName, React.CSSProperties>>;
isActive?: boolean;
openMotion?: CSSMotionProps;
Expand Down
32 changes: 16 additions & 16 deletions tests/__snapshots__/index.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ exports[`collapse props items should work with nested 1`] = `
<div
class="rc-collapse"
>
<div
<details
class="rc-collapse-item rc-collapse-item-disabled"
>
<div
<summary
aria-disabled="true"
aria-expanded="false"
class="rc-collapse-header rc-collapse-collapsible-disabled"
Expand All @@ -26,12 +26,12 @@ exports[`collapse props items should work with nested 1`] = `
>
collapse 1
</span>
</div>
</div>
<div
</summary>
</details>
<details
class="rc-collapse-item"
>
<div
<summary
aria-disabled="false"
aria-expanded="false"
class="rc-collapse-header"
Expand All @@ -57,12 +57,12 @@ exports[`collapse props items should work with nested 1`] = `
ExtraSpan
</span>
</div>
</div>
</div>
<div
</summary>
</details>
<details
class="rc-collapse-item important"
>
<div
<summary
aria-disabled="false"
aria-expanded="false"
class="rc-collapse-header"
Expand All @@ -81,12 +81,12 @@ exports[`collapse props items should work with nested 1`] = `
>
collapse 3
</span>
</div>
</div>
<div
</summary>
</details>
<details
class="rc-collapse-item"
>
<div
<summary
aria-disabled="false"
aria-expanded="false"
class="rc-collapse-header"
Expand All @@ -105,7 +105,7 @@ exports[`collapse props items should work with nested 1`] = `
>
title 3
</span>
</div>
</div>
</summary>
</details>
</div>
`;