diff --git a/README.md b/README.md index 06c961136..edbae61a3 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ render(, mountNode); | onOpenChange | Function(open:boolean) | | called when open/close picker | | onFocus | (evnet:React.FocusEventHandler) => void | | called like input's on focus | | onBlur | (evnet:React.FocusEventHandler) => void | | called like input's on blur | +| direction | String: ltr or rtl | | Layout direction of picker component, it supports RTL direction too. | ### PickerPanel @@ -99,6 +100,7 @@ render(, mountNode); | onSelect | Function(date: moment) | | a callback function, can be executed when the selected time | | onPanelChange | Function(value: moment, mode) | | callback when picker panel mode is changed | | onMouseDown | (evnet:React.MouseEventHandler) => void | | callback when executed onMouseDown evnent | +| direction | String: ltr or rtl | | Layout direction of picker component, it supports RTL direction too. | ### RangePicker @@ -125,6 +127,7 @@ render(, mountNode); | disabled | Boolean | false | whether the range picker is disabled | | onChange | Function(value:[moment], formatString: [string, string]) | | a callback function, can be executed when the selected time is changing | | onCalendarChange | Function(value:[moment], formatString: [string, string]) | | a callback function, can be executed when the start time or the end time of the range is changing | +| direction | String: ltr or rtl | | Layout direction of picker component, it supports RTL direction too. | ### showTime-options diff --git a/assets/index.less b/assets/index.less index 0fec86fc3..fddf02b28 100644 --- a/assets/index.less +++ b/assets/index.less @@ -5,10 +5,13 @@ .@{prefix-cls} { display: inline-flex; + &-rtl { + direction: rtl; + } + &-focused { border: 1px solid blue; } - &-panel { border: 1px solid #666; background: @background-color; @@ -18,6 +21,10 @@ &-focused { border-color: blue; } + + &-rtl { + direction: rtl; + } } // ===================== Shared Panel ===================== @@ -237,6 +244,11 @@ display: block; width: 100%; text-align: left; + + .@{prefix-cls}-panel-rtl & { + padding: 0 12px 0 0; + text-align: right; + } } } } @@ -274,6 +286,10 @@ display: inline-flex; width: 100%; + .@{prefix-cls}-rtl & { + text-align: right; + } + > input { width: 100%; } @@ -285,6 +301,11 @@ top: 0; cursor: pointer; + .@{prefix-cls}-rtl & { + right: auto; + left: 4px; + } + &-btn::after { content: '×'; } @@ -306,13 +327,15 @@ // Panel @arrow-size: 10px; - &-placement-topLeft { + &-placement-topLeft, + &-placement-topRight { .@{prefix-cls}-range-arrow { bottom: @arrow-size / 2 + 1px; transform: rotate(135deg); } } - &-placement-bottomLeft { + &-placement-bottomLeft, + &-placement-bottomright { .@{prefix-cls}-range-arrow { top: @arrow-size / 2 + 1px; transform: rotate(-45deg); @@ -326,7 +349,14 @@ z-index: 1; left: @arrow-size; margin-left: 10px; - transition: left 0.3s; + transition: all 0.3s; + + .@{prefix-cls}-dropdown-rtl& { + right: @arrow-size; + left: auto; + margin-left: 0; + margin-right: 10px; + } &::before, &::after { @@ -336,6 +366,12 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); + + .@{prefix-cls}-dropdown-rtl& { + right: 50%; + left: auto; + transform: translate(50%, -50%); + } } &::before { diff --git a/examples/rtl.tsx b/examples/rtl.tsx new file mode 100644 index 000000000..a41e8644b --- /dev/null +++ b/examples/rtl.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import moment, { Moment } from 'moment'; +import Picker from '../src/Picker'; +import RangePicker from '../src/RangePicker'; +import PickerPanel from '../src/PickerPanel'; +import momentGenerateConfig from '../src/generate/moment'; +import zhCN from '../src/locale/zh_CN'; +import enUS from '../src/locale/en_US'; +import jaJP from '../src/locale/ja_JP'; +import '../assets/index.less'; + +const defaultValue = moment('2019-11-28 01:02:03'); + +function formatDate(date: Moment | null) { + return date ? date.format('YYYY-MM-DD HH:mm:ss') : 'null'; +} + +export default () => { + const [value, setValue] = React.useState(defaultValue); + + const weekRef = React.useRef>(null); + + const onSelect = (newValue: Moment) => { + console.log('Select:', newValue); + }; + + const onChange = (newValue: Moment | null, formatString?: string) => { + console.log('Change:', newValue, formatString); + setValue(newValue); + }; + + const sharedProps = { + generateConfig: momentGenerateConfig, + value, + onSelect, + onChange, + direction: 'rtl', + }; + + const rangePickerRef = React.useRef>(null); + + return ( +
+

