diff --git a/assets/index.less b/assets/index.less index a23ce0b82..d5db8413b 100644 --- a/assets/index.less +++ b/assets/index.less @@ -37,6 +37,12 @@ &-invalid { box-shadow: 0 0 2px red; } + + &-panels { + display: flex; + flex-wrap: nowrap; + } + &-panel { display: inline-block; vertical-align: top; diff --git a/src/PickerInput/Popup/index.tsx b/src/PickerInput/Popup/index.tsx index 5482ada9d..4b6c26fea 100644 --- a/src/PickerInput/Popup/index.tsx +++ b/src/PickerInput/Popup/index.tsx @@ -8,7 +8,6 @@ import type { ValueDate, } from '../../interface'; import { toArray } from '../../utils/miscUtil'; -import { getRealPlacement, getoffsetUnit } from '../../utils/uiUtil'; import PickerContext from '../context'; import Footer, { type FooterProps } from './Footer'; import PopupPanel, { type PopupPanelProps } from './PopupPanel'; @@ -32,8 +31,7 @@ export interface PopupProps void; // Range - activeOffset?: number; - placement?: string; + activeInfo?: [activeInputLeft: number, activeInputRight: number, selectorWidth: number]; // Direction direction?: 'ltr' | 'rtl'; @@ -59,8 +57,7 @@ export default function Popup(props: PopupProps(props: PopupProps(0); const [containerOffset, setContainerOffset] = React.useState(0); + const [arrowOffset, setArrowOffset] = React.useState(0); const onResize: ResizeObserverProps['onResize'] = (info) => { - if (info.offsetWidth) { - setContainerWidth(info.offsetWidth); + if (info.width) { + setContainerWidth(info.width); } }; + const [activeInputLeft, activeInputRight, selectorWidth] = activeInfo; + React.useEffect(() => { // `activeOffset` is always align with the active input element // So we need only check container contains the `activeOffset` - if (range) { + if (range && wrapperRef.current) { // Offset in case container has border radius const arrowWidth = arrowRef.current?.offsetWidth || 0; - const maxOffset = containerWidth - arrowWidth; - if (activeOffset <= maxOffset) { - setContainerOffset(0); + // Arrow Offset + const wrapperRect = wrapperRef.current.getBoundingClientRect(); + const nextArrowOffset = rtl + ? activeInputRight - arrowWidth + : activeInputLeft - wrapperRect.left; + setArrowOffset(nextArrowOffset); + + // Container Offset + if (containerWidth < selectorWidth) { + const offset = rtl + ? wrapperRect.right - (activeInputRight - arrowWidth + containerWidth) + : activeInputLeft + arrowWidth - wrapperRect.left - containerWidth; + + const safeOffset = Math.max(0, offset); + setContainerOffset(safeOffset); } else { - setContainerOffset(activeOffset + arrowWidth - containerWidth); + setContainerOffset(0); } } - }, [containerWidth, activeOffset, range]); + }, [rtl, containerWidth, activeInputLeft, activeInputRight, selectorWidth, range]); // ======================== Custom ======================== function filterEmpty(list: T[]) { @@ -213,19 +225,13 @@ export default function Popup(props: PopupProps -
+
{/* Watch for container size */} {renderNode} diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 5c01e303d..81800fa08 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -160,7 +160,6 @@ function RangePicker( prefixCls, styles, classNames, - placement, // Value defaultValue, @@ -473,7 +472,10 @@ function RangePicker( // == Panels == // ======================================================== // Save the offset with active bar position - const [activeOffset, setActiveOffset] = React.useState(0); + // const [activeOffset, setActiveOffset] = React.useState(0); + const [activeInfo, setActiveInfo] = React.useState< + [activeInputLeft: number, activeInputRight: number, selectorWidth: number] + >([0, 0, 0]); // ======================= Presets ======================== const presetList = usePresets(presets, ranges); @@ -574,8 +576,7 @@ function RangePicker( // Range range multiplePanel={multiplePanel} - activeOffset={activeOffset} - placement={placement} + activeInfo={activeInfo} // Disabled disabledDate={mergedDisabledDate} // Focus @@ -796,7 +797,7 @@ function RangePicker( invalid={submitInvalidates} onInvalid={onSelectorInvalid} // Offset - onActiveOffset={setActiveOffset} + onActiveInfo={setActiveInfo} /> diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 70894a011..36328733d 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -8,7 +8,6 @@ import useInputProps from './hooks/useInputProps'; import useRootProps from './hooks/useRootProps'; import Icon, { ClearIcon } from './Icon'; import Input, { type InputRef } from './Input'; -import { getoffsetUnit, getRealPlacement } from '../../utils/uiUtil'; export type SelectorIdType = | string @@ -42,7 +41,9 @@ export interface RangeSelectorProps extends SelectorProps void; + onActiveInfo: ( + info: [activeInputLeft: number, activeInputRight: number, selectorWidth: number], + ) => void; } function RangeSelector( @@ -102,7 +103,7 @@ function RangeSelector( onOpenChange, // Offset - onActiveOffset, + onActiveInfo, placement, // Native @@ -173,9 +174,6 @@ function RangeSelector( }); // ====================== ActiveBar ======================= - const realPlacement = getRealPlacement(placement, rtl); - const offsetUnit = getoffsetUnit(realPlacement, rtl); - const placementRight = realPlacement?.toLowerCase().endsWith('right'); const [activeBarStyle, setActiveBarStyle] = React.useState({ position: 'absolute', width: 0, @@ -184,15 +182,16 @@ 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; - setActiveBarStyle(({ insetInlineStart, insetInlineEnd, ...rest }) => ({ - ...rest, - width: offsetWidth, - [offsetUnit]: activeOffset + const inputRect = input.nativeElement.getBoundingClientRect(); + const parentRect = rootRef.current.getBoundingClientRect(); + + const rectOffset = inputRect.left - parentRect.left; + setActiveBarStyle((ori) => ({ + ...ori, + width: inputRect.width, + left: rectOffset, })); - onActiveOffset(activeOffset); + onActiveInfo([inputRect.left, inputRect.right, parentRect.width]); } }); diff --git a/src/utils/uiUtil.ts b/src/utils/uiUtil.ts index 00f92d46d..5eb0c3445 100644 --- a/src/utils/uiUtil.ts +++ b/src/utils/uiUtil.ts @@ -1,206 +1,7 @@ -import isVisible from 'rc-util/lib/Dom/isVisible'; -import KeyCode from 'rc-util/lib/KeyCode'; -import raf from 'rc-util/lib/raf'; -import type { CustomFormat, PickerMode } from '../interface'; - -const scrollIds = new Map(); - -/** Trigger when element is visible in view */ -export function waitElementReady(element: HTMLElement, callback: () => void): () => void { - let id: number; - - function tryOrNextFrame() { - if (isVisible(element)) { - callback(); - } else { - id = raf(() => { - tryOrNextFrame(); - }); - } - } - - tryOrNextFrame(); - - return () => { - raf.cancel(id); - }; -} - -/* eslint-disable no-param-reassign */ -export function scrollTo(element: HTMLElement, to: number, duration: number) { - if (scrollIds.get(element)) { - cancelAnimationFrame(scrollIds.get(element)!); - } - - // jump to target if duration zero - if (duration <= 0) { - scrollIds.set( - element, - requestAnimationFrame(() => { - element.scrollTop = to; - }), - ); - - return; - } - const difference = to - element.scrollTop; - const perTick = (difference / duration) * 10; - - scrollIds.set( - element, - requestAnimationFrame(() => { - element.scrollTop += perTick; - if (element.scrollTop !== to) { - scrollTo(element, to, duration - 10); - } - }), - ); -} -/* eslint-enable */ - -export type KeyboardConfig = { - onLeftRight?: ((diff: number) => void) | null; - onCtrlLeftRight?: ((diff: number) => void) | null; - onUpDown?: ((diff: number) => void) | null; - onPageUpDown?: ((diff: number) => void) | null; - onEnter?: (() => void) | null; -}; -export function createKeyDownHandler( - event: React.KeyboardEvent, - { onLeftRight, onCtrlLeftRight, onUpDown, onPageUpDown, onEnter }: KeyboardConfig, -): boolean { - const { which, ctrlKey, metaKey } = event; - - switch (which) { - case KeyCode.LEFT: - if (ctrlKey || metaKey) { - if (onCtrlLeftRight) { - onCtrlLeftRight(-1); - return true; - } - } else if (onLeftRight) { - onLeftRight(-1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.RIGHT: - if (ctrlKey || metaKey) { - if (onCtrlLeftRight) { - onCtrlLeftRight(1); - return true; - } - } else if (onLeftRight) { - onLeftRight(1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.UP: - if (onUpDown) { - onUpDown(-1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.DOWN: - if (onUpDown) { - onUpDown(1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.PAGE_UP: - if (onPageUpDown) { - onPageUpDown(-1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.PAGE_DOWN: - if (onPageUpDown) { - onPageUpDown(1); - return true; - } - /* istanbul ignore next */ - break; - - case KeyCode.ENTER: - if (onEnter) { - onEnter(); - return true; - } - /* istanbul ignore next */ - break; - } - - return false; -} - -// ===================== Format ===================== -export function getDefaultFormat( - format: string | CustomFormat | (string | CustomFormat)[] | undefined, - picker: PickerMode | undefined, - showTime: boolean | object | undefined, - use12Hours: boolean | undefined, -) { - let mergedFormat = format; - if (!mergedFormat) { - switch (picker) { - case 'time': - mergedFormat = use12Hours ? 'hh:mm:ss a' : 'HH:mm:ss'; - break; - - case 'week': - mergedFormat = 'gggg-wo'; - break; - - case 'month': - mergedFormat = 'YYYY-MM'; - break; - - case 'quarter': - mergedFormat = 'YYYY-[Q]Q'; - break; - - case 'year': - mergedFormat = 'YYYY'; - break; - - default: - mergedFormat = showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; - } - } - - return mergedFormat; -} - // ====================== Mode ====================== -export function elementsContains( - elements: (HTMLElement | undefined | null)[], - target: HTMLElement, -) { - return elements.some((ele) => ele && ele.contains(target)); -} - export function getRealPlacement(placement: string, rtl: boolean) { if (placement !== undefined) { return placement; } return rtl ? 'bottomRight' : 'bottomLeft'; } - -export function getoffsetUnit(placement: string, rtl: boolean) { - const realPlacement = getRealPlacement(placement, rtl); - const placementRight = realPlacement?.toLowerCase().endsWith('right'); - let offsetUnit = placementRight ? 'insetInlineEnd' : 'insetInlineStart'; - if (rtl) { - offsetUnit = ['insetInlineStart', 'insetInlineEnd'].find(unit => unit !== offsetUnit); - } - return offsetUnit; -} \ No newline at end of file diff --git a/tests/__snapshots__/range.spec.tsx.snap b/tests/__snapshots__/range.spec.tsx.snap index 86b7282fe..7d51c4223 100644 --- a/tests/__snapshots__/range.spec.tsx.snap +++ b/tests/__snapshots__/range.spec.tsx.snap @@ -89,7 +89,7 @@ exports[`Picker.Range onPanelChange is array args should render correctly in pla
@@ -170,7 +170,7 @@ exports[`Picker.Range panelRender 1`] = `
@@ -184,7 +184,7 @@ exports[`Picker.Range panelRender 1`] = ` >
@@ -250,7 +250,7 @@ exports[`Picker.Range use dateRender and monthCellRender in date range picker 1` >
@@ -1307,7 +1307,7 @@ exports[`Picker.Range use dateRender and monthCellRender in month range picker 1 >
{ + act(() => { + _rs([{ target } as ResizeObserverEntry]); + }); +}; + import { DayRangePicker, clearValue, @@ -1788,20 +1796,52 @@ describe('Picker.Range', () => { mock.mockRestore(); }); - it('panel should be stable: arrow right and panel right', () => { + it('panel should be stable: arrow right and panel right', async () => { + const mockMatch = (element: HTMLElement, values: Record) => { + const keys = Object.keys(values); + for (const key of keys) { + if (element.classList.contains(key)) { + return values[key]; + } + } + return 0; + }; + + const getWidth = (element: HTMLElement) => { + return mockMatch(element, { + 'rc-picker-range': 200, + 'rc-picker-panel-container': 50, + 'rc-picker-range-wrapper': 200, + 'rc-picker-input': 100, + 'rc-picker-range-arrow': 0, + }); + }; + const getLeft = (element: HTMLElement) => { + return mockMatch(element, { + 'rc-picker-range-wrapper': 0, + 'rc-picker-input': 100, + }); + }; + const mock = spyElementPrototypes(HTMLElement, { + getBoundingClientRect() { + const left = getLeft(this); + const width = getWidth(this); + + return { + width, + left, + right: left + width, + }; + }, offsetWidth: { get() { - if (this.className.includes('rc-picker-range-wrapper')) { - return 200; - } + return getWidth(this); }, }, offsetLeft: { get() { - if (this.className.includes('rc-picker-input')) { - return 100; - } + return getLeft(this); }, }, }); @@ -1814,8 +1854,16 @@ describe('Picker.Range', () => { />, ); openPicker(container, 1); + + triggerResize(container.querySelector('.rc-picker-range')); + triggerResize(document.body.querySelector('.rc-picker-panel-container')); + + await act(async () => { + await Promise.resolve(); + }); + expect(document.querySelector('.rc-picker-panel-container')).toHaveStyle({ - marginLeft: '100px', + marginLeft: '50px', }); mock.mockRestore(); });