diff --git a/README.md b/README.md index 642e3277..f6e493dc 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ http://react-component.github.io/calendar/examples/index.html mode - enum('time', 'date', 'month', 'year', 'decade') + enum('time', 'date', 'month', 'quarter', 'year', 'decade') 'date' control which kind of panel should be shown @@ -411,7 +411,7 @@ http://react-component.github.io/calendar/examples/index.html mode - enum('date', 'month', 'year', 'decade')[] + enum('date', 'month', 'quarter', 'year', 'decade')[] ['date', 'date'] control which kind of panels should be shown @@ -536,6 +536,100 @@ http://react-component.github.io/calendar/examples/index.html +### rc-calendar/lib/QuarterCalendar props + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nametypedefaultdescription
prefixClsStringprefixCls of this component
classNameStringadditional css class of root dom node
styleObjectadditional style of root dom node
valuemomentcurrent value like input's value
defaultValuemomentdefaultValue like input's defaultValue
localeObjectimport from 'rc-calendar/lib/locale/en_US'calendar locale
disabledDateFunction(current:moment):Booleanwhether to disable select of current quarter
onSelectFunction(date: moment)called when a date is selected from calendar
quarterCellRenderfunctionCustom quarter cell render method
dateCellRenderfunctionCustom date cell render method
quarterCellContentRenderfunctionCustom quarter cell content render method,the content will be appended to the cell.
onChangeFunction(date: moment)called when a date is changed inside calendar (next year/next month/keyboard)
renderFooter() => React.Nodeextra footer
+ ### rc-calendar/lib/Picker props diff --git a/assets/index.less b/assets/index.less index 89e11059..ecfc1b15 100644 --- a/assets/index.less +++ b/assets/index.less @@ -4,6 +4,7 @@ @import "index/Time"; @import "index/TimePanel"; @import "index/MonthPanel"; +@import "index/QuarterPanel"; @import "index/YearPanel"; @import "index/DecadePanel"; @import "common/RangeCalendar"; diff --git a/assets/index/QuarterPanel.less b/assets/index/QuarterPanel.less new file mode 100644 index 00000000..3928895b --- /dev/null +++ b/assets/index/QuarterPanel.less @@ -0,0 +1,140 @@ +.@{prefixClass}-quarter-panel { + left: 0; + top:0; + bottom: 0; + right: 0; + background: #ffffff; + z-index: 10; + position: absolute; + outline: none; +} + +.@{prefixClass}-quarter-panel-hidden { + display: none; +} + +.@{prefixClass}-quarter-panel-header { + padding: 0 10px; + height: 34px; + line-height: 30px; + position: relative; + text-align: center; + user-select: none; + -webkit-user-select: none; + border-bottom: 1px solid #ccc; + + > a { + font-weight: bold; + display: inline-block; + padding: 4px 5px; + text-align: center; + width: 30px; + + &:hover { + cursor: pointer; + color: #23c0fa; + } + } +} + +.@{prefixClass}-quarter-panel-prev-year-btn, .@{prefixClass}-quarter-panel-next-year-btn { + position: absolute; + top: 0; +} + +.@{prefixClass}-quarter-panel-next-year-btn { + &:after { + content: '»' + } +} + +.@{prefixClass}-quarter-panel-prev-year-btn { + user-select: none; + left: 0; + + &:after { + content: '«' + } +} + +.@{prefixClass}-quarter-panel .@{prefixClass}-quarter-panel-year-select { + width: 180px; +} + +.@{prefixClass}-quarter-panel-year-select-arrow { + display: none; +} + +.@{prefixClass}-quarter-panel-next-year-btn { + user-select: none; + right: 0; +} + +.@{prefixClass}-quarter-panel-body { + padding: 9px 10px 10px; + position: absolute; + top: 34px; + bottom: 0; +} + +.@{prefixClass}-quarter-panel-table { + table-layout: fixed; + width: 100%; + height: 100%; + border-collapse: separate; +} + +.@{prefixClass}-quarter-panel-cell { + text-align: center; + + + + .@{prefixClass}-quarter-panel-quarter { + display: block; + width: 46px; + margin: 0 auto; + color: #666; + border-radius: 4px 4px; + height: 36px; + padding: 0; + background: transparent; + line-height: 36px; + text-align: center; + + &:hover { + background: #ebfaff; + cursor: pointer; + } + } + + &-disabled{ + .@{prefixClass}-quarter-panel-quarter { + color: #bfbfbf; + + &:hover { + background: white; + cursor: not-allowed; + } + } + } +} + +.@{prefixClass}-quarter-panel-selected-cell .@{prefixClass}-quarter-panel-quarter { + background: #3fc7fa; + color: #fff; + + &:hover { + background: #3fc7fa; + color: #fff; + } +} + +.@{prefixClass}-quarter-header-wrap { + position: relative; + height: 108px; +} + +.@{prefixClass}-quarter-year-header-wrap { + position: relative; + min-height: 308px; +} \ No newline at end of file diff --git a/examples/antd-month-calendar.js b/examples/antd-month-calendar.js index 15ed13f2..e68d413d 100644 --- a/examples/antd-month-calendar.js +++ b/examples/antd-month-calendar.js @@ -63,6 +63,7 @@ class Demo extends React.Component { render() { const state = this.state; + console.log(cn); const calendar = ( { + console.log(`DatePicker change: ${value && value.format(format)}`); + this.setState({ + value, + }); + } + + onShowTimeChange = (e) => { + this.setState({ + showTime: e.target.checked, + }); + } + + toggleDisabled = () => { + this.setState({ + disabled: !this.state.disabled, + }); + } + + render() { + const state = this.state; + const calendar = (); + return (
+
+      + +
+
+ + { + ({ value }) => { + return (); + } + } + +
+
); + } +} + +function onStandaloneSelect(value) { + console.log('quarter-calendar select', (value && value.format(format))); +} + +function onStandaloneChange(value) { + console.log('quarter-calendar change', (value && value.format(format))); +} + +function disabledDate(value) { + return value.year() > now.year() || + value.year() === now.year() && value.quarter() > now.quarter(); +} + +function onQuarterCellContentRender(value) { + // console.log('month-calendar onMonthCellContentRender', (value && value.format(format))); + return `${value.quarter()}季度`; +} + +ReactDOM.render( + (
+ 'extra footer'} + /> + +
+ +
+
) + , document.getElementById('__react-content')); diff --git a/src/QuarterCalendar.jsx b/src/QuarterCalendar.jsx new file mode 100644 index 00000000..416a5ace --- /dev/null +++ b/src/QuarterCalendar.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import KeyCode from 'rc-util/lib/KeyCode'; +import CalendarHeader from './calendar/CalendarHeader'; +import CalendarFooter from './calendar/CalendarFooter'; +import { + calendarMixinWrapper, + calendarMixinPropTypes, + calendarMixinDefaultProps, +} from './mixin/CalendarMixin'; +import { commonMixinWrapper, propType, defaultProp } from './mixin/CommonMixin'; +import moment from 'moment'; + +class QuarterCalendar extends React.Component { + static propTypes = { + ...calendarMixinPropTypes, + ...propType, + quarterCellRender: PropTypes.func, + dateCellRender: PropTypes.func, + value: PropTypes.object, + defaultValue: PropTypes.object, + selectedValue: PropTypes.object, + defaultSelectedValue: PropTypes.object, + disabledDate: PropTypes.func, + } + + static defaultProps = Object.assign({}, defaultProp, calendarMixinDefaultProps); + + constructor(props) { + super(props); + + this.state = { + isShowYear: false, + mode: 'quarter', + value: props.value || props.defaultValue || moment(), + selectedValue: props.selectedValue || props.defaultSelectedValue, + }; + } + + onKeyDown = (event) => { + const keyCode = event.keyCode; + const ctrlKey = event.ctrlKey || event.metaKey; + const stateValue = this.state.value; + const { disabledDate } = this.props; + let value = stateValue; + switch (keyCode) { + case KeyCode.DOWN: + value = stateValue.clone(); + value.add(0, 'quarters'); + break; + case KeyCode.UP: + value = stateValue.clone(); + value.add(0, 'quarters'); + break; + case KeyCode.LEFT: + value = stateValue.clone(); + if (ctrlKey) { + value.add(-1, 'years'); + } else { + value.add(-1, 'quarters'); + } + break; + case KeyCode.RIGHT: + value = stateValue.clone(); + if (ctrlKey) { + value.add(1, 'years'); + } else { + value.add(1, 'quarters'); + } + break; + case KeyCode.ENTER: + if (!disabledDate || !disabledDate(stateValue)) { + this.onSelect(stateValue); + } + event.preventDefault(); + return 1; + default: + return undefined; + } + if (value !== stateValue) { + this.setValue(value); + event.preventDefault(); + return 1; + } + } + + handlePanelChange = (_, mode) => { + if (mode !== 'date') { + this.setState({ mode }); + } + } + + render() { + const { props, state } = this; + const { mode, value, isShowYear } = state; + const children = ( +
+
+ this.setState({ isShowYear: bol })} + locale={props.locale} + disabledQuarter={props.disabledDate} + quarterCellRender={props.quarterCellRender} + quarterCellContentRender={props.quarterCellContentRender} + onQuarterSelect={this.onSelect} + onValueChange={this.setValue} + onPanelChange={this.handlePanelChange} + /> +
+ +
+ ); + return this.renderRoot({ + className: `${props.prefixCls}-quarter-calendar`, + children, + }); + } +} + +export default calendarMixinWrapper(commonMixinWrapper(QuarterCalendar)); diff --git a/src/calendar/CalendarHeader.jsx b/src/calendar/CalendarHeader.jsx index a7eccfc1..0704cf69 100644 --- a/src/calendar/CalendarHeader.jsx +++ b/src/calendar/CalendarHeader.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import toFragment from 'rc-util/lib/Children/mapSelf'; import MonthPanel from '../month/MonthPanel'; +import QuarterPanel from '../quarter/QuarterPanel'; import YearPanel from '../year/YearPanel'; import DecadePanel from '../decade/DecadePanel'; @@ -26,6 +27,7 @@ export default class CalendarHeader extends React.Component { prefixCls: PropTypes.string, value: PropTypes.object, onValueChange: PropTypes.func, + showYear: PropTypes.func, showTimePicker: PropTypes.bool, onPanelChange: PropTypes.func, locale: PropTypes.object, @@ -34,6 +36,7 @@ export default class CalendarHeader extends React.Component { disabledMonth: PropTypes.func, renderFooter: PropTypes.func, onMonthSelect: PropTypes.func, + onQuarterSelect: PropTypes.func, } static defaultProps = { @@ -63,11 +66,23 @@ export default class CalendarHeader extends React.Component { } } + onQuarterSelect = (value) => { + this.props.onPanelChange(value, 'date'); + if (this.props.onQuarterSelect) { + this.props.onQuarterSelect(value); + } else { + this.props.onValueChange(value); + } + } + onYearSelect = (value) => { const referer = this.state.yearPanelReferer; this.setState({ yearPanelReferer: null }); this.props.onPanelChange(value, referer); this.props.onValueChange(value); + if (this.props.showYear) { + this.props.showYear(false); + } } onDecadeSelect = (value) => { @@ -136,6 +151,9 @@ export default class CalendarHeader extends React.Component { showYearPanel = (referer) => { this.setState({ yearPanelReferer: referer }); this.props.onPanelChange(null, 'year'); + if (this.props.showYear) { + this.props.showYear(true); + } } showDecadePanel = () => { @@ -153,6 +171,7 @@ export default class CalendarHeader extends React.Component { enableNext, enablePrev, disabledMonth, + disabledQuarter, renderFooter, } = props; @@ -173,6 +192,21 @@ export default class CalendarHeader extends React.Component { /> ); } + if (mode === 'quarter') { + panel = ( + this.showYearPanel('quarter')} + disabledDate={disabledQuarter} + cellRender={props.quarterCellRender} + contentRender={props.quarterCellContentRender} + changeYear={this.changeYear} + /> + ); + } if (mode === 'year') { panel = ( { + this.setValue(value); + this.props.onSelect(value); + } + + setValue(value) { + if (!('value' in this.props)) { + this.setState({ + value, + }); + } + } + + render() { + const props = this.props; + const value = this.state.value; + const cellRender = props.cellRender; + const contentRender = props.contentRender; + const { locale } = props; + const year = value.year(); + const prefixCls = this.prefixCls; + + return ( +
+ +
+ ); + } +} + +QuarterPanel.defaultProps = { + onChange: noop, + onSelect: noop, +}; + +QuarterPanel.propTypes = { + value: PropTypes.object, + defaultValue: PropTypes.object, + rootPrefixCls: PropTypes.string, + onChange: PropTypes.func, + disabledDate: PropTypes.func, + onSelect: PropTypes.func, +}; + +export default QuarterPanel; diff --git a/src/quarter/QuarterTable.js b/src/quarter/QuarterTable.js new file mode 100644 index 00000000..dd8e0afc --- /dev/null +++ b/src/quarter/QuarterTable.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { getTodayTime, getQuarterName } from '../util/index'; + +const ROW = 1; +const COL = 4; + +function chooseQuarter(quarter) { + const next = this.state.value.clone(); + next.quarter(quarter); + this.setAndSelectValue(next); +} + +function noop() { + +} + +class QuarterTable extends Component { + constructor(props) { + super(props); + + this.state = { + value: props.value, + }; + } + + componentWillReceiveProps(nextProps) { + if ('value' in nextProps) { + this.setState({ + value: nextProps.value, + }); + } + } + + setAndSelectValue(value) { + this.setState({ + value, + }); + this.props.onSelect(value); + } + + quarters() { + const value = this.state.value; + const current = value.clone(); + const quarters = []; + let index = 1; + for (let rowIndex = 0; rowIndex < ROW; rowIndex++) { + quarters[rowIndex] = []; + for (let colIndex = 0; colIndex < COL; colIndex++) { + current.quarters(index); + const content = getQuarterName(current); + quarters[rowIndex][colIndex] = { + value: index, + content, + title: content, + }; + index++; + } + } + return quarters; + } + + render() { + const props = this.props; + const value = this.state.value; + const today = getTodayTime(value); + const quarters = this.quarters(); + const currentQuarter = value.quarter(); + const { prefixCls, locale, contentRender, cellRender } = props; + const quarterEls = quarters.map((quarter, index) => { + const tds = quarter.map(quarterData => { + let disabled = false; + if (props.disabledDate) { + const testValue = value.clone(); + testValue.quarter(quarterData.value); + disabled = props.disabledDate(testValue); + } + const classNameMap = { + [`${prefixCls}-cell`]: 1, + [`${prefixCls}-cell-disabled`]: disabled, + [`${prefixCls}-selected-cell`]: quarterData.value === currentQuarter, + [`${prefixCls}-current-cell`]: today.year() === value.year() && + quarterData.value === today.quarter(), + }; + let cellEl; + if (cellRender) { + const currentValue = value.clone(); + currentValue.quarter(quarterData.value); + cellEl = cellRender(currentValue, locale); + } else { + let content; + if (contentRender) { + const currentValue = value.clone(); + currentValue.quarter(quarterData.value); + content = contentRender(currentValue, locale); + } else { + content = quarterData.content; + } + cellEl = ( + + {content} + + ); + } + return ( +
); + }); + return ({tds}); + }); + + return ( +
+ {cellEl} +
+ + {quarterEls} + +
+ ); + } +} + +QuarterTable.defaultProps = { + onSelect: noop, +}; +QuarterTable.propTypes = { + onSelect: PropTypes.func, + cellRender: PropTypes.func, + prefixCls: PropTypes.string, + value: PropTypes.object, +}; + +export default QuarterTable; diff --git a/src/util/index.js b/src/util/index.js index 260f3ba8..80703712 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -12,6 +12,8 @@ const defaultDisabledTime = { }, }; +const quarterTitle = [['1季度', '2季度', '3季度', '4季度'], ['Q1', 'Q2', 'Q3', 'Q4']]; + export function getTodayTime(value) { const today = moment(); today.locale(value.locale()).utcOffset(value.utcOffset()); @@ -33,6 +35,12 @@ export function getMonthName(month) { return localeData[locale === 'zh-cn' ? 'months' : 'monthsShort'](month); } +export function getQuarterName(quarterM) { + const locale = quarterM.locale(); + const quarterN = quarterM.quarter(); + return quarterTitle[locale === 'zh-cn' ? 0 : 1][quarterN - 1]; +} + export function syncTime(from, to) { if (!moment.isMoment(from) || !moment.isMoment(to)) return; to.hour(from.hour()); diff --git a/tests/QuarterCalendar.spec.js b/tests/QuarterCalendar.spec.js new file mode 100644 index 00000000..f567e435 --- /dev/null +++ b/tests/QuarterCalendar.spec.js @@ -0,0 +1,108 @@ +/* eslint-disable no-undef */ +import React from 'react'; +import { mount } from 'enzyme'; +import keyCode from 'rc-util/lib/KeyCode'; +import moment from 'moment'; +import QuarterCalendar from '../src/QuarterCalendar'; + +describe('QuarterCalendar', () => { + it('year or decade panel work correctly', () => { + const format = 'YYYY-Q'; + const wrapper = mount(); + wrapper.find('.rc-calendar-quarter-panel-year-select').simulate('click'); + wrapper.find('.rc-calendar-year-panel-decade-select').simulate('click'); + wrapper.find('.rc-calendar-decade-panel-selected-cell').simulate('click'); + wrapper.find('.rc-calendar-year-panel-selected-cell').simulate('click'); + wrapper.find('.rc-calendar-quarter-panel-selected-cell').simulate('click'); + expect(wrapper.state('selectedValue').format(format)).toBe('2010-1'); + }); + describe('keyboard', () => { + let wrapper; + beforeEach(() => { + const selected = moment().add(1, 'Q'); + wrapper = mount(); + }); + + it('enter to select works', () => { + const onSelect = jest.fn(); + wrapper = mount(); + wrapper.simulate('keydown', { + keyCode: keyCode.ENTER, + }); + expect(onSelect).toHaveBeenCalledWith(wrapper.state('value'), undefined); + }); + + it('enter not to select disabled quarter', () => { + const onSelect = jest.fn(); + function disabledDate(current) { + if (!current) { + return false; + } + return true; + } + + wrapper = mount(); + wrapper.simulate('keydown', { + keyCode: keyCode.LEFT, + }); + wrapper.simulate('keydown', { + keyCode: keyCode.ENTER, + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('DOWN', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.DOWN, + }); + expect(wrapper.state().value.quarter()).toBe(2); + }); + + it('UP', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.UP, + }); + expect(wrapper.state().value.quarter()).toBe(2); + }); + + it('LEFT', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.LEFT, + }); + expect(wrapper.state().value.quarter()).toBe(1); + }); + + it('RIGHT', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.RIGHT, + }); + expect(wrapper.state().value.quarter()).toBe(3); + }); + + it('CTRL + LEFT', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.LEFT, + ctrlKey: 1, + }); + expect(wrapper.state().value.quarter()).toBe(2); + expect(wrapper.state().value.year()).toBe(2016); + }); + + it('CTRL + RIGHT', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.RIGHT, + ctrlKey: 1, + }); + expect(wrapper.state().value.quarter()).toBe(2); + expect(wrapper.state().value.year()).toBe(2018); + }); + + it('ignore other keys', () => { + wrapper.simulate('keydown', { + keyCode: keyCode.A, + }); + expect(wrapper.state().value.quarter()).toBe(2); + expect(wrapper.state().value.year()).toBe(2017); + }); + }); +});