+ Value:{' '} + {value ? `${formatDate(value[0])} ~ ${formatDate(value[1])}` : 'null'} +

+ +
+
+

Basic

+ {...sharedProps} locale={zhCN} /> +
+ +
+

Uncontrolled

+ + generateConfig={momentGenerateConfig} + locale={zhCN} + onChange={onChange} + defaultValue={moment('2000-01-01', 'YYYY-MM-DD')} + /> +
+ +
+

1 Month earlier

+ + {...sharedProps} + defaultPickerValue={defaultValue.clone().subtract(1, 'month')} + locale={enUS} + /> +
+ +
+

Week Picker CN

+ {...sharedProps} locale={zhCN} picker="week" /> +
+ +
+

Month Picker

+ {...sharedProps} locale={zhCN} picker="month" /> +
+ +
+

Week Picker US

+ {...sharedProps} locale={enUS} picker="week" /> +
+ +
+

Time

+ {...sharedProps} locale={jaJP} mode="time" /> +
+
+

Time AM/PM

+ + {...sharedProps} + locale={jaJP} + mode="time" + showTime={{ + use12Hours: true, + showSecond: false, + format: 'hh:mm A', + }} + /> +
+
+

Datetime

+ {...sharedProps} locale={zhCN} showTime /> +
+
+ +
+
+

Basic

+ {...sharedProps} locale={zhCN} /> +
+
+

Uncontrolled

+ + generateConfig={momentGenerateConfig} + locale={zhCN} + allowClear + /> +
+
+

Datetime

+ + {...sharedProps} + locale={zhCN} + defaultPickerValue={defaultValue.clone().subtract(1, 'month')} + showTime={{ + showSecond: false, + defaultValue: moment('11:28:39', 'HH:mm:ss'), + }} + showToday + disabledTime={date => { + if (date && date.isSame(defaultValue, 'date')) { + return { + disabledHours: () => [1, 3, 5, 7, 9, 11], + }; + } + return {}; + }} + /> +
+
+

Uncontrolled Datetime

+ generateConfig={momentGenerateConfig} locale={zhCN} /> +
+
+

Week

+ + {...sharedProps} + locale={zhCN} + format="YYYY-Wo" + allowClear + picker="week" + renderExtraFooter={() => 'I am footer!!!'} + ref={weekRef} + /> + + +
+
+

Week

+ + generateConfig={momentGenerateConfig} + locale={zhCN} + picker="week" + /> +
+
+

Time

+ {...sharedProps} locale={zhCN} picker="time" /> +
+
+

Time 12

+ + {...sharedProps} + locale={zhCN} + picker="time" + use12Hours + /> +
+
+

Year

+ {...sharedProps} locale={zhCN} picker="year" /> +
+
+ +
+
+

Basic RangePicker

