diff --git a/site/test-coverage.js b/site/test-coverage.js
index 2627522e..8b519645 100644
--- a/site/test-coverage.js
+++ b/site/test-coverage.js
@@ -59,7 +59,7 @@ module.exports = {
switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' },
tabBar: { statements: '100%', branches: '93.18%', functions: '100%', lines: '100%' },
table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' },
- tabs: { statements: '43.22%', branches: '18.75%', functions: '56%', lines: '45.07%' },
+ tabs: { statements: '99.35%', branches: '97.5%', 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/tabs/__tests__/tab-panel.test.tsx b/src/tabs/__tests__/tab-panel.test.tsx
new file mode 100644
index 00000000..e1af5278
--- /dev/null
+++ b/src/tabs/__tests__/tab-panel.test.tsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { describe, it, expect, render, vi, fireEvent, beforeEach, act } from '@test/utils';
+import { Tabs, TabPanel } from '../index';
+
+const prefix = 't';
+const name = `.${prefix}-tabs`;
+const panelClass = `.${prefix}-tab-panel`;
+
+describe('TabPanel', () => {
+ beforeEach(() => {
+ Object.defineProperty(Element.prototype, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value: vi.fn(),
+ });
+ });
+
+ describe('props', () => {
+ it(': value', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ // 根据 value 确定激活状态
+ const panels = container.querySelectorAll(panelClass);
+ expect(panels[0]).toHaveStyle({ display: 'block' });
+ expect(panels[1]).toHaveStyle({ display: 'none' });
+ });
+
+ it(': label', () => {
+ const { queryByText } = render(
+
+
+ Content
+
+ ,
+ );
+
+ expect(queryByText('自定义标签')).toBeInTheDocument();
+ });
+
+ it(': children', () => {
+ const { queryByText } = render(
+
+
+ 子元素内容
+
+ ,
+ );
+
+ expect(queryByText('子元素内容')).toBeInTheDocument();
+ });
+
+ it(': disabled', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ expect(tab2).toHaveClass(`${prefix}-tabs__item--disabled`);
+
+ fireEvent.click(tab2);
+ expect(onChange).not.toHaveBeenCalled();
+ });
+ it(': lazy', async () => {
+ const { container, queryByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ expect(queryByText('Content 2')).not.toBeInTheDocument();
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(tab2);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(queryByText('Content 2')).toBeInTheDocument();
+ });
+
+ it(': destroyOnHide', async () => {
+ const { container, queryByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ expect(queryByText('Content 1')).toBeInTheDocument();
+
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(tab2);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(queryByText('Content 2')).toBeInTheDocument();
+ expect(queryByText('Content 1')).not.toBeInTheDocument();
+ });
+
+ it(': badgeProps', () => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+
+ expect(container.querySelector('.t-badge')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/tabs/__tests__/tabs.test.tsx b/src/tabs/__tests__/tabs.test.tsx
new file mode 100644
index 00000000..d0267e4c
--- /dev/null
+++ b/src/tabs/__tests__/tabs.test.tsx
@@ -0,0 +1,670 @@
+import React from 'react';
+import { describe, it, expect, render, vi, fireEvent, beforeEach, act } from '@test/utils';
+import { Tabs, TabPanel } from '../index';
+
+const prefix = 't';
+const name = `.${prefix}-tabs`;
+
+describe('Tabs', () => {
+ beforeEach(() => {
+ Object.defineProperty(Element.prototype, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value: vi.fn(),
+ });
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
+ configurable: true,
+ get() {
+ return 100;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, 'offsetLeft', {
+ configurable: true,
+ get() {
+ return 50;
+ },
+ });
+ });
+
+ describe('props', () => {
+ it(': bottomLineMode', async () => {
+ const modes = ['fixed', 'auto', 'full'] as const;
+
+ modes.forEach((mode) => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(container.querySelector(`${name}__track`)).toBeTruthy();
+ });
+
+ // full 模式
+ const { container: fullContainer } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const fullTab2 = fullContainer.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(fullTab2);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(fullContainer.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2');
+
+ // auto 模式
+ const { container: autoContainer } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const autoTab2 = autoContainer.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(autoTab2);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(autoContainer.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2');
+ });
+
+ it(': children', () => {
+ const { queryByText } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(queryByText('Tab 1')).toBeInTheDocument();
+ expect(queryByText('Content 1')).toBeInTheDocument();
+ });
+
+ it(': list', () => {
+ const list = [
+ { label: 'Item 1', value: 'a' },
+ { label: 'Item 2', value: 'b' },
+ ];
+ const { queryByText: queryByText2 } = render();
+ expect(queryByText2('Item 1')).toBeInTheDocument();
+ });
+
+ it(': size', () => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(container.querySelector('.large')).toBeTruthy();
+ });
+
+ it(': spaceEvenly', () => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(container.querySelector(`${name}__item--evenly`)).toBeTruthy();
+ });
+
+ it(': theme', () => {
+ const themes = ['line', 'tag', 'card'] as const;
+ themes.forEach((theme) => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(container.querySelector(`${name}__item--${theme}`)).toBeTruthy();
+ });
+
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+ ,
+ );
+ expect(container.querySelector(`${name}__item-prefix`)).toBeTruthy();
+ expect(container.querySelector(`${name}__item-suffix`)).toBeTruthy();
+ });
+
+ it(': value', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2');
+ });
+
+ it(': defaultValue', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1');
+ });
+
+ it(': swipeable', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ const content = container.querySelector(`${name}__content`);
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 10, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1');
+ });
+
+ it(': stickyProps', () => {
+ const { container } = render(
+
+
+ Content 1
+
+ ,
+ );
+ expect(container.querySelector(name)).toBeTruthy();
+ });
+
+ it(': animation', async () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 350);
+ });
+ });
+
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(tab2);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2');
+
+ const tab1 = container.querySelectorAll(`${name}__item`)[0];
+ fireEvent.click(tab1);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1');
+ });
+
+ it(': disabled', () => {
+ const handleChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const disabledTab = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(disabledTab);
+ expect(handleChange).not.toHaveBeenCalled();
+
+ const activeTab = container.querySelectorAll(`${name}__item`)[0];
+ fireEvent.click(activeTab);
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('events', () => {
+ it(': onChange', () => {
+ const handleChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(tab2);
+
+ expect(handleChange).toHaveBeenCalledWith('2', 'Tab 2');
+ });
+
+ it(': onClick', () => {
+ const handleClick = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+ const tab2 = container.querySelectorAll(`${name}__item`)[1];
+ fireEvent.click(tab2);
+
+ expect(handleClick).toHaveBeenCalledWith('2', 'Tab 2');
+ });
+ });
+
+ describe('swipe gestures', () => {
+ it(': swipe left to next tab', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ // 向左滑动:startX > endX,切换到下一个 tab
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(onChange).toHaveBeenCalledWith('2', 'Tab 2');
+ });
+
+ it(': swipe right from middle tab triggers onChange', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ // 向右滑动:startX < endX,从中间 tab 切换到上一个
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 150, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(onChange).toHaveBeenCalledWith('1', 'Tab 1');
+ });
+
+ it(': swipe right from middle tab', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 10, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(container.querySelector(name)).toBeTruthy();
+ });
+
+ it(': swipe left from last tab (boundary)', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ // 从最后一个 tab 向左滑动,不应切换
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it(': boundary swipe (first tab right)', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 150, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it(': vertical swipe ignored', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ // 垂直滑动:dValueY > dValueX,不会触发水平切换
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 110, clientY: 150 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 120, clientY: 250 }] });
+ fireEvent.touchEnd(content);
+
+ // 验证组件正常工作
+ expect(container.querySelector(name)).toBeTruthy();
+ });
+
+ it(': canMove prevents multiple swipes', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 10, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(container.querySelector(name)).toBeTruthy();
+ });
+
+ it(': small swipe distance ignored', () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 30, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 35, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(container.querySelector(name)).toBeTruthy();
+ });
+ });
+
+ describe('edge cases', () => {
+ it(': should handle non-TabPanel children filtering', () => {
+ const { container, queryByText } = render(
+
+
+ Content 1
+
+ Not a TabPanel
+
+ Content 2
+
+ ,
+ );
+
+ expect(queryByText('Tab 1')).toBeInTheDocument();
+ expect(queryByText('Tab 2')).toBeInTheDocument();
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1');
+ });
+
+ it(': should handle moveToActiveTab with no active tab', async () => {
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 400);
+ });
+ });
+
+ const tab1 = container.querySelectorAll(`${name}__item`)[0];
+ fireEvent.click(tab1);
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1');
+ });
+
+ it(': should handle empty items array', () => {
+ const onChange = vi.fn();
+ const { container } = render();
+
+ const content = container.querySelector(`${name}__content`);
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] });
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ fireEvent.touchEnd(content);
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it(': should handle navScroll with no active tab early return', async () => {
+ const { rerender } = render(
+
+
+ Content 1
+
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 350);
+ });
+ });
+
+ rerender(
+
+
+ Content 1
+
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 30);
+ });
+ });
+
+ fireEvent(window, new Event('resize'));
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 30);
+ });
+ });
+ });
+
+ it(': should handle swipe right branch execution', async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+ ,
+ );
+
+ const content = container.querySelector(`${name}__content`);
+ await act(async () => {
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] });
+ });
+ await act(async () => {
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 120, clientY: 50 }] });
+ });
+ await act(async () => {
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 240, clientY: 50 }] });
+ });
+ await act(async () => {
+ fireEvent.touchEnd(content);
+ });
+
+ expect(onChange).toHaveBeenCalledWith('1', 'Tab 1');
+ });
+
+ it(': should handle swipe right branch with list data', async () => {
+ const onChange = vi.fn();
+ const list = [
+ { label: 'Tab 1', value: '1' },
+ { label: 'Tab 2', value: '2' },
+ { label: 'Tab 3', value: '3' },
+ ];
+ const { container } = render();
+
+ const content = container.querySelector(`${name}__content`);
+ await act(async () => {
+ fireEvent.touchStart(content, { targetTouches: [{ clientX: 30, clientY: 40 }] });
+ });
+ await act(async () => {
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 110, clientY: 40 }] });
+ });
+ await act(async () => {
+ fireEvent.touchMove(content, { targetTouches: [{ clientX: 220, clientY: 40 }] });
+ });
+ await act(async () => {
+ fireEvent.touchEnd(content);
+ });
+
+ expect(onChange).toHaveBeenCalledWith('1', 'Tab 1');
+ });
+ });
+});