模拟 AntD YearPicker

目前公司使用的是Ant Design 3.0, DatePicker mode="year" 时不支持 disabledDate 属性。
找到一篇模拟YearPicker的文章,但是不完全满足我的需求,在那篇文章的基础上进行了改造。


效果图

代码如下:
YearPicker.js

/**
 * 使用方法
 * 引入:
 * import YearPicker from "@/common/widget/YearPicker";//路径按照自己的来
<YearPicker 
  value={value}
  disabled={false} // 是否禁用时间控件
  disabledDate={timeLimit} // 禁用日期,参考disableDate 计算方式 
  callback={this.onChange} // DatePicker onChange 事件
  onBlur={this.onBlur} // 用于弹窗Input onBlur 事件
/>
*/
import React, { Component } from 'react';
import moment from 'moment';
import { Icon } from 'antd';
import Portal from './Portal';
import './YearPicker.less';

class YearPicker extends Component {
  static getDerivedStateFromProps(nextProps) {
    if ('value' in nextProps) {
      return {
        selectedyear: nextProps.value && nextProps.value != 'undefined'
          ? (nextProps.value._isAMomentObject ? nextProps.value.format('YYYY') : nextProps.value)
          : '',
      };
    }
    return {
      value: '',
    };
  }

  state = {
    isShow: false,
    selectedyear: this.props.value || null,
    listInputVal: '',
    years: [],
  }

  componentWillMount() {
    // document.removeEventListener('click', this.documentClick);
  }

  componentDidMount() {
    // document.addEventListener('click', this.documentClick, false);
  }

  documentClick = (e) => {
    const { isShow } = this.state;
    const clsName = e.target.className;
    if (
      clsName && typeof clsName == 'string' && clsName.indexOf('calendarX') === -1
      && e.target.tagName !== 'BUTTON'
      && isShow
    ) {
      this.hide();
    }
  }

  // 初始化数据处理
  initData = (defaultValue) => {
    const decade = parseInt(defaultValue / 10, 10) * 10;
    const start = decade - 1;
    const end = decade + 10;
    this.getYearsArr(start, end);
  };

  //   获取年份范围数组
  getYearsArr = (start, end) => {
    const arr = [];
    for (let i = start; i <= end; i++) {
      arr.push(Number(i));
    }
    this.setState({
      years: arr,
    });
  };

  //   获取日历Input所在位置
  getPosOfInput = (ele) => {
    const pos = ele.getBoundingClientRect();
    const { top, left } = pos;
    return { left, top: top || 0 };
  }

  // 显示日历年组件
  show = (e) => {
    const { left, top } = this.getPosOfInput(e.target);
    const { selectedyear } = this.state;
    this.initData(selectedyear || new Date().getFullYear());
    this.setState({
      isShow: true, left, top, listInputVal: selectedyear,
    });
    setTimeout(() => {
      // 展示弹窗时focus到input
      const inputFocus = document.getElementById('year-picker-id').getElementsByClassName('calendarX-modal-input');
      if (inputFocus && inputFocus[0]) inputFocus[0].focus();
    }, 50);
  };

  // 隐藏日期年组件
  hide = () => {
    this.setState({ isShow: false });
  };

  // 向前的年份
  prev = () => {
    const { years } = this.state;
    if (years[0] <= 1970) {
      return;
    }
    this.getNewYearRangestartAndEnd('prev');
  };

  // 向后的年份
  next = () => {
    this.getNewYearRangestartAndEnd('next');
  };

  //   获取新的年份
  getNewYearRangestartAndEnd = (type) => {
    const { years } = this.state;
    const start = Number(years[0]);
    const end = Number(years[years.length - 1]);
    let newstart;
    let newend;
    if (type == 'prev') {
      newstart = parseInt(start - 10, 10);
      newend = parseInt(end - 10, 10);
    }
    if (type == 'next') {
      newstart = parseInt(start + 10, 10);
      newend = parseInt(end + 10, 10);
    }
    this.getYearsArr(newstart, newend);
  };

  // 选中某一年
  selects = (e) => {
    const val = Number(e.target.value);
    this.hide();
    if (this.props.callback) {
      this.props.callback(String(val));
    }
  };

  getContainer = (domId = 'c-modal') => {
    const _this = this;
    const domContainer = document.createElement('div');
    domContainer.id = domId;
    domContainer.style.position = 'absolute';
    domContainer.style.top = '0';
    domContainer.style.left = '0';
    domContainer.style.width = '100%';
    domContainer.style.height = '100%';
    document.getElementsByTagName('body')[0].appendChild(domContainer);
    domContainer.onclick = (e) => {
      if (e.target == e.currentTarget) {
        _this.hide();
      }
    };
    return domContainer;
  }

  listInputChange = (e) => {
    if (e && e.target) {
      const val = e.target.value;
      this.setState({ listInputVal: val });
      if (val && /^([0-9]{4})$/.test(val)) {
        this.inputBlur(e);
        this.initData(val);
      }
    }
  }

  EnterKey = (e) => {
    if (e.keyCode == 13) {
      this.hide();
      this.inputBlur(e);
    }
  }

