diff --git a/packages/ui/src/DateRanger/PickerPanel.tsx b/packages/ui/src/DateRanger/PickerPanel.tsx index 169a679f7..2ac473f75 100644 --- a/packages/ui/src/DateRanger/PickerPanel.tsx +++ b/packages/ui/src/DateRanger/PickerPanel.tsx @@ -24,6 +24,7 @@ import moment from 'moment'; import dayjs from 'dayjs'; import { useUpdate } from 'ahooks'; import { toArray } from '@oceanbase/util'; +import { getPrefix } from '../_util'; type RangeValue = [Moment, Moment] | [Dayjs, Dayjs]; type ValidateTrigger = 'submit' | 'valueChange'; @@ -50,6 +51,8 @@ export interface PickerPanelProps { locale: any; } +const prefix = getPrefix('ranger-picker-panel'); + const prefixCls = 'ant-picker'; const DATE_FORMAT = 'YYYY-MM-DD'; const TIME_FORMAT = 'HH:mm:ss'; @@ -75,8 +78,12 @@ const InternalPickerPanel = (props: PickerPanelProps) => { const [activeIndex, setActiveIndex] = React.useState(0); const getDateInstance = useCallback( - (v?: string | Dayjs | Moment) => { - return isMoment ? moment(v as Moment) : dayjs(v as Dayjs); + ( + v?: string | Dayjs | Moment, + format?: typeof DATE_FORMAT | typeof TIME_FORMAT, + strict?: boolean + ) => { + return isMoment ? moment(v as Moment, format, strict) : dayjs(v as Dayjs, format, strict); }, [isMoment] ); @@ -135,12 +142,106 @@ const InternalPickerPanel = (props: PickerPanelProps) => { const validateInputDate = e => { const v = e.target.value; - const date = getDateInstance(v); + const date = getDateInstance(v, DATE_FORMAT, true); return date.isValid() ? date.format(DATE_FORMAT) : null; }; + const validateInputTime = e => { + const v: string = e.target.value; + const [h, m, s] = v?.split(':') ?? []; + if (!(h && m && s)) return null; + if (!(h.length === 2 && m.length === 2 && s.length === 2)) return null; + const date = getDateInstance(v, TIME_FORMAT, true); + return date.isValid() ? date.format(TIME_FORMAT) : null; + }; + + const selectDateInputRange = (inputDomRef: HTMLInputElement) => { + if (!inputDomRef) return; + // year + if (inputDomRef.selectionStart >= 0 && inputDomRef.selectionStart <= 4) { + inputDomRef.setSelectionRange(0, 4); + } + // month + if (inputDomRef.selectionStart > 4 && inputDomRef.selectionStart <= 7) { + inputDomRef.setSelectionRange(5, 7); + } + // day + if (inputDomRef.selectionStart > 7 && inputDomRef.selectionStart <= 10) { + inputDomRef.setSelectionRange(8, 10); + } + }; + + const selectTimeInputRange = (inputDomRef: HTMLInputElement) => { + if (!inputDomRef) return; + // hour + if (inputDomRef.selectionStart >= 0 && inputDomRef.selectionStart <= 2) { + inputDomRef.setSelectionRange(0, 2); + } + // minute + if (inputDomRef.selectionStart > 2 && inputDomRef.selectionStart <= 5) { + inputDomRef.setSelectionRange(3, 5); + } + // second + if (inputDomRef.selectionStart > 5 && inputDomRef.selectionStart <= 8) { + inputDomRef.setSelectionRange(6, 8); + } + }; + + const handleDateInputKeyDown = ( + inputDomRef: HTMLInputElement, + e: React.KeyboardEvent + ) => { + if (!inputDomRef) return; + if (e.key === 'Enter') { + inputDomRef.blur(); + return; + } + if (e.key === 'ArrowLeft') { + const curIndex = inputDomRef.selectionStart; + inputDomRef.setSelectionRange(curIndex - 1, curIndex - 1); + } + if (e.key === 'ArrowRight') { + const curIndex = inputDomRef.selectionEnd; + inputDomRef.setSelectionRange(curIndex + 1, curIndex + 1); + } + // NOTE: onKeyDown事件执行时,由于TimePicker需要执行受控逻辑,这会引起React rerender, + // 导致onKeyDown事件中拿到的Event还未更新,将下述校验逻辑放入requestIdleCallback确保其跟在React fiber调用栈后执行,这可取到最新的Event对象。 + requestIdleCallback(() => { + // 校验 + if (validateInputDate(e)) { + selectDateInputRange(inputDomRef); + } + }); + }; + + const handleTimeInputKeyDown = ( + inputDomRef: HTMLInputElement, + e: React.KeyboardEvent + ) => { + if (!inputDomRef) return; + if (e.key === 'Enter') { + inputDomRef.blur(); + return; + } + if (e.key === 'ArrowLeft') { + const curIndex = inputDomRef.selectionStart; + inputDomRef.setSelectionRange(curIndex - 1, curIndex - 1); + } + if (e.key === 'ArrowRight') { + const curIndex = inputDomRef.selectionEnd; + inputDomRef.setSelectionRange(curIndex + 1, curIndex + 1); + } + // NOTE: onKeyDown事件执行时,由于TimePicker需要执行受控逻辑,这会引起React rerender, + // 导致onKeyDown事件中拿到的Event还未更新,将下述校验逻辑放入requestIdleCallback确保其跟在React fiber调用栈后执行,这可取到最新的Event对象。 + requestIdleCallback(() => { + // 校验 + if (validateInputTime(e)) { + selectTimeInputRange(inputDomRef); + } + }); + }; return ( -
+
{tip && }
{ setFormatDateToForm(); } }} + onClick={() => { + selectDateInputRange(form.getFieldInstance('startDate').nativeElement); + }} + onKeyDown={e => { + handleDateInputKeyDown(form.getFieldInstance('startDate').nativeElement, e); + }} /> @@ -182,7 +289,23 @@ const InternalPickerPanel = (props: PickerPanelProps) => { validateStatus={errorTypeMap['startTime']} initialValue={defaultS || defaultTime} > - + triggerNode.parentNode as HTMLElement} + style={{ width: '100%' }} + onClick={() => { + selectTimeInputRange( + form.getFieldInstance('startTime').nativeElement.querySelector('input') + ); + }} + onKeyDown={e => { + handleTimeInputKeyDown( + form.getFieldInstance('startTime').nativeElement.querySelector('input'), + e + ); + }} + /> @@ -207,6 +330,12 @@ const InternalPickerPanel = (props: PickerPanelProps) => { setFormatDateToForm(); } }} + onClick={() => { + selectDateInputRange(form.getFieldInstance('endDate').nativeElement); + }} + onKeyDown={e => { + handleDateInputKeyDown(form.getFieldInstance('endDate').nativeElement, e); + }} /> @@ -218,7 +347,23 @@ const InternalPickerPanel = (props: PickerPanelProps) => { validateStatus={errorTypeMap['endTime']} initialValue={defaultE || defaultTime} > - + triggerNode.parentNode as HTMLElement} + style={{ width: '100%' }} + onClick={() => { + selectTimeInputRange( + form.getFieldInstance('endTime').nativeElement.querySelector('input') + ); + }} + onKeyDown={e => { + handleTimeInputKeyDown( + form.getFieldInstance('endTime').nativeElement.querySelector('input'), + e + ); + }} + /> @@ -237,6 +382,8 @@ const InternalPickerPanel = (props: PickerPanelProps) => { prefixCls={prefixCls} // @ts-ignore generateConfig={isMoment ? momentGenerateConfig : dayjsGenerateConfig} + // @ts-ignore + value={calendarValue} disabledDate={disabledDate} onHover={(...res) => { onPanelHover(res[0]); diff --git a/packages/ui/src/DateRanger/Ranger.tsx b/packages/ui/src/DateRanger/Ranger.tsx index 97cd7c4c3..1c36a2f9f 100644 --- a/packages/ui/src/DateRanger/Ranger.tsx +++ b/packages/ui/src/DateRanger/Ranger.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Button, DatePicker, Dropdown, Radio, Space, Tooltip, theme } from '@oceanbase/design'; +import { Button, DatePicker, Divider, Dropdown, Radio, Space, theme } from '@oceanbase/design'; import type { TooltipProps, FormItemProps } from '@oceanbase/design'; import { LeftOutlined, @@ -311,8 +311,46 @@ const Ranger = (props: DateRangerProps) => { setOpen(o); }} + dropdownRender={originNode => { + return ( +
+ {originNode} + + { + setIsPlay(false); + handleNameChange(CUSTOMIZE); + rangeChange( + vList.map(v => { + return isMoment ? moment(v) : dayjs(v); + }) as RangeValue + ); + + closeTooltip(); + }} + onCancel={() => { + closeTooltip(); + }} + /> +
+ ); + }} menu={{ + selectable: true, + defaultSelectedKeys: [rangeName], onClick: ({ key, domEvent }) => { + if (key === CUSTOMIZE) { + refState.current.tooltipOpen = true; + } else { + refState.current.tooltipOpen = false; + } const selected = NEAR_TIME_LIST.find(_item => _item.name === key); // 存在快捷选项切换为极简模式 if (selected?.range) { @@ -335,66 +373,13 @@ const Ranger = (props: DateRangerProps) => { .map(item => { return { key: item.name, - label: - item.name === CUSTOMIZE ? ( - { - if (o) { - setTooltipOpen(true); - } - }} - placement="right" - {...tooltipProps} - overlayStyle={{ - maxWidth: 336, - ...tooltipProps?.overlayStyle, - }} - overlayInnerStyle={{ - background: '#fff', - maxHeight: 'none', - margin: 16, - ...tooltipProps?.overlayInnerStyle, - }} - title={ - { - setIsPlay(false); - rangeChange( - vList.map(v => { - return isMoment ? moment(v) : dayjs(v); - }) as RangeValue - ); - - closeTooltip(); - }} - onCancel={() => { - closeTooltip(); - }} - /> - } - > - - {item.rangeLabel} - {/* @ts-ignore */} - {locale[item.label] || item.label} - - - ) : ( - - {item.rangeLabel} - {/* @ts-ignore */} - {locale[item.label] || item.label} - - ), + label: ( + + {item.rangeLabel} + {/* @ts-ignore */} + {locale[item.label] || item.label} + + ), }; }), }} diff --git a/packages/ui/src/DateRanger/__test__/index.test.tsx b/packages/ui/src/DateRanger/__test__/index.test.tsx index 08f2de48d..3369cb8fc 100644 --- a/packages/ui/src/DateRanger/__test__/index.test.tsx +++ b/packages/ui/src/DateRanger/__test__/index.test.tsx @@ -1,10 +1,119 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { DateRanger } from '@oceanbase/ui'; +import { NEAR_1_MINUTES, NEAR_30_MINUTES } from '../constant'; +import dayjs from 'dayjs'; describe('DateRanger', () => { - it('Normal display', () => { + it('Display normally' /** 成功渲染组件 */, async () => { const { container, asFragment } = render(); expect(container.querySelector('.ob-date-ranger-wrapper')).toBeTruthy(); }); + it('Ranger panel can be triggered by clicking' /** 选择面板可以通过点击触发 */, () => { + const { container } = render(); + const dropdownTrigger = container.querySelector( + '.ob-date-ranger-wrapper > .ant-dropdown-trigger' + ); + fireEvent.click(dropdownTrigger); + expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeTruthy(); + expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeTruthy(); + }); + it('Support setting default quick value' /** 支持设置默认的快捷选项值 */, () => { + // NEAR_1_MINUTES is default value of defaultQuickValue + const { container } = render(); + expect(container.querySelector('.ob-date-ranger-label').textContent).toBe( + NEAR_1_MINUTES.rangeLabel + ); + expect(container.querySelector('.ob-date-ranger-play').textContent).toBe(NEAR_1_MINUTES.label); + + // Custom defaultQuickValue + const { container: containerWith30Minutes } = render( + + ); + expect(containerWith30Minutes.querySelector('.ob-date-ranger-label').textContent).toBe( + NEAR_30_MINUTES.rangeLabel + ); + expect(containerWith30Minutes.querySelector('.ob-date-ranger-play').textContent).toBe( + NEAR_30_MINUTES.label + ); + }); + it('Should be simple mode when selected shortcut option' /** 选中快捷选项时,应当处于简单模式 */, () => { + const { container } = render(); + // As simple mode + expect(container.querySelector('.ob-date-ranger-play')).toBeTruthy(); + expect(container.querySelector('.ob-date-ranger-picker')).toBeFalsy(); + }); + it('Support setting default value' /** 支持设置默认时间值 */, () => { + const { container } = render( + + ); + // As normal mode + expect(container.querySelector('.ob-date-ranger-play')).toBeFalsy(); + expect(container.querySelector('.ob-date-ranger-picker')).toBeTruthy(); + }); + suite('Panel shortcut options' /** 选择面板中的快捷选项 */, () => { + it('In simple mode, the shortcut option that is consistent with the ranger label should be selected' /** 在简单模式下,快捷选项应选中和ranger label 一致的快捷选项 */, () => { + const { container } = render(); + const dropdownTrigger = container.querySelector( + '.ob-date-ranger-wrapper > .ant-dropdown-trigger' + ); + fireEvent.click(dropdownTrigger); + // Should be selected NEAR_30_MINUTES item that the same as ranger-label when open panel + const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker'); + expect( + dropdownLayerPicker.querySelector( + '.ant-dropdown-menu .ant-dropdown-menu-item-selected .ob-date-ranger-label' + ).textContent + ).toBe(NEAR_30_MINUTES.rangeLabel); + }); + it('In normal mode, the shortcut option should be selected custom item' /** 设置了时间值即为普通模式,选择面板中的快捷选项应当选中“自定义”项 */, () => { + const { container } = render( + + ); + const dropdownTrigger = container.querySelector( + '.ob-date-ranger-wrapper > .ant-dropdown-trigger' + ); + fireEvent.click(dropdownTrigger); + const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker'); + expect( + dropdownLayerPicker.querySelector( + '.ant-dropdown-menu .ant-dropdown-menu-item-selected .ob-date-ranger-label' + ).textContent + ).toBe('自定义'); + }); + it('Should selected shortcut option and close panel when click quick time item' /** 当点击快捷时间选项时应该选中该项的时间并关闭选择面板 */, () => { + let value = [dayjs('2024/10/12'), dayjs('2024/10/20')]; + const onChange = vi.fn(v => { + value = v; + }); + const { container } = render(); + const dropdownTrigger = container.querySelector( + '.ob-date-ranger-wrapper > .ant-dropdown-trigger' + ); + fireEvent.click(dropdownTrigger); + const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker'); + // By default, "NEAR_30_MINUTES" is the second option. + fireEvent.click(dropdownLayerPicker.querySelector('.ant-dropdown-menu').childNodes[1]); + expect(onChange).toHaveBeenCalled(); + expect(value.map(v => v.format())).toStrictEqual( + NEAR_30_MINUTES.range(dayjs()).map(v => v.format()) + ); + // Dropdown panel should be destroyed when close. + expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeFalsy(); + expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeFalsy(); + }); + it('Should not close panel when select custom time option' /** 当选中“自定义时间”时不应该关闭选择面板 */, () => { + const { container } = render(); + const dropdownTrigger = container.querySelector( + '.ob-date-ranger-wrapper > .ant-dropdown-trigger' + ); + fireEvent.click(dropdownTrigger); + const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker'); + // By default, "CUSTOMIZE" is the last option. + fireEvent.click(dropdownLayerPicker.querySelector('.ant-dropdown-menu').lastChild); + // The panel should remain open when select the custom option. + expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeTruthy(); + expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeTruthy(); + }); + }); }); diff --git a/packages/ui/src/DateRanger/index.less b/packages/ui/src/DateRanger/index.less index 21ce708ac..85e4e5301 100644 --- a/packages/ui/src/DateRanger/index.less +++ b/packages/ui/src/DateRanger/index.less @@ -43,6 +43,31 @@ } } +.@{prefix}-dropdown-picker { + display: flex; + padding: 4px; + gap: 4px; + background-color: #ffffff; + background-clip: padding-box; + border-radius: 8px; + outline: none; + box-shadow: + 0 6px 16px 0 rgba(54, 69, 99, 0.08), + 0 3px 6px -4px rgba(54, 69, 99, 0.12), + 0 9px 28px 8px rgba(54, 69, 99, 0.05); + + .ant-dropdown-menu { + padding: 0; + box-shadow: none; + } + + .ant-picker-time-panel-container { + .ant-picker-content { + height: 148px; + } + } +} + .@{prefix}-show-range { .@{prefix}-quick-picker.@{prefix}-quick-picker-select { margin-right: -1px;