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