diff --git a/src/PickerInput/Popup/index.tsx b/src/PickerInput/Popup/index.tsx index 5482ada9d..ae6bbc4b0 100644 --- a/src/PickerInput/Popup/index.tsx +++ b/src/PickerInput/Popup/index.tsx @@ -84,7 +84,7 @@ export default function Popup(props: PopupProps(props: PopupProps( onKeyDown?.(event, preventDefault); }; + // ======================= popup align ======================= + const [alignedPlacement, setAlignedPlacement] = React.useState(); + // ======================= Context ======================== const context = React.useMemo( () => ({ @@ -674,8 +677,18 @@ function RangePicker( generateConfig, button: components.button, input: components.input, + alignedPlacement, + setAlignedPlacement, }), - [prefixCls, locale, generateConfig, components.button, components.input], + [ + prefixCls, + locale, + generateConfig, + components.button, + components.input, + alignedPlacement, + setAlignedPlacement, + ], ); // ======================== Effect ======================== @@ -739,6 +752,7 @@ function RangePicker( // Visible visible={mergedOpen} onClose={onPopupClose} + alignedPlacement={alignedPlacement} // Range range > diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 70894a011..8c98b303d 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -9,6 +9,7 @@ import useRootProps from './hooks/useRootProps'; import Icon, { ClearIcon } from './Icon'; import Input, { type InputRef } from './Input'; import { getoffsetUnit, getRealPlacement } from '../../utils/uiUtil'; +import { getWin } from './util'; export type SelectorIdType = | string @@ -120,7 +121,7 @@ function RangeSelector( const rtl = direction === 'rtl'; // ======================== Prefix ======================== - const { prefixCls } = React.useContext(PickerContext); + const { prefixCls, alignedPlacement } = React.useContext(PickerContext); // ========================== Id ========================== const ids = React.useMemo(() => { @@ -173,7 +174,7 @@ function RangeSelector( }); // ====================== ActiveBar ======================= - const realPlacement = getRealPlacement(placement, rtl); + const realPlacement = getRealPlacement(alignedPlacement || placement, rtl); const offsetUnit = getoffsetUnit(realPlacement, rtl); const placementRight = realPlacement?.toLowerCase().endsWith('right'); const [activeBarStyle, setActiveBarStyle] = React.useState({ @@ -184,13 +185,26 @@ function RangeSelector( const syncActiveOffset = useEvent(() => { const input = getInput(activeIndex); if (input) { - const { offsetWidth, offsetLeft, offsetParent } = input.nativeElement; - const parentWidth = (offsetParent as HTMLElement)?.offsetWidth || 0; - const activeOffset = placementRight ? (parentWidth - offsetWidth - offsetLeft) : offsetLeft; + const { offsetParent } = input.nativeElement; + // offsetLeft is an integer, which will cause incorrect reulst. + const { x = 0, width: inputWidth = 0 } = input.nativeElement.getBoundingClientRect() || {}; + const { x: pX = 0, width: parentWidth = 0 } = offsetParent?.getBoundingClientRect() || {}; + const parentStyles = + offsetParent && getWin(offsetParent as HTMLElement).getComputedStyle(offsetParent); + const parentBorderRightWidth = Number( + (placementRight ? parentStyles?.borderRightWidth : parentStyles?.borderLeftWidth)?.replace( + 'px', + '', + ) || 0, + ); + const offsetLeft = x - pX; + + const activeOffset = placementRight ? parentWidth - inputWidth - offsetLeft : offsetLeft; setActiveBarStyle(({ insetInlineStart, insetInlineEnd, ...rest }) => ({ ...rest, - width: offsetWidth, - [offsetUnit]: activeOffset + width: inputWidth, + // parent will have border while focus, so need to cut `parentBorderWidth` on opposite side. + [offsetUnit]: activeOffset - parentBorderRightWidth, })); onActiveOffset(activeOffset); } @@ -198,7 +212,7 @@ function RangeSelector( React.useEffect(() => { syncActiveOffset(); - }, [activeIndex]); + }, [activeIndex, alignedPlacement]); // ======================== Clear ========================= const showClear = clearIcon && ((value[0] && !disabled[0]) || (value[1] && !disabled[1])); diff --git a/src/PickerInput/Selector/util.ts b/src/PickerInput/Selector/util.ts index 245b3dcc8..4eb7a6af8 100644 --- a/src/PickerInput/Selector/util.ts +++ b/src/PickerInput/Selector/util.ts @@ -12,4 +12,8 @@ export function getMaskRange(key: string): [startVal: number, endVal: number, de }; return PresetRange[key]; -} \ No newline at end of file +} + +export function getWin(ele: HTMLElement) { + return ele.ownerDocument.defaultView; +} diff --git a/src/PickerInput/context.tsx b/src/PickerInput/context.tsx index fcbc581b5..f7fb32b5c 100644 --- a/src/PickerInput/context.tsx +++ b/src/PickerInput/context.tsx @@ -9,7 +9,9 @@ export interface PickerContextProps { /** Customize button component */ button?: Components['button']; input?: Components['input']; - + /** trigger will change placement while aligining */ + alignedPlacement?: string; + setAlignedPlacement?: React.Dispatch>; } const PickerContext = React.createContext(null!); diff --git a/src/PickerTrigger/index.tsx b/src/PickerTrigger/index.tsx index a46011345..e13e48cae 100644 --- a/src/PickerTrigger/index.tsx +++ b/src/PickerTrigger/index.tsx @@ -54,7 +54,7 @@ export type PickerTriggerProps = { placement?: string; builtinPlacements?: BuildInPlacements; direction?: 'ltr' | 'rtl'; - + alignedPlacement?: string; // Visible visible: boolean; onClose: () => void; @@ -72,15 +72,15 @@ function PickerTrigger({ placement, builtinPlacements = BUILT_IN_PLACEMENTS, direction, - + alignedPlacement, // Visible visible, onClose, }: PickerTriggerProps) { - const { prefixCls } = React.useContext(PickerContext); + const { prefixCls, setAlignedPlacement } = React.useContext(PickerContext); const dropdownPrefixCls = `${prefixCls}-dropdown`; - const realPlacement = getRealPlacement(placement, direction === 'rtl'); + const realPlacement = getRealPlacement(alignedPlacement || placement, direction === 'rtl'); return ( { + if (!setAlignedPlacement) return; + + const matchedKey = Object.keys(BUILT_IN_PLACEMENTS).find( + (key) => + BUILT_IN_PLACEMENTS[key].points[0] === align.points[0] && + BUILT_IN_PLACEMENTS[key].points[1] === align.points[1], + ); + + if (matchedKey) { + setAlignedPlacement(matchedKey); + } + }} onPopupVisibleChange={(nextVisible) => { if (!nextVisible) { onClose(); diff --git a/tests/new-range.spec.tsx b/tests/new-range.spec.tsx index 97427cf69..32961cea6 100644 --- a/tests/new-range.spec.tsx +++ b/tests/new-range.spec.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render } from '@testing-library/react'; import dayjs, { type Dayjs } from 'dayjs'; import 'dayjs/locale/ar'; -import { spyElementPrototype } from 'rc-util/lib/test/domHook'; +import { spyElementPrototype, spyElementPrototypes } from 'rc-util/lib/test/domHook'; import { resetWarned } from 'rc-util/lib/warning'; import React from 'react'; import type { RangePickerProps } from '../src'; @@ -25,6 +25,7 @@ jest.mock('rc-util/lib/Dom/isVisible', () => { }); describe('NewPicker.Range', () => { + let rangeRect = { x: 0, y: 0, width: 0, height: 0 }; beforeEach(() => { resetWarned(); jest.useFakeTimers().setSystemTime(getDay('1990-09-03 00:00:00').valueOf()); @@ -36,6 +37,16 @@ describe('NewPicker.Range', () => { return childList.indexOf(this) * 30; }, }); + + // =============== handle trigger align =============== + rangeRect = { + x: 0, + y: 0, + width: 200, + height: 100, + }; + + document.documentElement.scrollLeft = 0; }); afterEach(() => { @@ -43,6 +54,83 @@ describe('NewPicker.Range', () => { jest.useRealTimers(); }); + beforeAll(() => { + jest.spyOn(document.documentElement, 'scrollWidth', 'get').mockReturnValue(1000); + + // Popup size + spyElementPrototypes(HTMLDivElement, { + getBoundingClientRect() { + if (this.className.includes('rc-picker-dropdown')) { + return { + x: 0, + y: 0, + width: 300, + height: 100, + }; + } + if (this.className.includes('rc-picker-range')) { + return rangeRect; + } + if (this.className.includes('rc-picker')) { + return rangeRect; + } + }, + offsetWidth: { + get() { + if (this.className.includes('rc-picker-range-wrapper')) { + return rangeRect.width; + } + if (this.className.includes('rc-picker-range-arrow')) { + return 10; + } + if (this.className.includes('rc-picker-input')) { + return 100; + } + if (this.className.includes('rc-picker-dropdown')) { + return 300; + } + }, + }, + offsetLeft: { + get() { + if (this.className.includes('rc-picker-input')) { + return 0; + } + }, + }, + }); + spyElementPrototypes(HTMLElement, { + // Viewport size + clientWidth: { + get: () => 400, + }, + clientHeight: { + get: () => 400, + }, + offsetParent: { + get: () => document.body, + }, + offsetWidth: { + get() { + if (this.tagName === 'BODY') { + return 200; + } + }, + }, + // offsetParent + getBoundingClientRect() { + if (this.tagName === 'BODY') { + return { + x: 0, + y: 0, + width: 200, + height: 200, + }; + } + }, + }); + }); + describe('PickerValue', () => { it('defaultPickerValue should reset every time when opened', () => { const { container } = render( @@ -1098,7 +1186,7 @@ describe('NewPicker.Range', () => { it('pass tabIndex', () => { const { container } = render(
- +
, ); @@ -1338,4 +1426,87 @@ describe('NewPicker.Range', () => { } expect(existed).toBeTruthy(); }); + + describe('pupop aligned position', () => { + it('the arrow should be set to `inset-inline-start` when the popup is aligned to `bottomLeft`.', async () => { + render(); + + const oriGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = (ele: HTMLElement) => { + const retObj = oriGetComputedStyle(ele); + + retObj.borderRightWidth = '4px'; + retObj.borderLeftWidth = '2px'; + return retObj; + }; + + await act(async () => { + jest.runAllTimers(); + + await Promise.resolve(); + }); + + expect(document.querySelector('.rc-picker-range-arrow')).toHaveStyle({ + 'inset-inline-start': '0', + }); + expect(document.querySelector('.rc-picker-active-bar')).toHaveStyle({ + 'inset-inline-start': '-2px', + }); + window.getComputedStyle = oriGetComputedStyle; + }); + + it('the arrow should be set to `inset-inline-end` when the popup is aligned to `bottomRight`.', async () => { + const mock = spyElementPrototypes(HTMLDivElement, { + getBoundingClientRect() { + if (this.className.includes('rc-picker-dropdown')) { + return { + x: 0, + y: 0, + width: 300, + height: 100, + }; + } + if (this.className.includes('rc-picker-range')) { + return { + ...rangeRect, + x: 300, + }; + } + if (this.className.includes('rc-picker-input')) { + return { + ...rangeRect, + width: 100, + }; + } + }, + }); + + const oriGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = (ele: HTMLElement) => { + const retObj = oriGetComputedStyle(ele); + + retObj.borderRightWidth = '4px'; + retObj.borderLeftWidth = '2px'; + return retObj; + }; + + render(); + + await act(async () => { + jest.runAllTimers(); + + await Promise.resolve(); + }); + expect(document.querySelector('.rc-picker-range-arrow')).toHaveStyle({ + 'inset-inline-end': '100px', + }); + + expect(document.querySelector('.rc-picker-active-bar')).toHaveStyle({ + 'inset-inline-end': '96px', + }); + + mock.mockRestore(); + window.getComputedStyle = oriGetComputedStyle; + }); + }); }); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 87cef745b..fcf93e867 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -1803,6 +1803,36 @@ describe('Picker.Range', () => { } }, }, + getBoundingClientRect() { + if (this.className.includes('rc-picker-dropdown')) { + return { + x: 0, + y: 0, + width: 300, + }; + } + if (this.className.includes('rc-picker-range')) { + return { + x: 0, + y: 0, + width: 200, + }; + } + if (this.className.includes('rc-picker-input')) { + return { + x: 100, + y: 0, + width: 100, + }; + } + if (this.className.includes('rc-picker')) { + return { + x: 0, + y: 0, + width: 200, + }; + } + }, }); const { container } = render(