Compare commits

...

13 Commits

Author SHA1 Message Date
kiner-tang(星河)
414238a2f7 Merge branch 'master' into fix-slider-tooltip 2026-01-19 22:18:56 +08:00
kiner-tang(星河)
1219564b2d Merge branch 'master' into fix-slider-tooltip 2026-01-18 18:49:18 +08:00
kiner-tang(星河)
2ed9896fd0 Merge branch 'master' into fix-slider-tooltip 2026-01-18 18:13:35 +08:00
kinertang
62d41646dc fix: improve slider's tooltip placement 2026-01-18 18:08:08 +08:00
kinertang
b17f8450c4 fix: improve slider's tooltip placement 2026-01-18 18:00:19 +08:00
kinertang
00a2e91472 fix: improve slider's tooltip placement 2026-01-18 15:32:45 +08:00
kiner-tang(星河)
8fe54617ed Merge branch 'master' into fix-slider-tooltip 2026-01-18 14:41:45 +08:00
kinertang
254691e1d9 fix: improve slider's tooltip placement 2026-01-18 14:36:54 +08:00
kinertang
4a3f5bece9 fix: improve slider's tooltip placement 2026-01-18 14:18:35 +08:00
kinertang
29f158c393 fix: improve slider's tooltip placement 2026-01-18 14:01:26 +08:00
kinertang
f1b508073b fix: improve slider's tooltip placement 2026-01-18 13:55:58 +08:00
kinertang
23f2061d73 fix: improve slider's tooltip placement 2026-01-18 13:30:36 +08:00
kinertang
727a547d59 fix: improve slider's tooltip placement 2026-01-18 13:20:48 +08:00
2 changed files with 683 additions and 7 deletions

View File

