Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 11 additions & 39 deletions packages/components/radio/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ import { CheckContext, type CheckContextValue } from '../common/Check';
import useCommonClassName from '../hooks/useCommonClassName';
import useConfig from '../hooks/useConfig';
import useControlled from '../hooks/useControlled';
import useDeepEffect from '../hooks/useDeepEffect';
import useDefaultProps from '../hooks/useDefaultProps';
import useMutationObserver from '../hooks/useMutationObserver';
import Radio from './Radio';
import { radioGroupDefaultProps } from './defaultProps';
import useKeyboard from './useKeyboard';

import type { StyledProps } from '../common';
import type { TdRadioGroupProps } from './type';
import type { RadioValue, TdRadioGroupProps } from './type';

/**
* RadioGroup 组件所接收的属性
*/
export interface RadioGroupProps extends TdRadioGroupProps, StyledProps {
export interface RadioGroupProps<T extends RadioValue = RadioValue> extends TdRadioGroupProps<T>, StyledProps {
children?: ReactNode;
}

Expand Down Expand Up @@ -88,48 +88,20 @@ const RadioGroup: React.FC<RadioGroupProps> = (originalProps) => {
});
};

// 针对子元素更新的场景,包括 value 变化等
// 只监听 class 属性变化,避免 bg-block 元素或子组件(如 Badge)导致无限循环
useMutationObserver(
radioGroupRef.current,
(mutations) => {
const hasRelevantChange = mutations.some((mutation) => {
const target = mutation.target as HTMLElement;
// 只关注 radio-button 元素的 class 变化(checked 状态变化)
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
return target.classList?.contains(`${classPrefix}-radio-button`);
}
return false;
});

if (hasRelevantChange) {
calcBarStyle();
}
},
{
config: {
attributes: true,
attributeFilter: ['class'],
childList: false,
characterData: false,
subtree: true,
},
},
);