  inputBlur = (e) => {
    if (this.props.onBlur) this.props.onBlur(e);
  }

  render() {
    const {
      isShow, years, selectedyear, top, left, listInputVal,
    } = this.state;
    const { disabledDate, disabled } = this.props;
    return (
      <div className="calendarX-wrap">
        <div className="calendarX-input">
          <input
            className="calendarX-value"
            placeholder=""
            onFocus={this.show}
            value={selectedyear}
            readOnly
            disabled={disabled}
          />
          <Icon type="calendar" className="calendarX-icon" />
          {selectedyear && (
          <Icon
            type="close-circle"
            theme="filled"
            className="close-circle-icon"
            onClick={() => {
              if (this.props.callback) {
                this.props.callback(null);
              }
            }}
          />
          )}
        </div>
        {isShow ? (
          <Portal getContainer={() => this.getContainer('year-picker-id')}>
            <div style={{ position: 'absolute', left, top }}>
              <List
                data={years}
                value={selectedyear}
                prev={this.prev}
                next={this.next}
                cback={this.selects}
                disabledDate={disabledDate}
                inputChange={this.listInputChange}
                listInputVal={listInputVal}
                EnterKey={this.EnterKey}
                inputBlur={this.inputBlur}
              />
            </div>
          </Portal>
        ) : (
          ''
        )}
      </div>
    );
  }
}
const List = (props) => {
  const {
    data, value, prev, next, cback, disabledDate, inputChange,
    listInputVal, EnterKey, inputBlur,
  } = props;
  const start = data && data[1];
  const end = data && data[data.length - 2];
  return (
    <>
      <div className="calendarX-container">
        <div className="calendarX-input-wrap">
          <div className="calendarX-date-input-wrap">
            <input
              className="calendarX-modal-input"
              placeholder=""
              value={listInputVal}
              onChange={inputChange}
              onKeyDown={EnterKey}
              onBlur={inputBlur}
            />
          </div>
        </div>
        <div className="calendarX-head-year">
          <Icon
            type="double-left"
            className="calendarX-btn prev-btn"
            title=""
            onClick={prev}
          />
          <span className="calendarX-year-range">{`${start}-${end}`}</span>
          <Icon
            type="double-right"
            className="calendarX-btn next-btn"
            title=""
            onClick={next}
          />
        </div>
        <div className="calendarX-body-year">
          <ul className="calendarX-year-ul">
            {data.map((item, index) => {
              const isDisabled = disabledDate && disabledDate(moment(String(item)));
              const isFirst = index == 0;
              const isLast = index == data.length - 1;
              return (
                <li
                  key={index}
                  title={item}
                  className={
                `${item == value
                  ? 'calendarX-year-li calendarX-year-selected'
                  : 'calendarX-year-li'}${isFirst ? ' calendarX-year-last-decade-li'
                  : (isLast ? ' calendarX-year-next-decade-li' : '')}${
                  isDisabled ? ' calendarX-year-li-disabled' : ''
                }`
              }
                >
                  <button
                    type="button"
                    onClick={(e) => {
                      if (isDisabled) { return; }
                      if (isFirst) { prev(); return; }
                      if (isLast) { next(); return; }
                      cback(e);
                    }}
                    value={item}
                  >
                    {item}
                  </button>
                </li>
              );
            },
            )}
          </ul>
        </div>
      </div>
    </>
  );
};

export default YearPicker;

YearPicker.less