+ + {...sharedProps} + value={undefined} + locale={zhCN} + allowClear + ref={rangePickerRef} + defaultValue={[moment('1990-09-03'), moment('1989-11-28')]} + placeholder={['start...', 'end...']} + /> +
+
+
+ ); +}; diff --git a/src/Picker.tsx b/src/Picker.tsx index 45ae66e3f..4418a6287 100644 --- a/src/Picker.tsx +++ b/src/Picker.tsx @@ -85,6 +85,8 @@ export interface PickerSharedProps extends React.AriaAttributes { // WAI-ARIA role?: string; name?: string; + + direction?: 'ltr' | 'rtl'; } type OmitPanelProps = Omit< @@ -158,6 +160,7 @@ function InnerPicker(props: PickerProps) { onMouseLeave, onContextMenu, onClick, + direction, } = props as MergedPickerProps; const inputRef = React.useRef(null); @@ -357,6 +360,7 @@ function InnerPicker(props: PickerProps) { locale={locale} tabIndex={-1} onChange={setSelectedValue} + direction={direction} /> ); @@ -394,6 +398,7 @@ function InnerPicker(props: PickerProps) { triggerOpen(false, true); } }; + const popupPlacement = direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; return ( (props: PickerProps) { dropdownAlign={dropdownAlign} getPopupContainer={getPopupContainer} transitionName={transitionName} + popupPlacement={popupPlacement} + direction={direction} >
{ onMouseDown?: React.MouseEventHandler; onOk?: (date: DateType) => void; + direction?: 'ltr' | 'rtl'; + /** @private This is internal usage. Do not use in your production env */ hideHeader?: boolean; /** @private This is internal usage. Do not use in your production env */ @@ -144,6 +146,7 @@ function PickerPanel(props: PickerPanelProps) { onPickerValueChange, onOk, components, + direction, } = props as MergedPickerPanelProps; const needConfirmButton: boolean = @@ -478,6 +481,7 @@ function PickerPanel(props: PickerPanelProps) { rangedValue && rangedValue[0] && rangedValue[1], [`${prefixCls}-panel-has-range-hover`]: hoverRangedValue && hoverRangedValue[0] && hoverRangedValue[1], + [`${prefixCls}-panel-rtl`]: direction === 'rtl', })} style={style} onKeyDown={onInternalKeyDown} diff --git a/src/PickerTrigger.tsx b/src/PickerTrigger.tsx index 550809861..ccf9e5d9b 100644 --- a/src/PickerTrigger.tsx +++ b/src/PickerTrigger.tsx @@ -38,6 +38,8 @@ const BUILT_IN_PLACEMENTS = { }, }; +type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + export interface PickerTriggerProps { prefixCls: string; visible: boolean; @@ -49,6 +51,8 @@ export interface PickerTriggerProps { getPopupContainer?: (node: HTMLElement) => HTMLElement; dropdownAlign?: AlignType; range?: boolean; + popupPlacement?: Placement; + direction?: 'ltr' | 'rtl'; } function PickerTrigger({ @@ -62,14 +66,23 @@ function PickerTrigger({ getPopupContainer, children, range, + popupPlacement, + direction, }: PickerTriggerProps) { const dropdownPrefixCls = `${prefixCls}-dropdown`; + const getPopupPlacement = () => { + if (popupPlacement !== undefined) { + return popupPlacement; + } + return direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; + }; + return ( { onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; onOk?: (dates: RangeValue) => void; + direction?: 'ltr' | 'rtl'; } type OmitPickerProps = Omit< @@ -212,6 +213,7 @@ function InnerRangePicker(props: RangePickerProps) { onBlur, onOk, components, + direction, } = props as MergedRangePickerProps; const needConfirmButton: boolean = @@ -718,6 +720,7 @@ function InnerRangePicker(props: RangePickerProps) { mode={mergedModes[activePickerIndex]} generateConfig={generateConfig} style={undefined} + direction={direction} disabledDate={ activePickerIndex === 0 ? disabledStartDate : disabledEndDate } @@ -775,6 +778,9 @@ function InnerRangePicker(props: RangePickerProps) { } } + const arrowPositionStyle = + direction === 'rtl' ? { right: arrowLeft } : { left: arrowLeft }; + function renderPanels() { let panels: React.ReactNode; const extraNode = getExtraFooter( @@ -806,25 +812,28 @@ function InnerRangePicker(props: RangePickerProps) { const currentMode = mergedModes[activePickerIndex]; const showDoublePanel = currentMode === picker; + const leftPanel = renderPanel(showDoublePanel ? 'left' : false, { + pickerValue: viewDate, + onPickerValueChange: newViewDate => { + setViewDate(newViewDate, activePickerIndex); + }, + }); + const rightPanel = renderPanel('right', { + pickerValue: nextViewDate, + onPickerValueChange: newViewDate => { + setViewDate( + getClosingViewDate(newViewDate, picker, generateConfig, -1), + activePickerIndex, + ); + }, + }); panels = ( <> - {renderPanel(showDoublePanel ? 'left' : false, { - pickerValue: viewDate, - onPickerValueChange: newViewDate => { - setViewDate(newViewDate, activePickerIndex); - }, - })} - {showDoublePanel && - renderPanel('right', { - pickerValue: nextViewDate, - onPickerValueChange: newViewDate => { - setViewDate( - getClosingViewDate(newViewDate, picker, generateConfig, -1), - activePickerIndex, - ); - }, - })} + {direction === 'rtl' ? rightPanel : leftPanel} + {direction === 'rtl' + ? showDoublePanel && leftPanel + : showDoublePanel && rightPanel} ); } else { @@ -857,7 +866,7 @@ function InnerRangePicker(props: RangePickerProps) { )} style={{ minWidth: popupMinWidth }} > -
+
{renderPanels()}
@@ -916,7 +925,8 @@ function InnerRangePicker(props: RangePickerProps) { activeBarWidth = endInputDivRef.current.offsetWidth; } } - + const activeBarPositionStyle = + direction === 'rtl' ? { right: activeBarLeft } : { left: activeBarLeft }; // ============================ Return ============================= const onContextSelect = ( date: DateType, @@ -954,12 +964,14 @@ function InnerRangePicker(props: RangePickerProps) { getPopupContainer={getPopupContainer} transitionName={transitionName} range + direction={direction} >
(props: RangePickerProps) {
+
+
+ + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Su + + Mo + + Tu + + We + + Th + + Fr + + Sa +
+
+ 26 +
+
+
+ 27 +
+
+
+ 28 +
+
+
+ 29 +
+
+
+ 30 +
+
+
+ 31 +
+
+
+ 1 +
+
+
+ 2 +
+
+
+ 3 +
+
+
+ 4 +
+
+
+ 5 +
+
+
+ 6 +
+
+
+ 7 +
+
+
+ 8 +
+
+
+ 9 +
+
+
+ 10 +
+
+
+ 11 +
+
+
+ 12 +
+
+
+ 13 +
+
+
+ 14 +
+
+
+ 15 +
+
+
+ 16 +
+
+
+ 17 +
+
+
+ 18 +
+
+
+ 19 +
+
+
+ 20 +
+
+
+ 21 +
+
+
+ 22 +
+
+
+ 23 +
+
+
+ 24 +
+
+
+ 25 +
+
+
+ 26 +
+
+
+ 27 +
+
+
+ 28 +
+
+
+ 29 +
+
+
+ 30 +
+
+
+ 1 +
+
+
+ 2 +
+
+
+ 3 +
+
+
+ 4 +
+
+
+ 5 +
+
+
+ 6 +
+
+
+
+
+`; + exports[`Picker.Panel time disabled columns 1`] = `
`; + +exports[`Picker.Basic should render correctly in rtl 1`] = ` +
+
+ +
+
+`; diff --git a/tests/__snapshots__/range.spec.tsx.snap b/tests/__snapshots__/range.spec.tsx.snap index 385871bd7..097cd531f 100644 --- a/tests/__snapshots__/range.spec.tsx.snap +++ b/tests/__snapshots__/range.spec.tsx.snap @@ -49,3 +49,39 @@ exports[`Picker.Range icon 1`] = `
`; + +exports[`Picker.Range onPanelChange is array args should render correctly in rtl 1`] = ` +
+
+ +
+
+ ~ +
+
+ +
+
+
+`; diff --git a/tests/panel.spec.tsx b/tests/panel.spec.tsx index fdfce2d97..bf63e9e69 100644 --- a/tests/panel.spec.tsx +++ b/tests/panel.spec.tsx @@ -418,6 +418,11 @@ describe('Picker.Panel', () => { errSpy.mockRestore(); }); + it('should render correctly in rtl', () => { + const wrapper = mount(); + expect(wrapper.render()).toMatchSnapshot(); + }); + describe('hideHeader', () => { ['decade', 'year', 'month', 'date', 'time'].forEach(mode => { it(mode, () => { diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx index b874e7af5..bcb67bb7e 100644 --- a/tests/picker.spec.tsx +++ b/tests/picker.spec.tsx @@ -523,4 +523,9 @@ describe('Picker.Basic', () => { wrapper.closePicker(); expect(wrapper.find('input').props().value).toEqual(''); }); + + it('should render correctly in rtl', () => { + const wrapper = mount(); + expect(wrapper.render()).toMatchSnapshot(); + }); }); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index d331d1417..197c7d50f 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -583,6 +583,11 @@ describe('Picker.Range', () => { expect(isSame(onPanelChange.mock.calls[0][0][1], '1998-09-03')); expect(onPanelChange.mock.calls[0][1]).toEqual(['month', 'month']); }); + + it('should render correctly in rtl', () => { + const wrapper = mount(); + expect(wrapper.render()).toMatchSnapshot(); + }); }); it('type can not change before start time', () => {