@@ -1,10 +1,10 @@
import * as React from 'react';
import { useRef } from 'react';
import { useRef, useState } from 'react';
import raf from '@rc-component/util/lib/raf';
import { composeRef } from '@rc-component/util/lib/ref';
import type { SliderRef } from '@rc-component/slider/lib/Slider';
import type { TooltipProps } from '../tooltip';
import type { TooltipPlacement, TooltipProps } from '../tooltip';
import Tooltip from '../tooltip';
export type SliderTooltipProps = TooltipProps & {
@@ -13,8 +13,9 @@ export type SliderTooltipProps = TooltipProps & {
};
const SliderTooltip = React.forwardRef<SliderRef, SliderTooltipProps>((props, ref) => {
const { open, draggingDelete, value } = props;
const { open, draggingDelete, value, placement, getPopupContainer } = props;
const innerRef = useRef<any>(null);
const [adjustedPlacement, setAdjustedPlacement] = useState<TooltipPlacement | undefined>(placement);
const mergedOpen = open && !draggingDelete;
@@ -32,17 +33,123 @@ const SliderTooltip = React.forwardRef<SliderRef, SliderTooltipProps>((props, re
});
}
// Check if tooltip overflows container and adjust placement
const checkAndAdjustPlacement = React.useCallback(() => {
const tooltipRef = innerRef.current;
const popupElement = tooltipRef.popupElement;
const triggerElement = tooltipRef.nativeElement;
if (!popupElement || !triggerElement) {
return;
}
// Get container
const container = getPopupContainer
? getPopupContainer(triggerElement)
: document.body;
// Get container boundaries
const containerRect = container === document.body
? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
: container.getBoundingClientRect();
// Get tooltip position
const popupRect = popupElement.getBoundingClientRect();
// Get original placement
const originalPlacement = placement || 'top';
// Check if tooltip overflows container
const isOverflowLeft = popupRect.left < containerRect.left;
const isOverflowRight = popupRect.right > containerRect.right;
const isOverflowTop = popupRect.top < containerRect.top;
const isOverflowBottom = popupRect.bottom > containerRect.bottom;
// If original placement is top or bottom (horizontal mode), check horizontal overflow
if (originalPlacement === 'top' || originalPlacement === 'bottom') {
if (isOverflowLeft) {
// Left overflow, change to right placement
setAdjustedPlacement('right');
} else if (isOverflowRight) {
// Right overflow, change to left placement
setAdjustedPlacement('left');
} else {
// No horizontal overflow, restore original placement
setAdjustedPlacement(placement);
}
}
// If original placement is left or right (vertical mode), check vertical overflow
else if (originalPlacement === 'left' || originalPlacement === 'right') {
if (isOverflowTop) {
// Top overflow, change to bottom placement
setAdjustedPlacement('bottom');
} else if (isOverflowBottom) {
// Bottom overflow, change to top placement
setAdjustedPlacement('top');
} else {
// No vertical overflow, restore original placement
setAdjustedPlacement(placement);
}
}
}, [mergedOpen, placement, getPopupContainer]);
const adjustPlacementRef = useRef<number | null>(null);
const cancelAdjustPlacement = React.useCallback(() => {
if (adjustPlacementRef.current !== null) {
raf.cancel(adjustPlacementRef.current);
adjustPlacementRef.current = null;
}
}, []);
const scheduleAdjustPlacement = React.useCallback(() => {
cancelAdjustPlacement();
adjustPlacementRef.current = raf(() => {
// Use raf again to ensure tooltip is positioned
adjustPlacementRef.current = raf(() => {
checkAndAdjustPlacement();
adjustPlacementRef.current = null;
});
});
}, [cancelAdjustPlacement, checkAndAdjustPlacement]);
React.useEffect(() => {
if (mergedOpen) {
keepAlign();
scheduleAdjustPlacement();
return () => {
cancelKeepAlign();
cancelAdjustPlacement();
};
} else {
cancelKeepAlign();
cancelAdjustPlacement();
// Reset placement when closed
setAdjustedPlacement(placement);
}
}, [mergedOpen, props.title, value, placement, scheduleAdjustPlacement, cancelAdjustPlacement]);
// Listen to tooltip position changes
React.useEffect(() => {
if (!mergedOpen) {
return;
}
return cancelKeepAlign;
}, [mergedOpen, props.title, value]);
const handleResize = () => {
scheduleAdjustPlacement();
};
return <Tooltip ref={composeRef(innerRef, ref)} {...props} open={mergedOpen} />;
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleResize, true);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleResize, true);
};
}, [mergedOpen, scheduleAdjustPlacement]);
return <Tooltip ref={composeRef(innerRef, ref)} {...props} open={mergedOpen} placement={adjustedPlacement} />;
});
if (process.env.NODE_ENV !== 'production') {

View File

@@ -17,9 +17,61 @@ jest.mock('../../tooltip', () => {
const ReactReal: typeof React = jest.requireActual('react');
const Tooltip = jest.requireActual('../../tooltip');
const TooltipComponent = Tooltip.default;
// Create mock default element factory inside mock
const createMockElement = (): HTMLElement => ({
getBoundingClientRect: jest.fn(() => ({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: jest.fn(),
})),
} as any);
const createMockDivElement = (): HTMLDivElement => ({
getBoundingClientRect: jest.fn(() => ({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: jest.fn(),
})),
} as any);
return ReactReal.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
(global as any).tooltipProps = props;
return <TooltipComponent {...props} ref={ref} />;
const internalRef = ReactReal.useRef<TooltipRef>(null);
ReactReal.useImperativeHandle(ref, () => {
const mockRef = (global as any).mockTooltipRef;
if (mockRef) {
return {
forceAlign: mockRef.forceAlign || jest.fn(),
get nativeElement() {
return mockRef.nativeElement || createMockElement();
},
get popupElement() {
return mockRef.popupElement || createMockDivElement();
},
} as TooltipRef;
}
return internalRef.current || {
forceAlign: jest.fn(),
nativeElement: createMockElement(),
popupElement: createMockDivElement(),
} as TooltipRef;
});
return <TooltipComponent {...props} ref={internalRef} />;
});
});
@@ -224,4 +276,521 @@ describe('Slider', () => {
expect(container.querySelector<HTMLDivElement>('.ant-slider-vertical')).not.toBeNull();
});
});
// ============================= auto adjust placement =============================
describe('auto adjust placement', () => {
let mockPopupElement: HTMLElement;
let mockTriggerElement: HTMLElement;
let mockContainer: HTMLElement;
beforeEach(() => {
// Create mock elements
mockPopupElement = document.createElement('div');
mockTriggerElement = document.createElement('div');
mockContainer = document.createElement('div');
document.body.appendChild(mockContainer);
// Mock getBoundingClientRect
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 100,
right: 200,
bottom: 200,
width: 100,
height: 100,
x: 100,
y: 100,
toJSON: jest.fn(),
}));
mockTriggerElement.getBoundingClientRect = jest.fn(() => ({
left: 150,
top: 150,
right: 150,
bottom: 150,
width: 0,
height: 0,
x: 150,
y: 150,
toJSON: jest.fn(),
}));
mockContainer.getBoundingClientRect = jest.fn(() => ({
left: 0,
top: 0,
right: 1000,
bottom: 1000,
width: 1000,
height: 1000,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
// Mock window.innerWidth and innerHeight
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1000,
});
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: 1000,
});
// Set up mock tooltip ref with getters to allow dynamic updates
(global as any).mockTooltipRef = {
forceAlign: jest.fn(),
get nativeElement() {
return (global as any).currentMockTriggerElement || mockTriggerElement;
},
get popupElement() {
return (global as any).currentMockPopupElement || mockPopupElement;
},
};
(global as any).currentMockTriggerElement = mockTriggerElement;
(global as any).currentMockPopupElement = mockPopupElement;
});
afterEach(() => {
if (document.body.contains(mockContainer)) {
document.body.removeChild(mockContainer);
}
(global as any).mockTooltipRef = null;
jest.clearAllMocks();
});
it('should not adjust placement when tooltip is closed', async () => {
const { container } = render(<Slider defaultValue={30} tooltip={{ open: false }} />);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// When open is false, mergedOpen is false, should not execute detection logic
// Since tooltip is not open, cannot get tooltipProps
expect(container.querySelector('.ant-slider-handle')).toBeTruthy();
});
it('should handle when tooltipRef is null gracefully', async () => {
const ref = React.createRef<any>();
render(<SliderTooltip title="30" open ref={ref} />);
await waitFakeTimer();
// Component should render normally without crashing
expect(ref.current).toBeDefined();
});
it('should use document.body as container when getPopupContainer is not provided', async () => {
const { container } = render(<Slider defaultValue={30} tooltip={{ open: true }} />);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should use document.body as container
expect(tooltipProps().placement).toBeDefined();
});
it('should use custom container when getPopupContainer is provided', async () => {
const customContainer = document.createElement('div');
document.body.appendChild(customContainer);
const getPopupContainer = jest.fn(() => customContainer);
const { container } = render(
<Slider defaultValue={30} tooltip={{ open: true, getPopupContainer }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
expect(getPopupContainer).toHaveBeenCalled();
document.body.removeChild(customContainer);
});
describe('horizontal mode (placement: top/bottom)', () => {
it('should adjust to right when tooltip overflows left with placement top', async () => {
// Mock left overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: -50,
top: 100,
right: 50,
bottom: 200,
width: 100,
height: 100,
x: -50,
y: 100,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider defaultValue={0} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to right
expect(tooltipProps().placement).toBe('right');
});
it('should adjust to left when tooltip overflows right with placement top', async () => {
// Mock right overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 950,
top: 100,
right: 1050,
bottom: 200,
width: 100,
height: 100,
x: 950,
y: 100,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider defaultValue={100} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to left
expect(tooltipProps().placement).toBe('left');
});
it('should keep original placement when no overflow with placement top', async () => {
// Mock no overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 100,
right: 200,
bottom: 200,
width: 100,
height: 100,
x: 100,
y: 100,
toJSON: jest.fn(),
}));
const { container } = render(
<Slider defaultValue={50} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should keep original placement
expect(tooltipProps().placement).toBe('top');
});
it('should adjust to right when tooltip overflows left with placement bottom', async () => {
// Mock left overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: -50,
top: 100,
right: 50,
bottom: 200,
width: 100,
height: 100,
x: -50,
y: 100,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider defaultValue={0} tooltip={{ open: true, placement: 'bottom' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to right
expect(tooltipProps().placement).toBe('right');
});
it('should adjust to left when tooltip overflows right with placement bottom', async () => {
// Mock right overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 950,
top: 100,
right: 1050,
bottom: 200,
width: 100,
height: 100,
x: 950,
y: 100,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider defaultValue={100} tooltip={{ open: true, placement: 'bottom' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to left
expect(tooltipProps().placement).toBe('left');
});
it('should keep original placement when no overflow with placement bottom', async () => {
// Mock no overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 100,
right: 200,
bottom: 200,
width: 100,
height: 100,
x: 100,
y: 100,
toJSON: jest.fn(),
}));
const { container } = render(
<Slider defaultValue={50} tooltip={{ open: true, placement: 'bottom' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should keep original placement
expect(tooltipProps().placement).toBe('bottom');
});
});
describe('vertical mode (placement: left/right)', () => {
it('should adjust to bottom when tooltip overflows top with placement left', async () => {
// Mock top overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: -50,
right: 200,
bottom: 50,
width: 100,
height: 100,
x: 100,
y: -50,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider vertical defaultValue={100} tooltip={{ open: true, placement: 'left' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to bottom
expect(tooltipProps().placement).toBe('bottom');
});
it('should adjust to top when tooltip overflows bottom with placement left', async () => {
// Mock bottom overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 950,
right: 200,
bottom: 1050,
width: 100,
height: 100,
x: 100,
y: 950,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider vertical defaultValue={0} tooltip={{ open: true, placement: 'left' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to top
expect(tooltipProps().placement).toBe('top');
});
it('should keep original placement when no overflow with placement left', async () => {
// Mock no overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 100,
right: 200,
bottom: 200,
width: 100,
height: 100,
x: 100,
y: 100,
toJSON: jest.fn(),
}));
const { container } = render(
<Slider vertical defaultValue={50} tooltip={{ open: true, placement: 'left' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should keep original placement
expect(tooltipProps().placement).toBe('left');
});
it('should adjust to bottom when tooltip overflows top with placement right', async () => {
// Mock top overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: -50,
right: 200,
bottom: 50,
width: 100,
height: 100,
x: 100,
y: -50,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider vertical defaultValue={100} tooltip={{ open: true, placement: 'right' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to bottom
expect(tooltipProps().placement).toBe('bottom');
});
it('should adjust to top when tooltip overflows bottom with placement right', async () => {
// Mock bottom overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 950,
right: 200,
bottom: 1050,
width: 100,
height: 100,
x: 100,
y: 950,
toJSON: jest.fn(),
}));
(global as any).currentMockPopupElement = mockPopupElement;
const { container } = render(
<Slider vertical defaultValue={0} tooltip={{ open: true, placement: 'right' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
await waitFakeTimer(); // Wait for double raf
// Should adjust to top
expect(tooltipProps().placement).toBe('top');
});
it('should keep original placement when no overflow with placement right', async () => {
// Mock no overflow
mockPopupElement.getBoundingClientRect = jest.fn(() => ({
left: 100,
top: 100,
right: 200,
bottom: 200,
width: 100,
height: 100,
x: 100,
y: 100,
toJSON: jest.fn(),
}));
const { container } = render(
<Slider vertical defaultValue={50} tooltip={{ open: true, placement: 'right' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should keep original placement
expect(tooltipProps().placement).toBe('right');
});
});
it('should reset placement when tooltip is closed', async () => {
const { container, rerender } = render(
<Slider defaultValue={30} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Close tooltip
rerender(<Slider defaultValue={30} tooltip={{ open: false, placement: 'top' }} />);
await waitFakeTimer();
// Placement should be reset
expect(tooltipProps().placement).toBe('top');
});
it('should adjust placement when value changes', async () => {
const { container, rerender } = render(
<Slider value={0} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Change value
rerender(<Slider value={100} tooltip={{ open: true, placement: 'top' }} />);
await waitFakeTimer();
// Should re-detect and adjust
expect(tooltipProps().placement).toBeDefined();
});
it('should adjust placement when title changes', async () => {
const { container, rerender } = render(
<Slider defaultValue={30} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Change title (via formatter)
rerender(
<Slider
defaultValue={30}
tooltip={{ open: true, placement: 'top', formatter: (val) => `New ${val}` }}
/>,
);
await waitFakeTimer();
// Should re-detect and adjust
expect(tooltipProps().placement).toBeDefined();
});
it('should handle resize event', async () => {
const { container } = render(
<Slider defaultValue={30} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Trigger resize event
act(() => {
window.dispatchEvent(new Event('resize'));
jest.runAllTimers();
});
await waitFakeTimer();
// Should re-detect and adjust
expect(tooltipProps().placement).toBeDefined();
});
it('should handle scroll event', async () => {
const { container } = render(
<Slider defaultValue={30} tooltip={{ open: true, placement: 'top' }} />,
);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Trigger scroll event
act(() => {
window.dispatchEvent(new Event('scroll'));
jest.runAllTimers();
});
await waitFakeTimer();
// Should re-detect and adjust
expect(tooltipProps().placement).toBeDefined();
});
it('should use default placement top when placement is undefined', async () => {
const { container } = render(<Slider defaultValue={30} tooltip={{ open: true }} />);
fireEvent.mouseEnter(container.querySelector('.ant-slider-handle')!);
await waitFakeTimer();
// Should use default top
expect(tooltipProps().placement).toBeDefined();
});
it('should handle draggingDelete prop', async () => {
render(<SliderTooltip title="30" open draggingDelete placement="top" />);
await waitFakeTimer();
// When draggingDelete is true, tooltip should not be displayed
expect(tooltipProps().open).toBe(false);
});
});
});