@focuscolor: #108ee9;
@bordercolor: #d9d9d9;/*这部分根据你自己的容器样式,我这个地方是因为公用组件的原因需要设置*/
#wrapper .toolbar {
  overflow: inherit !important;
}
#wrapper .toolbar > div:after {
  content: "";
  display: block;
  visibility: hidden;
  width: 0;
  clear: both;
}
/*---以下为必备样式----*/
:global {
    .calendarX-wrap {
        position: relative;
        .calendarX-input {
          width: 100%;
          position: relative;
          cursor: pointer;
          .calendarX-icon {
            position: absolute;
            right: 10px;
            top: 50%;
            margin-top: -7px;
            color: rgba(0, 0, 0, 0.25);
          }
          &:hover {
            .close-circle-icon {
                display: inline-block;
                transition: all 0.3s;
            } 
          }
          .close-circle-icon {
            display: none;
            position: absolute;
            right: 10px;
            top: 50%;
            margin-top: -7px;
            color: rgba(0, 0, 0, 0.25); 
            transition: all 0.3s;
            background-color: #fff;
          }
          input {
            width: 100%;
            height: 32px;
            border: 1px solid @bordercolor;
            border-radius: 4px;
            font-size: 14px;
            outline: none;
            display: block;
            padding: 4px 11px;
            transition: all 0.3s;
            &:hover:not(:disabled),
            &:active:not(:disabled) {
              border-color: #40a9ff;
            }
            &:disabled {
              color: rgba(0, 0, 0, 0.25);
              background-color: #f5f5f5;
              cursor: not-allowed;
              opacity: 1;
            }
          }
        }
      
      }

      
      .calendarX-container {
        position: relative;
        width: 280px;
        font-size: 14px;
        line-height: 1.5;
        text-align: left;
        list-style: none;
        background-color: #fff;
        background-clip: padding-box;
        border: 1px solid #fff;
        border-radius: 4px;
        outline: none;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
        z-index: 999;
      }
      .calendarX-head-year {
        height: 40px;
        line-height: 40px;
        text-align: center;
        width: 100%;
        position: relative;
        border-bottom: 1px solid #e8e8e8;
        .calendarX-year-range {
          padding: 0 2px;
          display: inline-block;
          color: rgba(0, 0, 0, 0.85);
          line-height: 34px;
        }
        .calendarX-btn {
          position: absolute;
          top: 0;
          color: #aaa;
          padding: 0 5px;
          font-size: 12px;
          display: inline-block;
          line-height: 34px;
          cursor: pointer;
          &:hover {
            color: @focuscolor;
          }
        }
        .prev-btn {
          left: 7px;
        }
        .next-btn {
          right: 7px;
        }
      }
      .calendarX-body-year {
        width: 100%;
        height: 218px;
        .calendarX-year-ul {
          list-style: none;
          .calendarX-year-li {
            float: left;
            text-align: center;
            width: 92px;
            > button {
              cursor: pointer;
              outline: none;
              border: 0;
              display: inline-block;
              margin: 0 auto;
              color: rgba(0, 0, 0, 0.65);
              background: transparent;
              text-align: center;
              height: 24px;
              line-height: 24px;
              padding: 0 8px;
              border-radius: 4px;
              transition: background 0.3s ease;
              margin: 14px 0;
              &:hover {
                color: @focuscolor;
              }
            }
            &::before {
                
            }
            &.calendarX-year-li-disabled {
                position: relative;
                cursor: not-allowed;
                &::before {
                    background: rgba(0, 0, 0, 0.04);
                    position: absolute;
                    top: 50%;
                    right: 0;
                    left: 0;
                    z-index: 1;
                    height: 24px;
                    transform: translateY(-50%);
                    transition: all 0.3s;
                    content: '';
                }
                > button {
                    color: rgba(0, 0, 0, 0.25);
                }
            }
          }
          .calendarX-year-selected {
            > button {
              background: #108ee9;
              color: #fff !important;
              &:hover {
                color: #fff;
              }
            }
          }
          .calendarX-year-last-decade-li, .calendarX-year-next-decade-li {
            > button {
                color: rgba(0, 0, 0, 0.25);
            }
          }
        }
      }
    .calendarX-input-wrap {
        height: 34px;
        padding: 6px 10px;
        border-bottom: 1px solid #e8e8e8;
        .calendarX-input {
            width: 100%;
            height: 22px;
            color: rgba(0, 0, 0, 0.65);
            background: #fff;
            border: 0;
            outline: 0;
            cursor: auto;
        }
    }
    .calendarX-modal-input {
      width: 100%;
      height: 22px;
      color: rgba(0, 0, 0, 0.65);
      background: #fff;
      border: 0;
      outline: 0;
      cursor: auto;
    }
}

Portal.js

import React from 'react';
import ReactDOM from 'react-dom';

/**
 * @function getContainer 渲染组件的父组件
 * @param children 需要渲染的组件
 * @export
 * @class Portal
 * @extends {React.Component}
 */
export default class Portal extends React.Component {
  componentDidMount() {
    this.createContainer();
  }

  componentDidUpdate() {
    // React版本较低,不使用ReactDOM.createPortal
    ReactDOM.unstable_renderSubtreeIntoContainer(
      this,
      this.props.children,
      this._container,
    );
  }

  componentWillUnmount() {
    this.removeContainer();
  }

  createContainer() {
    this._container = this.props.getContainer();
    this.forceUpdate();
  }

  removeContainer() {
    if (this._container) {
      this._container.parentNode.removeChild(this._container);
    }
  }

  render() {
    return null;
  }
}

disableDate 计算方式(也可用于禁用日期)

disabledDateBeforeToday = (current, format) => { // 禁止今年以前的年份(不包含今年)
      return current && current < moment(moment().startOf('day').format(format));
}

disabledDateAfterToday = (current) => { // 禁止今年之后的年份(不包含今年)
      return current && current >= moment().endOf('day');
}

问题1:本来关闭弹窗用的是document绑定事件,但是当在一个页面里存在多个YearPicker,打开其中一个选择弹窗,再点击其他YearPicker,会同时打开多个弹窗,所以使用 ReactDOM.createPortal 将整个选择的组件与input框隔离成独立的部分,采用透明全屏遮罩层的方式,检测input在窗口中的位置来设置展示组件的位置,这样就可以点击任意位置关闭组件,且只出现一个弹窗。

问题2:由于React版本问题,ReactDOM.createPortal 不支持。使用ReactDOM.unstable_renderSubtreeIntoContainer 将 YearPicker 加在 body 下。

借鉴文章:
时间选择控件YearPicker(基于React,antd)
React如何将组件渲染到指定节点—ReactDOM.createPortal

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容