From 14b56729165211b0ed38318384696f3b3df34de1 Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Tue, 24 Feb 2026 15:36:22 +0800 Subject: [PATCH 1/6] =?UTF-8?q?test(swiper):=20=E6=8F=90=E5=8D=87=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A6=86=E7=9B=96=E7=8E=87=E4=B8=94=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20navigation=20=E6=94=AF=E6=8C=81=20boolean=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swiper/Swiper.tsx | 38 ++++++++++++++++++++++++++++++-------- src/swiper/defaultProps.ts | 1 + src/swiper/swiper.en-US.md | 2 +- src/swiper/swiper.md | 2 +- src/swiper/type.ts | 2 +- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index 14f40180e..70ce3de83 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -1,5 +1,5 @@ import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isNumber, isObject } from 'lodash-es'; +import { isNumber } from 'lodash-es'; import classNames from 'classnames'; import { Property } from 'csstype'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -7,7 +7,7 @@ import { usePrefixClass } from '../hooks/useClass'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; import { useSwipe } from '../_util/useSwipe'; import parseTNode from '../_util/parseTNode'; -import { StyledProps } from '../common'; +import { StyledProps, TNode } from '../common'; import { SwiperChangeSource, SwiperNavigation, TdSwiperProps } from './type'; import { swiperDefaultProps } from './defaultProps'; import SwiperItem from './SwiperItem'; @@ -73,6 +73,14 @@ const Swiper = forwardRefWithStatics( const items = useRef([]); // swiper子项 const [itemCount, setItemCount] = useState(0); // 轮播子项数量 + // 默认导航配置 + const DEFAULT_SWIPER_NAVIGATION: SwiperNavigation = { + paginationPosition: 'bottom', + placement: 'inside', + showControls: false, + type: 'dots', + }; + const isVertical = useMemo(() => direction === 'vertical', [direction]); // 轮播滑动方向(垂直) const directionAxis = useMemo(() => (isVertical ? 'Y' : 'X'), [isVertical]); // 轮播滑动方向轴 @@ -96,7 +104,7 @@ const Swiper = forwardRefWithStatics( // 是否是导航配置 const isSwiperNavigation = useMemo(() => { - if (!navigation) return false; + if (!navigation || typeof navigation === 'boolean') return false; const { minShowNum, paginationPosition, placement, showControls, type } = navigation as any; return ( minShowNum !== undefined || @@ -109,11 +117,14 @@ const Swiper = forwardRefWithStatics( // 是否显示导航 const enableNavigation = useMemo(() => { + if (navigation === false) return false; + if (navigation === true) return true; if (isSwiperNavigation) { const nav = navigation as SwiperNavigation; return nav?.minShowNum ? items.current.length > nav?.minShowNum : true; } - return isObject(navigation); + // TNode 场景:有内容时显示导航,null/undefined 时不显示 + return !!navigation; }, [isSwiperNavigation, navigation]); const isBottomPagination = useMemo(() => { @@ -515,6 +526,15 @@ const Swiper = forwardRefWithStatics( ); const swiperNav = () => { + // 获取实际使用的导航配置 + const getNavigation = (): SwiperNavigation => { + if (navigation === true) return DEFAULT_SWIPER_NAVIGATION; + if (isSwiperNavigation) return navigation as SwiperNavigation; + return DEFAULT_SWIPER_NAVIGATION; + }; + + const nav = getNavigation(); + // dots const dots = (navigation: SwiperNavigation) => { if (['dots', 'dots-bar'].includes(navigation?.type || '')) { @@ -572,15 +592,17 @@ const Swiper = forwardRefWithStatics( }; if (!enableNavigation) return ''; - if (isSwiperNavigation) { + if (isSwiperNavigation || navigation === true) { return ( <> - {controlsNav(navigation as SwiperNavigation)} - {typeNav(navigation as SwiperNavigation)} + {controlsNav(nav)} + {typeNav(nav)} ); } - return isObject(navigation) ? '' : parseTNode(navigation); + // 对于 TNode 类型(函数、React 元素等),通过 parseTNode 渲染 + // 已经排除了 boolean 和 SwiperNavigation,剩余类型断言为 TNode + return parseTNode(navigation as TNode); }; return ( diff --git a/src/swiper/defaultProps.ts b/src/swiper/defaultProps.ts index 3ec019f4c..7a4546502 100644 --- a/src/swiper/defaultProps.ts +++ b/src/swiper/defaultProps.ts @@ -12,6 +12,7 @@ export const swiperDefaultProps: TdSwiperProps = { duration: 300, interval: 5000, loop: true, + navigation: true, nextMargin: 0, previousMargin: 0, type: 'default', diff --git a/src/swiper/swiper.en-US.md b/src/swiper/swiper.en-US.md index 44a84afe4..c787f2ea0 100644 --- a/src/swiper/swiper.en-US.md +++ b/src/swiper/swiper.en-US.md @@ -16,7 +16,7 @@ duration | Number | 300 | \- | N height | String / Number | - | \- | N interval | Number | 5000 | \- | N loop | Boolean | true | \- | N -navigation | TNode | - | Typescript:`SwiperNavigation \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +navigation | TNode | - | Typescript:`Boolean \| SwiperNavigation \| TNode`。`true` to show default navigation (dots), `false` to hide navigation。`SwiperNavigation \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N nextMargin | String / Number | 0 | \- | N previousMargin | String / Number | 0 | \- | N type | String | default | options: default/card | N diff --git a/src/swiper/swiper.md b/src/swiper/swiper.md index d13f8e0a8..3ef18c810 100644 --- a/src/swiper/swiper.md +++ b/src/swiper/swiper.md @@ -16,7 +16,7 @@ duration | Number | 300 | 滑动动画时长 | N height | String / Number | - | 当使用垂直方向滚动时的高度 | N interval | Number | 5000 | 轮播间隔时间 | N loop | Boolean | true | 是否循环播放 | N -navigation | TNode | - | 导航器全部配置。TS 类型:`SwiperNavigation \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +navigation | TNode | - | 导航器全部配置。TS 类型:`Boolean \| SwiperNavigation \| TNode`。`true` 表示显示默认导航(点状),`false` 表示不显示导航。`SwiperNavigation \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N nextMargin | String / Number | 0 | 后边距,可用于露出后一项的一小部分。默认单位 `px` | N previousMargin | String / Number | 0 | 前边距,可用于露出前一项的一小部分。默认单位 `px` | N type | String | default | 样式类型:默认样式、卡片样式。可选项:default/card | N diff --git a/src/swiper/type.ts b/src/swiper/type.ts index ce7124cf2..3feb1ffd2 100644 --- a/src/swiper/type.ts +++ b/src/swiper/type.ts @@ -54,7 +54,7 @@ export interface TdSwiperProps { /** * 导航器全部配置 */ - navigation?: SwiperNavigation | TNode; + navigation?: boolean | SwiperNavigation | TNode; /** * 后边距,可用于露出后一项的一小部分。默认单位 `px` * @default 0 From dbdeeff3e9a182915e1ae50c16dce79e937758c6 Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Wed, 25 Feb 2026 16:12:53 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BD=AE=E6=92=AD?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=A6=81=E7=94=A8=E7=8A=B6=E6=80=81=E5=8F=8A?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swiper/Swiper.tsx | 34 +- src/swiper/__tests__/swiper.test.tsx | 932 +++++++++++++++++++++++++++ 2 files changed, 955 insertions(+), 11 deletions(-) create mode 100644 src/swiper/__tests__/swiper.test.tsx diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index 70ce3de83..c6df6b94e 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -121,11 +121,10 @@ const Swiper = forwardRefWithStatics( if (navigation === true) return true; if (isSwiperNavigation) { const nav = navigation as SwiperNavigation; - return nav?.minShowNum ? items.current.length > nav?.minShowNum : true; + return nav?.minShowNum ? itemCount >= nav?.minShowNum : true; } - // TNode 场景:有内容时显示导航,null/undefined 时不显示 return !!navigation; - }, [isSwiperNavigation, navigation]); + }, [isSwiperNavigation, navigation, itemCount]); const isBottomPagination = useMemo(() => { if (!isSwiperNavigation || !enableNavigation) return false; @@ -148,9 +147,9 @@ const Swiper = forwardRefWithStatics( className, `${swiperClass}`, `${swiperClass}--${type}`, - `${isBottomPagination && navPlacement ? `${swiperClass}--${navPlacement}` : ''}`, + `${enableNavigation && navPlacement ? `${swiperClass}--${navPlacement}` : ''}`, ], - [swiperClass, type, isBottomPagination, navPlacement, className], + [swiperClass, type, enableNavigation, navPlacement, className], ); const intervalTimer = useRef(null); // 轮播计时器 @@ -362,16 +361,24 @@ const Swiper = forwardRefWithStatics( // 退出切换状态 const quitSwitching = useCallback( (axis: string) => { - previousIndex.current = calculateItemIndex(nextIndex.current, items.current.length, loop); - updateSwiperItemPosition(axis, previousIndex.current, loop); + const newIndex = calculateItemIndex(nextIndex.current, items.current.length, loop); + const wasNavCtrlActive = navCtrlActive.current; + updateSwiperItemPosition(axis, newIndex, loop); enterIdle(axis); - setItemChange((prevState) => !prevState); + // 导航操作或索引变化时触发 onChange + if (newIndex !== previousIndex.current || wasNavCtrlActive) { + previousIndex.current = newIndex; + setItemChange((prevState) => !prevState); + } else { + previousIndex.current = newIndex; + } }, [calculateItemIndex, enterIdle, loop, updateSwiperItemPosition], ); // 上一页 const goPrev = (source: SwiperChangeSource) => { + if (disabled) return; navCtrlActive.current = true; swiperSource.current = source; nextIndex.current = previousIndex.current - 1; @@ -380,6 +387,7 @@ const Swiper = forwardRefWithStatics( // 下一页 const goNext = (source: SwiperChangeSource) => { + if (disabled) return; navCtrlActive.current = true; swiperSource.current = source; nextIndex.current = previousIndex.current + 1; @@ -387,6 +395,7 @@ const Swiper = forwardRefWithStatics( }; const onItemClick = () => { + if (disabled) return; onClick?.(previousIndex.current ?? 0); }; @@ -444,9 +453,10 @@ const Swiper = forwardRefWithStatics( }, [calculateItemIndex, current, directionAxis, enterSwitching, loop, currentIsNull]); useEffect(() => { + if (disabled) return; onChange?.(previousIndex.current, { source: swiperSource.current }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [itemChange]); + }, [itemChange, disabled]); useEffect(() => { if (props.height) { @@ -469,6 +479,8 @@ const Swiper = forwardRefWithStatics( clearTimeout(durationTimer.current); durationTimer.current = null; } + if (disabled) return; + switch (swiperStatus) { case SwiperStatus.IDLE: if (autoplay) { @@ -490,7 +502,7 @@ const Swiper = forwardRefWithStatics( setSwiperStatus(SwiperStatus.IDLE); break; } - }, [autoplay, directionAxis, duration, enterIdle, enterSwitching, interval, quitSwitching, swiperStatus]); + }, [autoplay, directionAxis, duration, disabled, enterIdle, enterSwitching, interval, quitSwitching, swiperStatus]); const changeProvide = () => { if (props.disabled) return; @@ -529,7 +541,7 @@ const Swiper = forwardRefWithStatics( // 获取实际使用的导航配置 const getNavigation = (): SwiperNavigation => { if (navigation === true) return DEFAULT_SWIPER_NAVIGATION; - if (isSwiperNavigation) return navigation as SwiperNavigation; + if (isSwiperNavigation) return { ...DEFAULT_SWIPER_NAVIGATION, ...(navigation as SwiperNavigation) }; return DEFAULT_SWIPER_NAVIGATION; }; diff --git a/src/swiper/__tests__/swiper.test.tsx b/src/swiper/__tests__/swiper.test.tsx new file mode 100644 index 000000000..446a725d7 --- /dev/null +++ b/src/swiper/__tests__/swiper.test.tsx @@ -0,0 +1,932 @@ +import React, { useState } from 'react'; +import { describe, it, expect, render, fireEvent, vi, act, afterEach } from '@test/utils'; +import Swiper from '../index'; + +const prefix = 't'; +const swiperClass = `.${prefix}-swiper`; +const swiperItemClass = `.${prefix}-swiper-item`; +const swiperNavClass = `.${prefix}-swiper-nav`; + +const createRect = (width = 300, height = 200): DOMRect => ({ + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), +}); + +const mockElementMetrics = (width = 300, height = 200) => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => createRect(width, height)); +}; + +const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('Swiper', () => { + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders dots navigation with controls and triggers nav changes', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + const handleClick = vi.fn(); + + const { container } = render( + + Slide 1 + Slide 2 + Slide 3 + , + ); + + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--outside'); + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(3); + expect(container.querySelector(`${swiperItemClass}--active`)).toBeInTheDocument(); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + fireEvent.click(swiperContainer); + expect(handleClick).toHaveBeenCalledWith(0); + + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`)! as HTMLElement; + fireEvent.click(nextBtn); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'nav' })); + expect(container.querySelectorAll(`${swiperItemClass}--next`).length).toBeGreaterThan(0); + expect(container.querySelector(`${swiperNavClass}__dots-item--active`)).toBeInTheDocument(); + }); + + it('autoplays and loops with fraction navigation', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { getByText } = render( + + One + Two + , + ); + + expect(getByText('1/2')).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'autoplay' })); + expect(getByText('2/2')).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenLastCalledWith(0, expect.any(Object)); + expect(getByText('1/2')).toBeInTheDocument(); + }); + + it('respects controlled props and layout styles', async () => { + vi.useFakeTimers(); + mockElementMetrics(240, 180); + const handleChange = vi.fn(); + + const { container, rerender } = render( + + First + Second + Third + , + ); + + const containerEl = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(containerEl.style.left).toBe('24px'); + expect(containerEl.style.right).toBe('16px'); + expect(containerEl.style.flexDirection).toBe('column'); + expect(containerEl.style.height).toBe('200px'); + expect(container.querySelector(`${swiperNavClass}`)).not.toBeInTheDocument(); + + rerender( + + First + Second + Third + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + + expect(handleChange).toHaveBeenCalledWith(2, expect.any(Object)); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Third'); + }); + + it('handles swipe gestures in both directions', async () => { + vi.useFakeTimers(); + mockElementMetrics(300, 250); + const handleChange = vi.fn(); + + const { container } = render( + + Alpha + Beta + Gamma + , + ); + + await flushEffects(); + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 200, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: -200, clientY: 0 }] }); + expect(swiperContainer.style.transform).toContain('translateX(-100%)'); + expect(container.querySelectorAll(`${swiperItemClass}--next`).length).toBeGreaterThan(0); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: -200, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'touch' })); + handleChange.mockClear(); + + // Test that small movements below threshold don't trigger a change + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 20, clientY: 0 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 20, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(handleChange).not.toHaveBeenCalled(); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Beta'); + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: -200, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 220, clientY: 0 }] }); + expect(swiperContainer.style.transform).toContain('translateX(100%)'); + expect(container.querySelectorAll(`${swiperItemClass}--prev`).length).toBeGreaterThan(0); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 220, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + + expect(handleChange).toHaveBeenCalledWith(0, expect.objectContaining({ source: 'touch' })); + }); + + it('honors disabled state for navigation and gestures', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { container } = render( + + Left + Right + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + const baselineCalls = handleChange.mock.calls.length; + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 120, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(handleChange).toHaveBeenCalledTimes(baselineCalls); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Left'); + }); + + it('resets vertical swipe when movement is below threshold', async () => { + vi.useFakeTimers(); + mockElementMetrics(240, 360); + const handleChange = vi.fn(); + + const { container } = render( + + Top + Bottom + , + ); + + const baselineCalls = handleChange.mock.calls.length; + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + const startY = 240; + const moveY = 180; + const containerHeight = 360; + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: startY }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: moveY }] }); + + const expectedPercent = ((moveY - startY) / containerHeight) * 100; + expect(swiperContainer.style.transform).toContain(`translateY(${expectedPercent}%)`); + + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 180 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12); + }); + + expect(handleChange.mock.calls.length).toBe(baselineCalls); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Top'); + }); + + it('supports card layout with string sizing and nav placement configs', async () => { + mockElementMetrics(360, 220); + + const { container, rerender } = render( + + Slide A + Slide B + Slide C + Slide D + , + ); + + const cardContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(cardContainer.style.left).toBe('5%'); + expect(cardContainer.style.right).toBe('12%'); + expect(cardContainer.style.height).toBe('180px'); + + await flushEffects(); + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--card'); + + rerender( + + Slide A + Slide B + Slide C + Slide D + , + ); + + await flushEffects(); + expect(container.querySelector(`${swiperClass}`)).toBeInTheDocument(); + }); + + it('normalizes swipe offsets and respects guard fallbacks', async () => { + vi.useFakeTimers(); + const zeroRect = () => createRect(0, 0); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(undefined as any); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(undefined as any); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(zeroRect); + + const { container, rerender } = render( + + {/* intentionally empty to hit guards */} + , + ); + + const emptyContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(emptyContainer, { touches: [{ clientX: 10, clientY: 5 }] }); + fireEvent.touchMove(emptyContainer, { touches: [{ clientX: 80, clientY: 5 }] }); + fireEvent.touchEnd(emptyContainer, { changedTouches: [{ clientX: 80, clientY: 5 }], touches: [] }); + expect(emptyContainer.style.transform).toBe(''); + + rerender( + + Solo + Partner + , + ); + + const fallbackContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(fallbackContainer, { touches: [{ clientX: 100, clientY: 0 }] }); + fireEvent.touchMove(fallbackContainer, { touches: [{ clientX: -260, clientY: 0 }] }); + fireEvent.touchEnd(fallbackContainer, { changedTouches: [{ clientX: -260, clientY: 0 }], touches: [] }); + await flushEffects(); + const swiperItems = Array.from(container.querySelectorAll(`${swiperItemClass}`)); + expect(swiperItems[0]).toHaveTextContent('Solo'); + + vi.restoreAllMocks(); + vi.useFakeTimers(); + mockElementMetrics(320, 200); + + rerender( + + One + Two + Three + , + ); + + const activeContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`); + if (nextBtn) { + fireEvent.click(nextBtn); + } + + fireEvent.touchStart(activeContainer, { touches: [{ clientX: 160, clientY: 0 }] }); + fireEvent.touchMove(activeContainer, { touches: [{ clientX: -120, clientY: 0 }] }); + fireEvent.touchEnd(activeContainer, { changedTouches: [{ clientX: -120, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + + fireEvent.touchStart(activeContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(activeContainer, { touches: [{ clientX: -700, clientY: 0 }] }); + expect(activeContainer.style.transform).toContain('translateX(-100%)'); + fireEvent.touchEnd(activeContainer, { changedTouches: [{ clientX: -700, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + }); + it('recomputes when items are removed dynamically', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + + const Demo = () => { + const [visible, setVisible] = useState(true); + return ( +
+ + {visible && Shown} + Persistent + + +
+ ); + }; + + const { getByText } = render(); + + fireEvent.click(getByText('toggle')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(document.querySelectorAll(`${swiperItemClass}`).length).toBe(1); + }); + + it('clamps navigation at bounds without loop and falls back for custom navigation slot', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { container, rerender } = render( + + First + Second + , + ); + + await flushEffects(); + const initialCalls = handleChange.mock.calls.length; + const prevBtn = container.querySelector(`${swiperNavClass}__btn--prev`)! as HTMLElement; + fireEvent.click(prevBtn); + + await act(async () => { + await vi.advanceTimersByTimeAsync(18); + }); + + expect(handleChange.mock.calls.length).toBeGreaterThan(initialCalls); + expect(handleChange).toHaveBeenLastCalledWith(0, expect.objectContaining({ source: 'nav' })); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('First'); + + rerender( + slot} + onChange={handleChange} + > + First + Second + , + ); + + expect(container.querySelector('[data-testid="custom-nav"]')).toBeInTheDocument(); + }); + + it('handles vertical direction with controls and height from items', async () => { + vi.useFakeTimers(); + mockElementMetrics(300, 400); + const handleChange = vi.fn(); + + const { container } = render( + + Top + Bottom + Another + , + ); + + expect(container.querySelector(`${swiperClass}__container--card`)).toHaveStyle({ flexDirection: 'column' }); + expect(container.querySelector(`${swiperNavClass}__btn`)).not.toBeInTheDocument(); // controls not shown in vertical + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(3); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: 200 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: 50 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 50 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'touch' })); + }); + + it('renders without navigation and uses height from first item', async () => { + mockElementMetrics(300, 200); + const mockRect = { + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(mockRect); + + const { container } = render( + + Item 1 + Item 2 + , + ); + + expect(container.querySelector(`${swiperNavClass}`)).not.toBeInTheDocument(); + expect(container.querySelector(`${swiperClass}__container--card`)).toHaveStyle({ height: '150px' }); + + vi.restoreAllMocks(); + }); + + it('renders custom navigation as string', async () => { + const { container } = render( + + Slide 1 + , + ); + + expect(container.textContent).toContain('Custom Nav'); + }); + + it('handles navigation object without type property', async () => { + const { container } = render( + + Slide 1 + Slide 2 + , + ); + + expect(container.querySelector(`${swiperNavClass}__btn`)).toBeInTheDocument(); + }); + + it('exposes SwiperItem as static property', () => { + expect(Swiper.SwiperItem).toBeDefined(); + expect(typeof Swiper.SwiperItem).toBe('function'); + // Test that static properties are properly hoisted + expect(Object.keys(Swiper)).toContain('SwiperItem'); + expect(Object.getOwnPropertyDescriptor(Swiper, 'SwiperItem')).toBeDefined(); + // Test property descriptor attributes + const descriptor = Object.getOwnPropertyDescriptor(Swiper, 'SwiperItem'); + expect(descriptor?.writable).toBe(true); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(true); + }); + + it('renders dots-bar navigation with top pagination position', async () => { + mockElementMetrics(); + const { container } = render( + + Slide 1 + Slide 2 + Slide 3 + , + ); + + expect(container.querySelector(`${swiperNavClass}__dots-bar`)).toBeInTheDocument(); + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--inside'); + }); + + it('renders fraction navigation with left pagination position', async () => { + mockElementMetrics(); + const { container, getByText } = render( + + Slide 1 + Slide 2 + Slide 3 + , + ); + + expect(container.querySelector(`${swiperNavClass}__fraction`)).toBeInTheDocument(); + expect(getByText('1/3')).toBeInTheDocument(); + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--outside'); + }); + + it('respects minShowNum for navigation visibility', async () => { + mockElementMetrics(); + const { container } = render( + + Slide 1 + Slide 2 + , + ); + + await flushEffects(); + expect(container.querySelector(`${swiperNavClass}__dots`)).toBeInTheDocument(); + }); + + it('handles card type with loop and margins', async () => { + vi.useFakeTimers(); + mockElementMetrics(320, 240); + const handleChange = vi.fn(); + + const { container } = render( + + Card 1 + Card 2 + Card 3 + Card 4 + , + ); + + const cardContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(cardContainer.style.left).toBe('10%'); + expect(cardContainer.style.right).toBe('15%'); + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--card'); + + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`)! as HTMLElement; + fireEvent.click(nextBtn); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'nav' })); + }); + + it('sets height for vertical direction', () => { + mockElementMetrics(300, 400); + const { container } = render( + + Vertical 1 + Vertical 2 + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(swiperContainer.style.height).toBe('500px'); + expect(swiperContainer.style.flexDirection).toBe('column'); + }); + + it('handles disabled state completely', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + const handleClick = vi.fn(); + + const { container } = render( + + Disabled 1 + Disabled 2 + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`); + + // Touch should not work + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 150, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 50, clientY: 0 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 50, clientY: 0 }], touches: [] }); + + // Click should not work + fireEvent.click(swiperContainer); + + // Navigation button should not work + if (nextBtn) { + fireEvent.click(nextBtn); + } + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(handleChange).not.toHaveBeenCalled(); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('handles empty children gracefully', () => { + const { container } = render(); + + expect(container.querySelector(`${swiperClass}`)).toBeInTheDocument(); + expect(container.querySelectorAll(`${swiperItemClass}`).length).toBe(0); + }); + + it('handles single child without loop', () => { + const { container } = render( + + Single + , + ); + + expect(container.querySelectorAll(`${swiperItemClass}`).length).toBe(1); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Single'); + }); + + it('respects custom duration and interval', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + render( + + Fast 1 + Fast 2 + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'autoplay' })); + }); + + it('handles non-loop mode boundaries', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { container } = render( + + First + Second + Third + , + ); + + await flushEffects(); + const prevBtn = container.querySelector(`${swiperNavClass}__btn--prev`)! as HTMLElement; + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`)! as HTMLElement; + + // Try to go before first + fireEvent.click(prevBtn); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + expect(handleChange).toHaveBeenCalledWith(0, expect.objectContaining({ source: 'nav' })); + + // Go to last + fireEvent.click(nextBtn); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + fireEvent.click(nextBtn); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Third'); + + // Try to go after last + fireEvent.click(nextBtn); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Third'); + }); + + it('handles controlled current prop', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { rerender } = render( + + Controlled 1 + Controlled 2 + Controlled 3 + , + ); + + expect(document.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Controlled 1'); + + rerender( + + Controlled 1 + Controlled 2 + Controlled 3 + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + + expect(handleChange).toHaveBeenCalledWith(2, expect.any(Object)); + expect(document.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Controlled 3'); + }); + + it('renders navigation at various pagination positions', async () => { + mockElementMetrics(); + const positions: Array< + 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'left' | 'right' + > = ['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right', 'left', 'right']; + + for (const position of positions) { + const { container } = render( + + Test + Test2 + , + ); + expect(container.querySelector(`${swiperNavClass}__dots`)).toBeInTheDocument(); + // Note: Some positions may not show controls, but dots should be visible + } + }); + + it('handles touchable prop when available', () => { + // Note: touchable prop exists in interface but may not be implemented + // This test ensures it doesn't break if passed + const { container } = render( + + Touchable + , + ); + expect(container.querySelector(`${swiperClass}`)).toBeInTheDocument(); + }); + + it('triggers onClick with correct index', () => { + mockElementMetrics(); + const handleClick = vi.fn(); + + const { container } = render( + + Click 1 + Click 2 + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + fireEvent.click(swiperContainer); + expect(handleClick).toHaveBeenCalledWith(0); + }); + + it('handles animation prop', () => { + const { container } = render( + + Animated + , + ); + expect(container.querySelector(`${swiperClass}`)).toBeInTheDocument(); + }); + + it('renders default navigation when navigation is true', async () => { + mockElementMetrics(); + const { container } = render( + + Slide 1 + Slide 2 + Slide 3 + , + ); + + // 应该显示默认的圆点导航 + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(3); + // 默认不显示控制按钮 + expect(container.querySelector(`${swiperNavClass}__btn`)).not.toBeInTheDocument(); + // 默认底部位置 + expect(container.querySelector(`${swiperNavClass}--bottom`)).toBeInTheDocument(); + }); + + it('hides navigation when navigation is false', async () => { + mockElementMetrics(); + const { container } = render( + + Slide 1 + Slide 2 + , + ); + + // 不应该显示任何导航 + expect(container.querySelector(`${swiperNavClass}__dots-item`)).not.toBeInTheDocument(); + expect(container.querySelector(`${swiperNavClass}__btn`)).not.toBeInTheDocument(); + }); + + it('merges navigation config with defaults when navigation is object', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const { container } = render( + + Slide 1 + Slide 2 + , + ); + + // 应该显示默认的圆点导航(从默认配置合并) + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(2); + // 应该显示控制按钮(用户配置) + expect(container.querySelector(`${swiperNavClass}__btn`)).toBeInTheDocument(); + }); +}); From a4f8f73f25bdffe751665e5e610313829f7965c8 Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Thu, 26 Feb 2026 15:02:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E8=BD=AE?= =?UTF-8?q?=E6=92=AD=E5=AF=BC=E8=88=AA=E6=9D=A1=E4=BB=B6=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swiper/Swiper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index c6df6b94e..b857d7384 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -147,9 +147,9 @@ const Swiper = forwardRefWithStatics( className, `${swiperClass}`, `${swiperClass}--${type}`, - `${enableNavigation && navPlacement ? `${swiperClass}--${navPlacement}` : ''}`, + `${isBottomPagination && navPlacement ? `${swiperClass}--${navPlacement}` : ''}`, ], - [swiperClass, type, enableNavigation, navPlacement, className], + [swiperClass, type, isBottomPagination, navPlacement, className], ); const intervalTimer = useRef(null); // 轮播计时器 From 33b4be722e55e50eb22e11881c87b174728eb152 Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Thu, 26 Feb 2026 16:02:07 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20swiper=20?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E6=A0=B7=E5=BC=8F=E5=BA=94=E7=94=A8=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B9=B6=E6=9B=B4=E6=96=B0=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/test-coverage.js | 20 ++++++++++---------- src/swiper/__tests__/swiper.test.tsx | 12 ++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/site/test-coverage.js b/site/test-coverage.js index e623f806f..634fedd74 100644 --- a/site/test-coverage.js +++ b/site/test-coverage.js @@ -1,11 +1,11 @@ module.exports = { - actionSheet: { statements: '100%', branches: '96.55%', functions: '100%', lines: '100%' }, + actionSheet: { statements: '98.41%', branches: '89.18%', functions: '100%', lines: '98.36%' }, avatar: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, backTop: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, badge: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, button: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, calendar: { statements: '97.55%', branches: '90%', functions: '98.52%', lines: '98.45%' }, - cascader: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, + cascader: { statements: '100%', branches: '98.14%', functions: '100%', lines: '100%' }, cell: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, checkbox: { statements: '99.12%', branches: '98.27%', functions: '100%', lines: '100%' }, collapse: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, @@ -24,11 +24,11 @@ module.exports = { form: { statements: '2.79%', branches: '0%', functions: '0%', lines: '2.95%' }, grid: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, guide: { statements: '99.43%', branches: '94.49%', functions: '100%', lines: '100%' }, - hooks: { statements: '70.7%', branches: '45.88%', functions: '73.68%', lines: '70.66%' }, + hooks: { statements: '67.74%', branches: '43%', functions: '71.73%', lines: '67.79%' }, image: { statements: '97.72%', branches: '100%', functions: '92.3%', lines: '97.61%' }, imageViewer: { statements: '8.33%', branches: '2.83%', functions: '0%', lines: '8.69%' }, - indexes: { statements: '95.65%', branches: '69.81%', functions: '100%', lines: '96.94%' }, - input: { statements: '100%', branches: '98.18%', functions: '100%', lines: '100%' }, + indexes: { statements: '96.37%', branches: '77.35%', functions: '100%', lines: '96.94%' }, + input: { statements: '100%', branches: '96.49%', functions: '100%', lines: '100%' }, layout: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, link: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, list: { statements: '92%', branches: '77.77%', functions: '100%', lines: '100%' }, @@ -38,7 +38,7 @@ module.exports = { navbar: { statements: '100%', branches: '96.15%', functions: '100%', lines: '100%' }, noticeBar: { statements: '6.38%', branches: '0%', functions: '0%', lines: '6.52%' }, overlay: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, - picker: { statements: '51.71%', branches: '29.69%', functions: '57.31%', lines: '52.51%' }, + picker: { statements: '51.71%', branches: '29.09%', functions: '57.31%', lines: '52.51%' }, popover: { statements: '100%', branches: '96.55%', functions: '100%', lines: '100%' }, popup: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, progress: { statements: '100%', branches: '97.36%', functions: '100%', lines: '100%' }, @@ -47,7 +47,7 @@ module.exports = { radio: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, rate: { statements: '99.3%', branches: '98.98%', functions: '100%', lines: '99.3%' }, result: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, - search: { statements: '91.83%', branches: '59.09%', functions: '91.66%', lines: '93.75%' }, + search: { statements: '82.08%', branches: '55.88%', functions: '66.66%', lines: '83.33%' }, sideBar: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, skeleton: { statements: '100%', branches: '95.83%', functions: '100%', lines: '100%' }, slider: { statements: '97.74%', branches: '96.49%', functions: '100%', lines: '97.68%' }, @@ -55,11 +55,11 @@ module.exports = { steps: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, sticky: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' }, swipeCell: { statements: '100%', branches: '98.7%', functions: '100%', lines: '100%' }, - swiper: { statements: '57.55%', branches: '37.1%', functions: '67.6%', lines: '59.74%' }, + swiper: { statements: '97.29%', branches: '93.87%', functions: '100%', lines: '99.38%' }, switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, - tabBar: { statements: '100%', branches: '93.18%', functions: '100%', lines: '100%' }, + tabBar: { statements: '96.77%', branches: '87.5%', functions: '100%', lines: '96.55%' }, table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' }, - tabs: { statements: '99.35%', branches: '97.5%', functions: '100%', lines: '100%' }, + tabs: { statements: '99.35%', branches: '96.34%', functions: '100%', lines: '100%' }, tag: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, textarea: { statements: '98.64%', branches: '95%', functions: '93.33%', lines: '100%' }, toast: { statements: '98.73%', branches: '100%', functions: '94.11%', lines: '98.66%' }, diff --git a/src/swiper/__tests__/swiper.test.tsx b/src/swiper/__tests__/swiper.test.tsx index 446a725d7..fdcf4941a 100644 --- a/src/swiper/__tests__/swiper.test.tsx +++ b/src/swiper/__tests__/swiper.test.tsx @@ -588,13 +588,15 @@ describe('Swiper', () => { ); expect(container.querySelector(`${swiperNavClass}__dots-bar`)).toBeInTheDocument(); - expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--inside'); + // paginationPosition 为 top 时,inside/outside 无意义,外层容器不应有 placement 类名 + expect(container.querySelector(`${swiperClass}`)).not.toHaveClass('t-swiper--inside'); + expect(container.querySelector(`${swiperClass}`)).not.toHaveClass('t-swiper--outside'); }); - it('renders fraction navigation with left pagination position', async () => { + it('renders fraction navigation with bottom pagination position', async () => { mockElementMetrics(); const { container, getByText } = render( - + Slide 1 Slide 2 Slide 3 @@ -603,7 +605,9 @@ describe('Swiper', () => { expect(container.querySelector(`${swiperNavClass}__fraction`)).toBeInTheDocument(); expect(getByText('1/3')).toBeInTheDocument(); - expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--outside'); + // fraction 类型不在 isBottomPagination 判断中(只有 dots/dots-bar 算),外层容器不应有 placement 类名 + expect(container.querySelector(`${swiperClass}`)).not.toHaveClass('t-swiper--inside'); + expect(container.querySelector(`${swiperClass}`)).not.toHaveClass('t-swiper--outside'); }); it('respects minShowNum for navigation visibility', async () => { From 0f0d8bf17b1b5ae75d53e31beaea72bf09d146ae Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Thu, 26 Feb 2026 17:27:45 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swiper/Swiper.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index b857d7384..07ee869f0 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -538,6 +538,9 @@ const Swiper = forwardRefWithStatics( ); const swiperNav = () => { + // 如果不显示导航,直接返回 + if (!enableNavigation) return ''; + // 获取实际使用的导航配置 const getNavigation = (): SwiperNavigation => { if (navigation === true) return DEFAULT_SWIPER_NAVIGATION; @@ -603,7 +606,6 @@ const Swiper = forwardRefWithStatics( } }; - if (!enableNavigation) return ''; if (isSwiperNavigation || navigation === true) { return ( <> From 6e999eea848a3ee923e4ca0ea08915097a40003f Mon Sep 17 00:00:00 2001 From: v_sshuairen Date: Fri, 27 Feb 2026 15:26:03 +0800 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=20getNavigat?= =?UTF-8?q?ion=20=E6=96=B9=E6=B3=95=E4=BB=A5=E5=A4=8D=E7=94=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=85=8D=E7=BD=AE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swiper/Swiper.tsx | 45 ++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index 07ee869f0..7ad949ffc 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -13,6 +13,14 @@ import { swiperDefaultProps } from './defaultProps'; import SwiperItem from './SwiperItem'; import SwiperContext, { SwiperItemReference } from './SwiperContext'; +// 默认导航配置 +const DEFAULT_SWIPER_NAVIGATION: SwiperNavigation = { + paginationPosition: 'bottom', + placement: 'inside', + showControls: false, + type: 'dots', +}; + export interface SwiperProps extends TdSwiperProps, StyledProps { children?: React.ReactNode; touchable?: boolean; @@ -73,14 +81,6 @@ const Swiper = forwardRefWithStatics( const items = useRef([]); // swiper子项 const [itemCount, setItemCount] = useState(0); // 轮播子项数量 - // 默认导航配置 - const DEFAULT_SWIPER_NAVIGATION: SwiperNavigation = { - paginationPosition: 'bottom', - placement: 'inside', - showControls: false, - type: 'dots', - }; - const isVertical = useMemo(() => direction === 'vertical', [direction]); // 轮播滑动方向(垂直) const directionAxis = useMemo(() => (isVertical ? 'Y' : 'X'), [isVertical]); // 轮播滑动方向轴 @@ -115,32 +115,40 @@ const Swiper = forwardRefWithStatics( ); }, [navigation]); + // 获取实际使用的导航配置 + const getNavigation = useCallback((): SwiperNavigation => { + if (navigation === true) return DEFAULT_SWIPER_NAVIGATION; + if (isSwiperNavigation) return { ...DEFAULT_SWIPER_NAVIGATION, ...(navigation as SwiperNavigation) }; + // navigation 是 truthy 但不是有效配置时(如自定义 TNode),返回空对象 + return {} as SwiperNavigation; + }, [navigation, isSwiperNavigation]); + // 是否显示导航 const enableNavigation = useMemo(() => { if (navigation === false) return false; if (navigation === true) return true; if (isSwiperNavigation) { - const nav = navigation as SwiperNavigation; + const nav = getNavigation(); return nav?.minShowNum ? itemCount >= nav?.minShowNum : true; } return !!navigation; - }, [isSwiperNavigation, navigation, itemCount]); + }, [navigation, isSwiperNavigation, getNavigation, itemCount]); const isBottomPagination = useMemo(() => { if (!isSwiperNavigation || !enableNavigation) return false; - const nav = navigation as SwiperNavigation; + const nav = getNavigation(); return ( (!nav?.paginationPosition || nav?.paginationPosition === 'bottom') && (nav?.type === 'dots' || nav?.type === 'dots-bar') ); - }, [enableNavigation, isSwiperNavigation, navigation]); + }, [enableNavigation, getNavigation, isSwiperNavigation]); // 导航位置 const navPlacement = useMemo(() => { if (!isSwiperNavigation) return undefined; - const nav = navigation as SwiperNavigation; - return nav.placement; - }, [isSwiperNavigation, navigation]); + const nav = getNavigation(); + return nav?.placement; + }, [getNavigation, isSwiperNavigation]); const rootClass = useMemo( () => [ @@ -541,13 +549,6 @@ const Swiper = forwardRefWithStatics( // 如果不显示导航,直接返回 if (!enableNavigation) return ''; - // 获取实际使用的导航配置 - const getNavigation = (): SwiperNavigation => { - if (navigation === true) return DEFAULT_SWIPER_NAVIGATION; - if (isSwiperNavigation) return { ...DEFAULT_SWIPER_NAVIGATION, ...(navigation as SwiperNavigation) }; - return DEFAULT_SWIPER_NAVIGATION; - }; - const nav = getNavigation(); // dots