useEffect(() => {
calcBarStyle();
if (!radioGroupRef.current) return;

// 针对父元素初始化时隐藏导致无法正确计算尺寸的问题
const observer = observe(radioGroupRef.current, null, calcBarStyle, 0);
observerRef.current = observer;

return () => {
observerRef.current?.disconnect();
observerRef.current = null;
observer?.disconnect();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useDeepEffect(() => {
calcBarStyle();
}, [internalValue, options]);

const renderBlock = () => {
if (!variant.includes('filled') || !barStyle) {
Expand Down Expand Up @@ -180,4 +152,4 @@ const RadioGroup: React.FC<RadioGroupProps> = (originalProps) => {

RadioGroup.displayName = 'RadioGroup';

export default RadioGroup;
export default RadioGroup as <T extends RadioValue = RadioValue>(props: RadioGroupProps<T>) => React.ReactElement;
195 changes: 188 additions & 7 deletions packages/components/radio/__tests__/radio.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import { render, fireEvent, vi } from '@test/utils';
import React, { useEffect, useState } from 'react';
import { act, fireEvent, mockElementSizes, mockIntersectionObserver, render, vi } from '@test/utils';
import Radio from '../Radio';

describe('Radio', () => {
describe('Radio [basic api]', () => {
test('checked & children', () => {
const { container, queryByText } = render(<Radio checked={true}>单选框</Radio>);
expect(container.firstChild).toHaveClass('t-radio', 't-is-checked');
Expand All @@ -25,7 +25,7 @@ describe('Radio', () => {
const { container } = render(<Radio disabled={true} onChange={fn}></Radio>);
expect(container.firstChild).toHaveClass('t-is-disabled', 't-radio');
fireEvent.click(container.firstChild);
expect(fn).toBeCalledTimes(0);
expect(fn).toHaveBeenCalledTimes(0);
});

test('label', () => {
Expand All @@ -37,11 +37,11 @@ describe('Radio', () => {
const fn = vi.fn();
const { container } = render(<Radio disabled={true} onChange={fn} />);
fireEvent.click(container.firstElementChild);
expect(fn).toBeCalledTimes(0);
expect(fn).toHaveBeenCalledTimes(0);
});
});

describe('RadioGroup', () => {
describe('RadioGroup [basic api]', () => {
test('value', () => {
const { container } = render(
<Radio.Group value="gz">
Expand All @@ -65,7 +65,7 @@ describe('RadioGroup', () => {
</Radio.Group>,
);
fireEvent.click(container.firstChild.firstChild);
expect(fn).toBeCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
});

test('options', () => {
Expand Down Expand Up @@ -135,3 +135,184 @@ describe('RadioGroup', () => {
expect(container.firstChild.firstChild).toHaveClass('t-radio-button');
});
});

describe('RadioGroup [primary-filled bg-block]', () => {
const MOCK_SIZES = [
{ selector: '.t-radio-group', width: 300, height: 40, left: 0, top: 0 },
{ selector: '.t-radio-button:nth-child(1)', width: 60, height: 32, left: 4, top: 4 },
{ selector: '.t-radio-button:nth-child(2)', width: 60, height: 32, left: 68, top: 4 },
{ selector: '.t-radio-button:nth-child(3)', width: 60, height: 32, left: 132, top: 4 },
];

// 根据选项索引获取对应按钮的尺寸数据
const getButtonSize = (optionIndex: number) => {
const buttonSelector = `.t-radio-button:nth-child(${optionIndex + 1})`;
return MOCK_SIZES.find((size) => size.selector === buttonSelector);
};

const mockRadioGroupSizes = () => mockElementSizes(MOCK_SIZES);

test('bg-block not rendered when value changes to empty', async () => {
const cleanup = mockRadioGroupSizes();

const TestComponent = () => {
const [value, setValue] = React.useState<string>('1');
return (
<>
<Radio.Group variant="primary-filled" theme="button" value={value} options={['0', '1', '2']} />
<button data-testid="clear-btn" onClick={() => setValue('')}>
Clear
</button>
</>
);
};

const { container, getByTestId } = render(<TestComponent />);

const bgBlock = container.querySelector('.t-radio-group__bg-block') as HTMLElement;
expect(bgBlock).toBeInTheDocument();

const expectedSize = getButtonSize(1);
expect(bgBlock.style.left).toBe(`${expectedSize.left}px`);
expect(bgBlock.style.top).toBe(`${expectedSize.top}px`);
expect(bgBlock.style.width).toBe(`${expectedSize.width}px`);
expect(bgBlock.style.height).toBe(`${expectedSize.height}px`);

await act(async () => {
fireEvent.click(getByTestId('clear-btn'));
});

expect(container.querySelector('.t-radio-group__bg-block')).not.toBeInTheDocument();

cleanup();
});

test('bg-block updates when options change dynamically', async () => {
const cleanup = mockRadioGroupSizes();

const TestComponent = () => {
const [value, setValue] = useState<string>('1');
const [options, setOptions] = useState<string[]>(['0']);

useEffect(() => {
const timer = setTimeout(() => {
setOptions(['0', '1', '2']);
}, 100);
return () => clearTimeout(timer);
}, []);

return (
<Radio.Group<string>
variant="primary-filled"
theme="button"
value={value}
options={options}
onChange={(val) => setValue(val)}
/>
);
};

const { container } = render(<TestComponent />);

expect(container.querySelector('.t-radio-group__bg-block')).not.toBeInTheDocument();

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});

const bgBlock = container.querySelector('.t-radio-group__bg-block') as HTMLElement;
expect(bgBlock).toBeInTheDocument();

const expectedSize = getButtonSize(1);
expect(bgBlock.style.left).toBe(`${expectedSize.left}px`);
expect(bgBlock.style.top).toBe(`${expectedSize.top}px`);
expect(bgBlock.style.width).toBe(`${expectedSize.width}px`);
expect(bgBlock.style.height).toBe(`${expectedSize.height}px`);

cleanup();
});

test('bg-block updates when component becomes visible from hidden', async () => {
const cleanup = mockRadioGroupSizes();

let observerCallback: Function | null = null;
mockIntersectionObserver(
{},
{
observe: (element: Element, callback: Function) => {
observerCallback = callback;
if (!element.closest('[hidden]')) {
callback([{ isIntersecting: true, target: element }]);
}
},
},
);

const TestComponent = () => {
const [activeNav, setActiveNav] = useState('nav1');
const [value, setValue] = useState('1');

const navList = [
{
id: 'nav1',
component: <div>First Nav Content</div>,
},
{
id: 'nav2',
component: (
<Radio.Group
variant="primary-filled"
theme="button"
value={value}
options={['0', '1', '2']}
onChange={(val) => setValue(val)}
/>
),
},
];

return (
<>
{navList.map((nav) => (
<div key={nav.id}>
<button data-testid={`nav-${nav.id}`} onClick={() => setActiveNav(nav.id)}>
{nav.id}
</button>
<div data-testid={`content-${nav.id}`} hidden={nav.id !== activeNav}>
{nav.component}
</div>
</div>
))}
</>
);
};

const { container, getByTestId } = render(<TestComponent />);

await act(async () => {
fireEvent.click(getByTestId('nav-nav2'));
});

if (observerCallback) {
await act(async () => {
const radioGroup = container.querySelector('.t-radio-group');
observerCallback([{ isIntersecting: true, target: radioGroup }]);
});
}

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});

const bgBlock = container.querySelector('.t-radio-group__bg-block') as HTMLElement;
expect(bgBlock).toBeInTheDocument();

const expectedSize = getButtonSize(1);
expect(bgBlock.style.left).toBe(`${expectedSize.left}px`);
expect(bgBlock.style.top).toBe(`${expectedSize.top}px`);
expect(bgBlock.style.width).toBe(`${expectedSize.width}px`);
expect(bgBlock.style.height).toBe(`${expectedSize.height}px`);

cleanup();
});
});
4 changes: 2 additions & 2 deletions packages/tdesign-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ spline: explain

### 🐞 Bug Fixes

- `RadioGroup`: 修复 NextJS 中,`variant="default-filled` 时,子组件含动态内容时导致无限循环的问题 @tingtingcheng6 ([#3921](https://github.com/Tencent/tdesign-react/pull/3921))
- `RadioGroup`: 修复 NextJS 中,`variant="default-filled` 时,子组件含动态内容时导致无限循环的问题 @tingtingcheng6 ([#4010](https://github.com/Tencent/tdesign-react/pull/4010))

## 🌈 1.15.10 `2025-12-12`

### 🐞 Bug Fixes

- `Drawer`: 修复回调事件错误缓存的问题 @uyarn ([#4008](https://github.com/Tencent/tdesign-react/pull/3921))
- `Drawer`: 修复回调事件错误缓存的问题 @uyarn ([#4008](https://github.com/Tencent/tdesign-react/pull/4008))

## 🌈 1.15.9 `2025-11-28`

Expand Down
Loading
Loading