From 375cf82fc21e285000e059c312ab1b08e088f526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Tue, 29 Sep 2026 18:22:15 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(SwipeCell):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=AE=9E=E4=BE=8B=E6=96=B9=E6=B3=95=20showSu?= =?UTF-8?q?re=20=E5=B1=95=E7=A4=BA=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swipe-cell/SwipeCell.tsx | 52 +++++++++--- src/swipe-cell/__tests__/swipe-cell.test.tsx | 84 ++++++++++++++++++++ src/swipe-cell/_example/event.tsx | 40 +++++----- src/swipe-cell/swipe-cell.en-US.md | 8 ++ src/swipe-cell/swipe-cell.md | 8 ++ src/swipe-cell/type.ts | 8 ++ 6 files changed, 168 insertions(+), 32 deletions(-) diff --git a/src/swipe-cell/SwipeCell.tsx b/src/swipe-cell/SwipeCell.tsx index da98f3517..39eb57a31 100644 --- a/src/swipe-cell/SwipeCell.tsx +++ b/src/swipe-cell/SwipeCell.tsx @@ -6,7 +6,7 @@ import { useDrag } from '@use-gesture/react'; import parseTNode from '../_util/parseTNode'; import nearest from '../_util/nearest'; import withNativeProps from '../_util/withNativeProps'; -import { TdSwipeCellProps, SwipeActionItem, Sure } from './type'; +import { TdSwipeCellProps, SwipeActionItem, Sure, SwipeCellInstanceFunctions } from './type'; import { swipeCellDefaultProps } from './defaultProps'; import { usePrefixClass } from '../hooks/useClass'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -16,9 +16,10 @@ import { Styles, StyledProps, TNode } from '../common'; import './style'; type SideType = 'left' | 'right'; -export interface SwipeCellRef { +export interface SwipeCellRef extends SwipeCellInstanceFunctions { expand: (side?: SideType, immediate?: boolean) => void; close: (immediate?: boolean) => void; + showSure: (sure: Sure, onClick?: SwipeActionItem['onClick']) => void; } export interface SwipeCellProps extends TdSwipeCellProps, StyledProps {} @@ -61,6 +62,7 @@ const SwipeCell = forwardRef((originProps, ref) => width: number; transform: string; }>({ content: '', width: 0, transform: 'none' }); + const sureClickHandlerRef = useRef(undefined); // Helper function to set timers that are tracked for cleanup const setTimer = (callback: () => void, delay: number) => { @@ -149,6 +151,25 @@ const SwipeCell = forwardRef((originProps, ref) => onChange(side); }; + const openSure = (side: SideType, sure: Sure, onClick?: SwipeActionItem['onClick']) => { + sureClickHandlerRef.current = onClick; + setSure({ + content: sure, + width: getSideOffsetX(side), + transform: side === 'left' ? 'translateX(-100%)' : 'translateX(100%)', + }); + setTimer(() => { + setSure((current) => ({ + ...current, + transform: 'none', + })); + }, 0); + }; + + const showSure: SwipeCellRef['showSure'] = (sure, onClick) => { + openSure(x > 0 ? 'left' : 'right', sure, onClick); + }; + const bind = useDrag( (state) => { ctx.dragging = true; @@ -185,6 +206,9 @@ const SwipeCell = forwardRef((originProps, ref) => ctx.dragging = false; }, 0); } else { + if (curSure.content) { + setSure({ content: '', width: 0, transform: 'none' }); + } setX(offsetX); } }, @@ -204,6 +228,7 @@ const SwipeCell = forwardRef((originProps, ref) => useImperativeHandle(ref, () => ({ expand, close, + showSure, })); useLayoutEffect(() => { @@ -224,17 +249,14 @@ const SwipeCell = forwardRef((originProps, ref) => const onActionClick = (action: SwipeActionItem, side: SideType) => { if (action.sure) { - setSure({ - content: action.sure, - width: getSideOffsetX(side), - transform: side === 'left' ? 'translateX(-100%)' : 'translateX(100%)', + openSure(side, action.sure, () => { + close(); + if (action.onClick) { + action.onClick(); + } else { + props.onClick?.(action, side); + } }); - setTimer(() => { - setSure((current) => ({ - ...current, - transform: 'none', - })); - }, 0); return; } @@ -278,7 +300,11 @@ const SwipeCell = forwardRef((originProps, ref) => left: 0, right: 0, }; - return
{parseTNode(curSure.content)}
; + return ( +
sureClickHandlerRef.current?.()}> + {parseTNode(curSure.content)} +
+ ); } return null; }; diff --git a/src/swipe-cell/__tests__/swipe-cell.test.tsx b/src/swipe-cell/__tests__/swipe-cell.test.tsx index 573782a5e..1dceb942c 100644 --- a/src/swipe-cell/__tests__/swipe-cell.test.tsx +++ b/src/swipe-cell/__tests__/swipe-cell.test.tsx @@ -257,6 +257,90 @@ describe('SwipeCell', () => { vi.useRealTimers(); }); + it('ref showSure renders sure content on expanded side and fires onClick on confirm', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const onSureClick = vi.fn(); + const { container, getByText } = render(内容} />); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + act(() => { + ref.current.expand('right'); + }); + act(() => { + ref.current.showSure('确认删除?', onSureClick); + }); + const overlay = getByText('确认删除?') as HTMLElement; + expect(overlay.style.transform).toContain('100%'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(overlay.style.transform).toBe('none'); + fireEvent.click(overlay); + expect(onSureClick).toHaveBeenCalledTimes(1); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('ref showSure picks left side when expanded to left', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const { container, getByText } = render(内容} />); + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + act(() => { + ref.current.expand('left'); + }); + act(() => { + ref.current.showSure('确认左侧删除?'); + }); + const overlay = getByText('确认左侧删除?') as HTMLElement; + expect(overlay.style.transform).toContain('-100%'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('clicking sure content from action closes and fires action.onClick', () => { + vi.useFakeTimers(); + const actionOnClick = vi.fn(); + const action = { text: '删除', sure: '确认删除?', onClick: actionOnClick }; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + expect(actionOnClick).not.toHaveBeenCalled(); + fireEvent.click(getByText('确认删除?')); + expect(actionOnClick).toHaveBeenCalledTimes(1); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('sure confirm falls back to props.onClick when action.onClick missing', () => { + vi.useFakeTimers(); + const propOnClick = vi.fn(); + const action = { text: '删除', sure: '确认删除?' }; + const { getByText } = render(内容} opened onClick={propOnClick} />); + fireEvent.click(getByText('删除')); + fireEvent.click(getByText('确认删除?')); + expect(propOnClick).toHaveBeenCalledWith(action, 'right'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('dragging clears visible sure content', () => { + vi.useFakeTimers(); + const { getByText, queryByText } = render( + 内容} opened />, + ); + fireEvent.click(getByText('删除')); + expect(getByText('确认删除?')).toBeInTheDocument(); + const latest = dragStore[dragStore.length - 1]; + act(() => { + latest.handler({ last: false, offset: [10, 0], lastOffset: [-10, 0], velocity: [0, 0], direction: [1, 0] }); + }); + expect(queryByText('确认删除?')).not.toBeInTheDocument(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + it('click away closes cell', () => { const { getByText, container } = render(
diff --git a/src/swipe-cell/_example/event.tsx b/src/swipe-cell/_example/event.tsx index fac971a64..86373dbae 100644 --- a/src/swipe-cell/_example/event.tsx +++ b/src/swipe-cell/_example/event.tsx @@ -12,38 +12,40 @@ const DemoBlock = ({ summary, children }: { summary?: string; children?: React.R export default function Demo() { const ref = useRef(null); - const handleClick = (message = 'click') => { - Toast({ - message, - }); - }; + const handleSureConfirm = () => { Toast.success({ message: '删除成功', }); - ref.current.close(); + ref.current?.close(); }; - const handleEdit = () => handleClick('编辑'); - const handleDelete = () => handleClick('删除'); + const handleEdit = () => + Toast({ + message: '编辑', + }); + + const handleDelete = () => { + ref.current?.showSure(
确认删除?
, handleSureConfirm); + }; - const sure = ( -
- 确认删除? -
+ const renderActions = () => ( + <> +
+ 编辑 +
+
+ 删除 +
+ ); - const actions = [ - { text: '编辑', className: 'btn edit-btn', onClick: handleEdit }, - { text: '删除', className: 'btn delete-btn', onClick: handleDelete, sure }, - ]; - return ( } opened /> diff --git a/src/swipe-cell/swipe-cell.en-US.md b/src/swipe-cell/swipe-cell.en-US.md index 928bff413..cca617656 100644 --- a/src/swipe-cell/swipe-cell.en-US.md +++ b/src/swipe-cell/swipe-cell.en-US.md @@ -17,3 +17,11 @@ onChange | Function | | Typescript: `(value: string) => void`
| N onClick | Function | | Typescript: `(action: SwipeActionItem, source: SwipeSource) => void`
[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts)。
`type SwipeSource = 'left' \| 'right'`
| N onDragend | Function | | Typescript: `() => void`
| N onDragstart | Function | | Typescript: `() => void`
| N + +### SwipeCellInstanceFunctions 组件实例方法 + +name | params | return | description +-- | -- | -- | -- +className | String | - | className of component | N +style | Object | - | CSS(Cascading Style Sheets),Typescript: `React.CSSProperties` | N +showSure | `(sure: string \| TNode, onClick?: SwipeActionItem['onClick'])` | `void` | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) diff --git a/src/swipe-cell/swipe-cell.md b/src/swipe-cell/swipe-cell.md index 581744cf5..e449770d8 100644 --- a/src/swipe-cell/swipe-cell.md +++ b/src/swipe-cell/swipe-cell.md @@ -17,3 +17,11 @@ onChange | Function | | TS 类型:`(value: string) => void`
菜单展开 onClick | Function | | TS 类型:`(action: SwipeActionItem, source: SwipeSource) => void`
操作项点击时触发(插槽写法组件不触发,业务侧自定义内容和事件)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts)。
`type SwipeSource = 'left' \| 'right'`
| N onDragend | Function | | 【开发中】TS 类型:`() => void`
滑动结束事件 | N onDragstart | Function | | 【开发中】TS 类型:`() => void`
滑动开始事件 | N + +### SwipeCellInstanceFunctions 组件实例方法 + +名称 | 参数 | 返回值 | 描述 +-- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式,TS 类型:`React.CSSProperties` | N +showSure | `(sure: string \| TNode, onClick?: SwipeActionItem['onClick'])` | `void` | 显示二次确认内容的函数。参数: `sure` 表示二次确认的具体内容,同 content; `onClick` 表示点击二次确认内容时执行的回调。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) diff --git a/src/swipe-cell/type.ts b/src/swipe-cell/type.ts index a3334f006..c056e2cf3 100644 --- a/src/swipe-cell/type.ts +++ b/src/swipe-cell/type.ts @@ -46,6 +46,14 @@ export interface TdSwipeCellProps { onDragstart?: () => void; } +/** 组件实例方法 */ +export interface SwipeCellInstanceFunctions { + /** + * 显示二次确认内容的函数。参数: `sure` 表示二次确认的具体内容,同 content; `onClick` 表示点击二次确认内容时执行的回调 + */ + showSure?: (sure: string | TNode, onClick?: SwipeActionItem['onClick']) => void; +} + export interface SwipeActionItem { text: string; className?: string; From 694b6cc306f9b7282eea216ca001f58f505091fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Tue, 29 Sep 2026 21:50:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(SwipeCell):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=E5=86=85=E5=AE=B9=E5=9C=A8?= =?UTF-8?q?=E4=B8=A4=E4=BE=A7=E9=87=8D=E5=A4=8D=E6=B8=B2=E6=9F=93=E5=8F=8A?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=99=A8=E6=9C=AA=E6=B8=85=E7=90=86=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swipe-cell/SwipeCell.tsx | 30 ++++++++++--- src/swipe-cell/__tests__/swipe-cell.test.tsx | 45 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/swipe-cell/SwipeCell.tsx b/src/swipe-cell/SwipeCell.tsx index 39eb57a31..14d6e403e 100644 --- a/src/swipe-cell/SwipeCell.tsx +++ b/src/swipe-cell/SwipeCell.tsx @@ -57,10 +57,12 @@ const SwipeCell = forwardRef((originProps, ref) => const leftRef = useRef(null); const rightRef = useRef(null); const timersRef = useRef[]>([]); + const sureClearTimerRef = useRef | null>(null); const [curSure, setSure] = useState<{ content: Sure; width: number; transform: string; + side?: SideType; }>({ content: '', width: 0, transform: 'none' }); const sureClickHandlerRef = useRef(undefined); @@ -78,11 +80,20 @@ const SwipeCell = forwardRef((originProps, ref) => return timerId; }; + const clearSureTimer = () => { + if (sureClearTimerRef.current === null) return; + + clearTimeout(sureClearTimerRef.current); + timersRef.current = timersRef.current.filter((timerId) => timerId !== sureClearTimerRef.current); + sureClearTimerRef.current = null; + }; + // Cleanup all timers on unmount useEffect( () => () => { timersRef.current.forEach((timerId) => clearTimeout(timerId)); timersRef.current = []; + sureClearTimerRef.current = null; }, [], ); @@ -135,13 +146,17 @@ const SwipeCell = forwardRef((originProps, ref) => setX(0); onChange(); if (curSure.content) { - setTimer(() => { + clearSureTimer(); + const timerId = setTimer(() => { setSure({ content: '', width: 0, transform: 'none', + side: undefined, }); + sureClearTimerRef.current = null; }, 300); + sureClearTimerRef.current = timerId; } }; @@ -152,11 +167,13 @@ const SwipeCell = forwardRef((originProps, ref) => }; const openSure = (side: SideType, sure: Sure, onClick?: SwipeActionItem['onClick']) => { + clearSureTimer(); sureClickHandlerRef.current = onClick; setSure({ content: sure, width: getSideOffsetX(side), transform: side === 'left' ? 'translateX(-100%)' : 'translateX(100%)', + side, }); setTimer(() => { setSure((current) => ({ @@ -207,7 +224,8 @@ const SwipeCell = forwardRef((originProps, ref) => }, 0); } else { if (curSure.content) { - setSure({ content: '', width: 0, transform: 'none' }); + clearSureTimer(); + setSure({ content: '', width: 0, transform: 'none', side: undefined }); } setX(offsetX); } @@ -288,8 +306,8 @@ const SwipeCell = forwardRef((originProps, ref) => return parseTNode(actions); }; - const renderSureContent = () => { - if (curSure.content) { + const renderSureContent = (side: SideType) => { + if (curSure.content && curSure.side === side) { const style: Styles = { width: Math.abs(curSure.width), transition: 'all .3s ease-in-out', @@ -326,14 +344,14 @@ const SwipeCell = forwardRef((originProps, ref) =>
{left && (
- {renderSureContent()} + {renderSureContent('left')} {renderActions(left, 'left')}
)} {parseTNode(content)} {right && (
- {renderSureContent()} + {renderSureContent('right')} {renderActions(right, 'right')}
)} diff --git a/src/swipe-cell/__tests__/swipe-cell.test.tsx b/src/swipe-cell/__tests__/swipe-cell.test.tsx index 1dceb942c..a4d6d6b77 100644 --- a/src/swipe-cell/__tests__/swipe-cell.test.tsx +++ b/src/swipe-cell/__tests__/swipe-cell.test.tsx @@ -300,6 +300,51 @@ describe('SwipeCell', () => { vi.useRealTimers(); }); + it('ref showSure renders confirmation content only on the active side', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const { container, queryAllByText } = render( + 内容
} />, + ); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + act(() => { + ref.current.expand('right'); + ref.current.showSure('确认删除?'); + }); + + expect(queryAllByText('确认删除?')).toHaveLength(1); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('keeps newly opened sure content after close and reopen', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const { container, getByText, queryByText } = render( + 内容
} />, + ); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + + act(() => { + ref.current.expand('right'); + ref.current.showSure('第一次确认'); + }); + expect(getByText('第一次确认')).toBeInTheDocument(); + + act(() => { + ref.current.close(); + ref.current.showSure('第二次确认'); + vi.advanceTimersByTime(300); + }); + + expect(queryByText('第一次确认')).not.toBeInTheDocument(); + expect(getByText('第二次确认')).toBeInTheDocument(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + it('clicking sure content from action closes and fires action.onClick', () => { vi.useFakeTimers(); const actionOnClick = vi.fn(); From 656f16a44da75b210f65fbb28e27837a90a2d563 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Sep 2026 14:11:44 +0000 Subject: [PATCH 3/5] chore: update snapshot --- test/snap/__snapshots__/csr.test.jsx.snap | 64 ++++------------------- test/snap/__snapshots__/ssr.test.jsx.snap | 4 +- 2 files changed, 12 insertions(+), 56 deletions(-) diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index d68eedede..b4af96562 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -127767,24 +127767,13 @@ exports[`csr snapshot test > csr test src/swipe-cell/_example/event.tsx 1`] = ` >
- - 编辑 - + 编辑
- - 删除 - + 删除
csr test src/swipe-cell/_example/event.tsx 1`] = ` >
- - 编辑 - + 编辑
- - 删除 - + 删除
@@ -129603,24 +129581,13 @@ exports[`csr snapshot test > csr test src/swipe-cell/_example/index.tsx 1`] = ` >
- - 编辑 - + 编辑
- - 删除 - + 删除
csr test src/swipe-cell/_example/index.tsx 1`] = ` >
- - 编辑 - + 编辑
- - 删除 - + 删除
@@ -167886,11 +167842,11 @@ exports[`ssr snapshot test > ssr test src/sticky/_example/offset.tsx 1`] = `" ssr test src/swipe-cell/_example/double.tsx 1`] = `"

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除
"`; -exports[`ssr snapshot test > ssr test src/swipe-cell/_example/event.tsx 1`] = `"

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; +exports[`ssr snapshot test > ssr test src/swipe-cell/_example/event.tsx 1`] = `"

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; exports[`ssr snapshot test > ssr test src/swipe-cell/_example/icon.tsx 1`] = `"

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除
"`; -exports[`ssr snapshot test > ssr test src/swipe-cell/_example/index.tsx 1`] = `"

SwipeCell 滑动操作

用于承载列表中的更多操作,通过左右滑动来展示,按钮的宽度固定高度根据列表高度而变化。

01 组件类型

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除

右滑单操作

选择
右滑单操作
辅助信息

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; +exports[`ssr snapshot test > ssr test src/swipe-cell/_example/index.tsx 1`] = `"

SwipeCell 滑动操作

用于承载列表中的更多操作,通过左右滑动来展示,按钮的宽度固定高度根据列表高度而变化。

01 组件类型

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除

右滑单操作

选择
右滑单操作
辅助信息

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; exports[`ssr snapshot test > ssr test src/swipe-cell/_example/left.tsx 1`] = `"

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index d2fc8fba8..62b3bde13 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -654,11 +654,11 @@ exports[`ssr snapshot test > ssr test src/sticky/_example/offset.tsx 1`] = `" ssr test src/swipe-cell/_example/double.tsx 1`] = `"

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除
"`; -exports[`ssr snapshot test > ssr test src/swipe-cell/_example/event.tsx 1`] = `"

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; +exports[`ssr snapshot test > ssr test src/swipe-cell/_example/event.tsx 1`] = `"

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; exports[`ssr snapshot test > ssr test src/swipe-cell/_example/icon.tsx 1`] = `"

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除
"`; -exports[`ssr snapshot test > ssr test src/swipe-cell/_example/index.tsx 1`] = `"

SwipeCell 滑动操作

用于承载列表中的更多操作,通过左右滑动来展示,按钮的宽度固定高度根据列表高度而变化。

01 组件类型

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除

右滑单操作

选择
右滑单操作
辅助信息

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; +exports[`ssr snapshot test > ssr test src/swipe-cell/_example/index.tsx 1`] = `"

SwipeCell 滑动操作

用于承载列表中的更多操作,通过左右滑动来展示,按钮的宽度固定高度根据列表高度而变化。

01 组件类型

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除

右滑单操作

选择
右滑单操作
辅助信息

左右滑操作

删除
左右滑操作
辅助信息
收藏
删除

带图标的滑动操作

图标加文字横排
辅助信息
编辑
删除
纯图标
辅助信息
图标加文字竖排
一段很长很长的内容文字
辅助信息
编辑
删除

带二次确认的操作

编辑
删除
带二次确认的操作
辅助信息
编辑
删除
"`; exports[`ssr snapshot test > ssr test src/swipe-cell/_example/left.tsx 1`] = `"

左滑单操作

左滑单操作
辅助信息
删除
左滑单操作
一段很长很长的内容文字
辅助信息
删除
左滑双操作
辅助信息
收藏
编辑
左滑三操作
辅助信息
收藏
编辑
删除
"`; From 0b423afdf1f86851b201f992c7da00159c4e7142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Tue, 29 Sep 2026 22:54:39 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(SwipeCell):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E7=9A=84=20Sure=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=20SwipeSource=20=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swipe-cell/SwipeCell.tsx | 29 ++++++++++++++--------------- src/swipe-cell/swipe-cell.en-US.md | 2 +- src/swipe-cell/swipe-cell.md | 6 +++--- src/swipe-cell/type.ts | 4 +--- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/swipe-cell/SwipeCell.tsx b/src/swipe-cell/SwipeCell.tsx index 14d6e403e..2cd1d250a 100644 --- a/src/swipe-cell/SwipeCell.tsx +++ b/src/swipe-cell/SwipeCell.tsx @@ -6,7 +6,7 @@ import { useDrag } from '@use-gesture/react'; import parseTNode from '../_util/parseTNode'; import nearest from '../_util/nearest'; import withNativeProps from '../_util/withNativeProps'; -import { TdSwipeCellProps, SwipeActionItem, Sure, SwipeCellInstanceFunctions } from './type'; +import { TdSwipeCellProps, SwipeActionItem, SwipeCellInstanceFunctions, SwipeSource } from './type'; import { swipeCellDefaultProps } from './defaultProps'; import { usePrefixClass } from '../hooks/useClass'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -15,11 +15,10 @@ import { Styles, StyledProps, TNode } from '../common'; import './style'; -type SideType = 'left' | 'right'; export interface SwipeCellRef extends SwipeCellInstanceFunctions { - expand: (side?: SideType, immediate?: boolean) => void; + expand: (side?: SwipeSource, immediate?: boolean) => void; close: (immediate?: boolean) => void; - showSure: (sure: Sure, onClick?: SwipeActionItem['onClick']) => void; + showSure: (sure: string | TNode, onClick?: SwipeActionItem['onClick']) => void; } export interface SwipeCellProps extends TdSwipeCellProps, StyledProps {} @@ -29,8 +28,8 @@ const threshold = '50%'; export const syncOpenedState = ( rootRef: React.RefObject, opened: SwipeCellProps['opened'], - getOpenedSide: (opened: SwipeCellProps['opened']) => SideType | undefined, - expand: (side: SideType) => void, + getOpenedSide: (opened: SwipeCellProps['opened']) => SwipeSource | undefined, + expand: (side: SwipeSource) => void, close: () => void, setTimer: (callback: () => void, delay: number) => void, ) => { @@ -59,10 +58,10 @@ const SwipeCell = forwardRef((originProps, ref) => const timersRef = useRef[]>([]); const sureClearTimerRef = useRef | null>(null); const [curSure, setSure] = useState<{ - content: Sure; + content: string | TNode; width: number; transform: string; - side?: SideType; + side?: SwipeSource; }>({ content: '', width: 0, transform: 'none' }); const sureClickHandlerRef = useRef(undefined); @@ -123,14 +122,14 @@ const SwipeCell = forwardRef((originProps, ref) => // eslint-disable-next-line react-hooks/exhaustive-deps const ctx = useMemo(() => ({ dragging: false, lastExpanded: '', initialExpanded: isOpened }), []); const swipeCellClass = usePrefixClass('swipe-cell'); - const onChange = (side?: SideType) => { + const onChange = (side?: SwipeSource) => { if (side !== ctx.lastExpanded) { props.onChange?.(side); } ctx.lastExpanded = side; }; - const getSideOffsetX = (side?: SideType) => { + const getSideOffsetX = (side?: SwipeSource) => { if (side === 'left' && leftRef.current) { return leftRef.current.clientWidth; } @@ -160,13 +159,13 @@ const SwipeCell = forwardRef((originProps, ref) => } }; - const expand = (side: SideType = 'right') => { + const expand = (side: SwipeSource = 'right') => { const x = getSideOffsetX(side); setX(x); onChange(side); }; - const openSure = (side: SideType, sure: Sure, onClick?: SwipeActionItem['onClick']) => { + const openSure = (side: SwipeSource, sure: string | TNode, onClick?: SwipeActionItem['onClick']) => { clearSureTimer(); sureClickHandlerRef.current = onClick; setSure({ @@ -265,7 +264,7 @@ const SwipeCell = forwardRef((originProps, ref) => ['touchstart'], ); - const onActionClick = (action: SwipeActionItem, side: SideType) => { + const onActionClick = (action: SwipeActionItem, side: SwipeSource) => { if (action.sure) { openSure(side, action.sure, () => { close(); @@ -282,7 +281,7 @@ const SwipeCell = forwardRef((originProps, ref) => if (props.onClick) props.onClick(action, side); }; - const renderActions = (actions: SwipeActionItem[] | TNode, side: SideType) => { + const renderActions = (actions: SwipeActionItem[] | TNode, side: SwipeSource) => { if (isArray(actions)) { return actions.map((action: SwipeActionItem, index: number) => { const btnClass = classNames([`${swipeCellClass}__content`, action.className || '']); @@ -306,7 +305,7 @@ const SwipeCell = forwardRef((originProps, ref) => return parseTNode(actions); }; - const renderSureContent = (side: SideType) => { + const renderSureContent = (side: SwipeSource) => { if (curSure.content && curSure.side === side) { const style: Styles = { width: Math.abs(curSure.width), diff --git a/src/swipe-cell/swipe-cell.en-US.md b/src/swipe-cell/swipe-cell.en-US.md index cca617656..036d169fa 100644 --- a/src/swipe-cell/swipe-cell.en-US.md +++ b/src/swipe-cell/swipe-cell.en-US.md @@ -12,7 +12,7 @@ content | TNode | - | Typescript: `string \| TNode`。[see more ts definition](h disabled | Boolean | - | \- | N left | TNode | - | Typescript: `Array \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N opened | Boolean / Array | false | Typescript: `boolean \| Array` | N -right | TNode | - | Typescript: `Array \| TNode` `interface SwipeActionItem {text: string; className?: string; style?: Styles; sure?: Sure; onClick?: () => void; [key: string]: any }` `type Sure = string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts) | N +right | TNode | - | Typescript: `Array \| TNode` `interface SwipeActionItem {text: string; className?: string; style?: Styles; sure?: string \| TNode; onClick?: () => void; [key: string]: any }`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts) | N onChange | Function | | Typescript: `(value: string) => void`
| N onClick | Function | | Typescript: `(action: SwipeActionItem, source: SwipeSource) => void`
[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts)。
`type SwipeSource = 'left' \| 'right'`
| N onDragend | Function | | Typescript: `() => void`
| N diff --git a/src/swipe-cell/swipe-cell.md b/src/swipe-cell/swipe-cell.md index e449770d8..b35413c5e 100644 --- a/src/swipe-cell/swipe-cell.md +++ b/src/swipe-cell/swipe-cell.md @@ -12,11 +12,11 @@ content | TNode | - | 操作项以外的内容。TS 类型:`string \| TNode` disabled | Boolean | - | 是否禁用滑动 | N left | TNode | - | 左侧滑动操作项。所有行为同 `right`。TS 类型:`Array \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N opened | Boolean / Array | false | 操作项是否呈现为打开态,值为数组时表示分别控制左右滑动的展开和收起状态。TS 类型:`boolean \| Array` | N -right | TNode | - | 右侧滑动操作项。有两种定义方式,一种是使用数组,二种是使用插槽。`right.text` 表示操作文本,`right.className` 表示操作项类名,`right.style` 表示操作项样式,`right.onClick` 表示点击操作项后执行的回调函数。示例:`[{ text: '删除', style: 'background-color: red', onClick: () => {} }]`。TS 类型:`Array \| TNode` `interface SwipeActionItem {text: string; className?: string; style?: Styles; sure?: Sure; onClick?: () => void; [key: string]: any }` `type Sure = string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts) | N +right | TNode | - | 右侧滑动操作项。有两种定义方式,一种是使用数组,二种是使用插槽。`right.text` 表示操作文本,`right.className` 表示操作项类名,`right.style` 表示操作项样式,`right.onClick` 表示点击操作项后执行的回调函数。示例:`[{ text: '删除', style: 'background-color: red', onClick: () => {} }]`。TS 类型:`Array \| TNode` `interface SwipeActionItem {text: string; className?: string; style?: Styles; sure?: string \| TNode; onClick?: () => void; [key: string]: any }`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts) | N onChange | Function | | TS 类型:`(value: string) => void`
菜单展开或者收回后将菜单的状态传递给父组件,值为数组时表示分别控制左右滑动的展开和收起状态 | N onClick | Function | | TS 类型:`(action: SwipeActionItem, source: SwipeSource) => void`
操作项点击时触发(插槽写法组件不触发,业务侧自定义内容和事件)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/swipe-cell/type.ts)。
`type SwipeSource = 'left' \| 'right'`
| N -onDragend | Function | | 【开发中】TS 类型:`() => void`
滑动结束事件 | N -onDragstart | Function | | 【开发中】TS 类型:`() => void`
滑动开始事件 | N +onDragend | Function | | TS 类型:`() => void`
滑动结束事件 | N +onDragstart | Function | | TS 类型:`() => void`
滑动开始事件 | N ### SwipeCellInstanceFunctions 组件实例方法 diff --git a/src/swipe-cell/type.ts b/src/swipe-cell/type.ts index c056e2cf3..830d4512c 100644 --- a/src/swipe-cell/type.ts +++ b/src/swipe-cell/type.ts @@ -58,11 +58,9 @@ export interface SwipeActionItem { text: string; className?: string; style?: Styles; - sure?: Sure; + sure?: string | TNode; onClick?: () => void; [key: string]: any; } -export type Sure = string | TNode; - export type SwipeSource = 'left' | 'right'; From 5ce2d2a6d5dd8a713dc3723e443df9726f4f08c7 Mon Sep 17 00:00:00 2001 From: "Y." Date: Tue, 13 Oct 2026 22:46:04 +0800 Subject: [PATCH 5/5] fix(SwipeCell): refine sure overlay lifecycle and side detection --- src/swipe-cell/SwipeCell.tsx | 33 +++++++++++------ src/swipe-cell/__tests__/swipe-cell.test.tsx | 39 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/swipe-cell/SwipeCell.tsx b/src/swipe-cell/SwipeCell.tsx index 2cd1d250a..519879615 100644 --- a/src/swipe-cell/SwipeCell.tsx +++ b/src/swipe-cell/SwipeCell.tsx @@ -59,10 +59,9 @@ const SwipeCell = forwardRef((originProps, ref) => const sureClearTimerRef = useRef | null>(null); const [curSure, setSure] = useState<{ content: string | TNode; - width: number; transform: string; side?: SwipeSource; - }>({ content: '', width: 0, transform: 'none' }); + }>({ content: '', transform: 'none' }); const sureClickHandlerRef = useRef(undefined); // Helper function to set timers that are tracked for cleanup @@ -87,6 +86,11 @@ const SwipeCell = forwardRef((originProps, ref) => sureClearTimerRef.current = null; }; + const resetSure = () => { + setSure({ content: '', transform: 'none', side: undefined }); + sureClickHandlerRef.current = undefined; + }; + // Cleanup all timers on unmount useEffect( () => () => { @@ -147,12 +151,7 @@ const SwipeCell = forwardRef((originProps, ref) => if (curSure.content) { clearSureTimer(); const timerId = setTimer(() => { - setSure({ - content: '', - width: 0, - transform: 'none', - side: undefined, - }); + resetSure(); sureClearTimerRef.current = null; }, 300); sureClearTimerRef.current = timerId; @@ -163,6 +162,10 @@ const SwipeCell = forwardRef((originProps, ref) => const x = getSideOffsetX(side); setX(x); onChange(side); + if (curSure.content && curSure.side !== side) { + clearSureTimer(); + resetSure(); + } }; const openSure = (side: SwipeSource, sure: string | TNode, onClick?: SwipeActionItem['onClick']) => { @@ -170,7 +173,6 @@ const SwipeCell = forwardRef((originProps, ref) => sureClickHandlerRef.current = onClick; setSure({ content: sure, - width: getSideOffsetX(side), transform: side === 'left' ? 'translateX(-100%)' : 'translateX(100%)', side, }); @@ -183,7 +185,15 @@ const SwipeCell = forwardRef((originProps, ref) => }; const showSure: SwipeCellRef['showSure'] = (sure, onClick) => { - openSure(x > 0 ? 'left' : 'right', sure, onClick); + let side: SwipeSource; + if (x > 0) { + side = 'left'; + } else if (x < 0) { + side = 'right'; + } else { + side = rightRef.current ? 'right' : 'left'; + } + openSure(side, sure, onClick); }; const bind = useDrag( @@ -224,7 +234,7 @@ const SwipeCell = forwardRef((originProps, ref) => } else { if (curSure.content) { clearSureTimer(); - setSure({ content: '', width: 0, transform: 'none', side: undefined }); + resetSure(); } setX(offsetX); } @@ -308,7 +318,6 @@ const SwipeCell = forwardRef((originProps, ref) => const renderSureContent = (side: SwipeSource) => { if (curSure.content && curSure.side === side) { const style: Styles = { - width: Math.abs(curSure.width), transition: 'all .3s ease-in-out', transform: curSure.transform, position: 'absolute', diff --git a/src/swipe-cell/__tests__/swipe-cell.test.tsx b/src/swipe-cell/__tests__/swipe-cell.test.tsx index a4d6d6b77..48dfbbb5d 100644 --- a/src/swipe-cell/__tests__/swipe-cell.test.tsx +++ b/src/swipe-cell/__tests__/swipe-cell.test.tsx @@ -318,6 +318,45 @@ describe('SwipeCell', () => { vi.useRealTimers(); }); + it('ref showSure falls back to left side when closed and only left actions exist', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const { getByText } = render(内容} />); + act(() => { + ref.current.showSure('确认左侧删除?'); + }); + expect(getByText('确认左侧删除?')).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(1); + }); + vi.useRealTimers(); + }); + + it('expand to a different side clears visible sure content', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const { container, getByText, queryByText } = render( + 内容} />, + ); + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + act(() => { + ref.current.expand('right'); + ref.current.showSure('确认删除?'); + }); + expect(getByText('确认删除?')).toBeInTheDocument(); + act(() => { + ref.current.expand('left'); + }); + expect(queryByText('确认删除?')).not.toBeInTheDocument(); + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + }); + it('keeps newly opened sure content after close and reopen', () => { vi.useFakeTimers(); const ref = React.createRef();