diff --git a/site/test-coverage.js b/site/test-coverage.js
index e623f806..634fedd7 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/Swiper.tsx b/src/swiper/Swiper.tsx
index 14f40180..7ad949ff 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,12 +7,20 @@ 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';
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;
@@ -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 ||
@@ -107,30 +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;
- return nav?.minShowNum ? items.current.length > nav?.minShowNum : true;
+ const nav = getNavigation();
+ return nav?.minShowNum ? itemCount >= nav?.minShowNum : true;
}
- return isObject(navigation);
- }, [isSwiperNavigation, navigation]);
+ return !!navigation;
+ }, [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(
() => [
@@ -351,16 +369,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;
@@ -369,6 +395,7 @@ const Swiper = forwardRefWithStatics(
// 下一页
const goNext = (source: SwiperChangeSource) => {
+ if (disabled) return;
navCtrlActive.current = true;
swiperSource.current = source;
nextIndex.current = previousIndex.current + 1;
@@ -376,6 +403,7 @@ const Swiper = forwardRefWithStatics(
};
const onItemClick = () => {
+ if (disabled) return;
onClick?.(previousIndex.current ?? 0);
};
@@ -433,9 +461,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) {
@@ -458,6 +487,8 @@ const Swiper = forwardRefWithStatics(
clearTimeout(durationTimer.current);
durationTimer.current = null;
}
+ if (disabled) return;
+
switch (swiperStatus) {
case SwiperStatus.IDLE:
if (autoplay) {
@@ -479,7 +510,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;
@@ -515,6 +546,11 @@ const Swiper = forwardRefWithStatics(
);
const swiperNav = () => {
+ // 如果不显示导航,直接返回
+ if (!enableNavigation) return '';
+
+ const nav = getNavigation();
+
// dots
const dots = (navigation: SwiperNavigation) => {
if (['dots', 'dots-bar'].includes(navigation?.type || '')) {
@@ -571,16 +607,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/__tests__/swiper.test.tsx b/src/swiper/__tests__/swiper.test.tsx
new file mode 100644
index 00000000..fdcf4941
--- /dev/null
+++ b/src/swiper/__tests__/swiper.test.tsx
@@ -0,0 +1,936 @@
+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();
+ // 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 bottom 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();
+ // 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 () => {
+ 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();
+ });
+});
diff --git a/src/swiper/defaultProps.ts b/src/swiper/defaultProps.ts
index 3ec019f4..7a454650 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 44a84afe..c787f2ea 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 d13f8e0a..3ef18c81 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 ce7124cf..3feb1ffd 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