datetime/datetime.vue

<template>
    <div class="ion-datetime" @click="clickHandler($event)"
         :displayFormat="displayFormat"
         :pickerFormat="pickerFormat"
         :min="min"
         :max="max">
        <div v-if="!text" class="datetime-text datetime-placeholder">{{placeholder}}</div>
        <div v-if="text" class="datetime-text">{{text}}</div>
        <vm-button type="button"
                   :id="_uid"
                   role="item-cover"
                   class="item-cover">
        </vm-button>
    </div>
</template>
<style lang="scss" src="./style.scss"></style>
<script type="text/javascript">
  /**
   * @component Datetime
   * @description
   *
   * ## 时间组件 / Datetime时间选择组件
   *
   * ### 简介
   * Datetime组件是对Picker组件的再封装, 这个和Select组件对Alert组件的封装类似. Datetime组件用于替代原生input[type="datetime"]的解决方案, 且功能比原生更丰富. 支持单列~多列时间的选择/固定时间范围/固定时间的选择间隔等.
   *
   * ### 感谢Ionic
   *
   * 该组件是对Ionic的Datetime组件的转义, 具体使用和Ionic的datetime完全一致. API参考下方链接.
   *
   * ### 改进部分
   * Ionic原组件值对符合ISO格式的日期能正确显示使用, 这里做了改进:
   *
   * 通过v-model可以传入如下类型: **Date日期对象/ISO格式的时间String/能转化为Date对象**的字符串 这三类. 但是v-model返回的数据都是ISO格式日期String, 如果期望返回每个column返回的详细结果, 请监听onChange事件.
   *
   * ### 如何引入
   * ```
   * // 引入
   * import { Datetime } from 'vimo'
   * // 安装
   * Vue.component(Datetime.name, Datetime)
   * // 或者
   * export default{
   *   components: {
   *     Datetime
   *  }
   * }
   * ```
   *
   * @usage
   * <Item>
   *    <Label>MMMM</Label>
   *    <Datetime slot="item-right" displayFormat="MMMM" v-model="monthOnly"></Datetime>
   * </Item>
   *
   * @props {String} [min] - ISO 8601 datetime 的时间格式, 1996-12-19
   * @props {String} [max] - ISO 8601 datetime 的时间格式, 1996-12-19
   * @props {String} displayFormat - 外部 显示的格式
   * @props {String} [pickerFormat] - picker 显示的格式
   * @props {String} [placeholder] - placeholder
   * @props {String|Object|Date} value - value
   * @props {String} [cancelText='取消'] - 取消的显示文本
   * @props {String} [doneText='确认'] - 确定的显示文本
   * @props {String|Array} [yearValues] - 显示可以选择的 年 信息, 例如: "2024,2020,2016,2012,2008"
   * @props {String|Array} [monthValues] - 显示可以选择的 月 信息, 例如: "6,7,8"
   * @props {String|Array} [dayValues] - 显示可以选择的 月 信息, 例如: "6,7,8"
   * @props {String|Array} [hourValues] - 显示可以选择的 小时 信息,
   * @props {String|Array} [minuteValues] - 显示可以选择的 分钟 信息,
   * @props {String|Array} [monthNames] - 每个月 的名字
   * @props {String|Array} [monthShortNames] - 每个月 的短名字
   * @props {String|Array} [dayNames] - 每天 的显示名字
   * @props {String|Array} [dayShortNames] - 每天 的短名字
   * @props {Object} [pickerOptions] - pickerOptions
   * @props {String} [mode='ios'] - mode
   *
   *
   * @see http://ionicframework.com/docs/demos/src/datetime/www/?production=true&ionicplatform=ios
   * @see http://ionicframework.com/docs/api/components/datetime/DateTime/
   * @demo #/time-picker
   * @fires component:Datetime#onCancel
   * @fires component:Datetime#onChange
   * */
  import { setElementClass } from '../../util/util'
  import { isBlank, isPresent, isTrueProperty } from '../../util/type'
  import Picker from '../picker'
  import Button from '../button'
  import {
    convertDataToISO,
    convertFormatToKey,
    convertToArrayOfNumbers,
    convertToArrayOfStrings,
    dateDataSortValue,
    dateSortValue,
    dateValueRange,
    daysInMonth,
    getValueFromFormat,
    parseDate,
    parseTemplate,
    renderDateTime,
    renderTextFormat,
    updateDate
  } from './datetime-util'

  const DEFAULT_FORMAT = 'MMM D, YYYY'
  // const DEFAULT_FORMAT = 'YYYY/MM/DD'
  export default {
    name: 'Datetime',
    inject: {
      itemComponent: {
        from: 'itemComponent',
        default: null
      }
    },
    data () {
      return {
        theDisabled: this.disabled,
        text: '',
        theMin: this.min,
        theMax: this.max,
        theValue: {},
        locale: {}
      }
    },
    props: {
      min: String,                      //  ISO 8601 datetime 的时间格式, 1996-12-19
      max: String,                      //  ISO 8601 datetime 的时间格式, 1996-12-19

      displayFormat: String,            // 外部 显示的格式
      pickerFormat: String,             // picker 显示的格式
      placeholder: String,
      value: [String, Object, Date],

      cancelText: {                     // 取消的显示文本
        type: String,
        default: '取消'
      },
      doneText: {                       // 确定的显示文本
        type: String,
        default: '确认'
      },

      yearValues: [String, Array],      // 显示可以选择的 年 信息, 例如: "2024,2020,2016,2012,2008"
      monthValues: [String, Array],     // 显示可以选择的 月 信息, 例如: "6,7,8"
      dayValues: [String, Array],       // 显示可以选择的 日 信息, 例如: "6,7,8"
      hourValues: [String, Array],      // 显示可以选择的 小时 信息,
      minuteValues: [String, Array],    // 显示可以选择的 分钟 信息,

      monthNames: [String, Array],               // 每个月 的名字
      monthShortNames: [String, Array],          // 每个月 的短名字
      dayNames: [String, Array],                  // 每天 的显示名字
      dayShortNames: [String, Array],            // 每天 的显示名字

      pickerOptions: {
        type: Object,
        default () { return {} }
      },

      mode: {
        type: String,
        default () { return this.$config && this.$config.get('mode') || 'ios' }
      }
    },
    watch: {
      disabled (val) {
        this.theDisabled = isTrueProperty(val)
        this.itemComponent && setElementClass(this.itemComponent.$el, 'item-datetime-disabled', this.theDisabled)
      },
      value (val) {
        this.theValue = parseDate(val)
        this.onChange(val)
      }
    },
    methods: {
      clickHandler ($event) {
        if ($event.detail === 0) {
          // do not continue if the click event came from a form submit
          return
        }
        $event.preventDefault()
        $event.stopPropagation()
        this.open()
      },

      open () {
        if (this.theDisabled) {
          return
        }

        // the user may have assigned some options specifically for the alert
        const pickerOptions = JSON.parse(JSON.stringify(this.pickerOptions))

        pickerOptions.buttons = [
          {
            text: this.cancelText,
            role: 'cancel',
            handler: () => {
              /**
               * @event component:Datetime#onCancel
               * @description 点击取消按钮触发
               */
              this.$emit('onCancel', null)
            }
          },
          {
            text: this.doneText,
            handler: (data) => {
              console.debug('datetime, done', data)
              this.onChange(data)
              let value = convertDataToISO(this.theValue)
              /**
               * @event component:Datetime#onChange
               * @description 点击确定按钮
               */
              this.$emit('input', value)
              this.$emit('onChange', data)
            }
          }
        ]
        pickerOptions.columns = []

        this.generate(pickerOptions)
        this.validate(pickerOptions)

        pickerOptions.onChange = () => {
          this.validate(pickerOptions)
          Picker.refresh()
        }
        pickerOptions.onDismiss = () => {}
        pickerOptions.isH5 = true // 强制使用h5模式

        Picker.present(pickerOptions)

        Picker.refresh()
      },

      /**
       * @private
       */
      generate (pickerOptions) {
        // if a picker format wasn't provided, then fallback
        // to use the display format
        let template = this.pickerFormat || this.displayFormat || DEFAULT_FORMAT

        if (isPresent(template)) {
          // make sure we've got up to date sizing information
          this.calcMinMax()

          // does not support selecting by day name
          // automaticallly remove any day name formats
          template = template.replace('DDDD', '{~}').replace('DDD', '{~}')
          if (template.indexOf('D') === -1) {
            // there is not a day in the template
            // replace the day name with a numeric one if it exists
            template = template.replace('{~}', 'D')
          }
          // make sure no day name replacer is left in the string
          template = template.replace(/{~}/g, '')

          // parse apart the given template into an array of "formats"
          parseTemplate(template).forEach(format => {
            // loop through each format in the template
            // create a new picker column to build up with data
            let key = convertFormatToKey(format)
            let values

            // first see if they have exact values to use for this input
            if (isPresent((this)[key + 'Values'])) {
              // user provide exact values for this date part
              values = convertToArrayOfNumbers((this)[key + 'Values'], key)
            } else {
              // use the default date part values
              values = dateValueRange(format, this.theMin, this.theMax)
            }

            let column = {
              name: key,
              selectedIndex: 0,
              options: values.map(val => {
                return {
                  value: val,
                  text: renderTextFormat(format, val, null, this.locale)
                }
              })
            }

            if (column.options.length) {
              // cool, we've loaded up the columns with options
              // preselect the option for this column
              var selected
              if (this.theValue) {
                selected = column.options.find(opt => opt.value === getValueFromFormat(this.theValue, format))
              }
              if (selected) {
                // set the select index for this column's options
                column.selectedIndex = column.options.indexOf(selected)
              }

              // add our newly created column to the picker
              pickerOptions.columns.push(column)
            }
          })

          this.divyColumns(pickerOptions)
        }
      },

      /**
       * @private
       */
      validate (pickerOptions) {
        let i
        let today = new Date()
        let columns = pickerOptions.columns

        // find the columns used
        let yearCol = columns.find(col => col.name === 'year')
        let monthCol = columns.find(col => col.name === 'month')
        let dayCol = columns.find(col => col.name === 'day')

        let yearOpt
        let monthOpt
        let dayOpt

        // default to the current year
        let selectedYear = today.getFullYear()

        if (yearCol) {
          // default to the first value if the current year doesn't exist in the options
          if (!yearCol.options.find(col => col.value === today.getFullYear())) {
            selectedYear = yearCol.options[0].value
          }

          yearOpt = yearCol.options[yearCol.selectedIndex]
          if (yearOpt) {
            // they have a selected year value
            selectedYear = yearOpt.value
          }
        }

        // default to assuming this month has 31 days
        let numDaysInMonth = 31
        let selectedMonth
        if (monthCol) {
          monthOpt = monthCol.options[monthCol.selectedIndex]
          if (monthOpt) {
            // they have a selected month value
            selectedMonth = monthOpt.value

            // calculate how many days are in this month
            numDaysInMonth = daysInMonth(selectedMonth, selectedYear)
          }
        }

        // create sort values for the min/max datetimes
        let minCompareVal = dateDataSortValue(this.theMin)
        let maxCompareVal = dateDataSortValue(this.theMax)

        if (monthCol) {
          // enable/disable which months are valid
          // to show within the min/max date range
          for (i = 0; i < monthCol.options.length; i++) {
            monthOpt = monthCol.options[i]

            // loop through each month and see if it
            // is within the min/max date range
            monthOpt.disabled = (dateSortValue(selectedYear, monthOpt.value, 31) < minCompareVal ||
              dateSortValue(selectedYear, monthOpt.value, 1) > maxCompareVal)
          }
        }

        if (dayCol) {
          if (isPresent(selectedMonth)) {
            // enable/disable which days are valid
            // to show within the min/max date range
            for (i = 0; i < dayCol.options.length; i++) {
              dayOpt = dayCol.options[i]

              // loop through each day and see if it
              // is within the min/max date range
              var compareVal = dateSortValue(selectedYear, selectedMonth, dayOpt.value)

              dayOpt.disabled = (compareVal < minCompareVal ||
                compareVal > maxCompareVal ||
                numDaysInMonth <= i)
            }
          } else {
            // enable/disable which numbers of days to show in this month
            for (i = 0; i < dayCol.options.length; i++) {
              dayCol.options[i].disabled = (numDaysInMonth <= i)
            }
          }
        }
      },

      /**
       * 制作picker的columns
       * @private
       */
      divyColumns (pickerOptions) {
        let pickerColumns = pickerOptions.columns
        let columns = []

        pickerColumns.forEach((col, i) => {
          columns.push(0)

          col.options.forEach(opt => {
            if (opt.text.length > columns[i]) {
              columns[i] = opt.text.length
            }
          })
        })

        if (columns.length === 2) {
          let width = Math.max(columns[0], columns[1])
          pickerColumns[0].align = 'right'
          pickerColumns[1].align = 'left'
          pickerColumns[0].optionsWidth = pickerColumns[1].optionsWidth = `${width * 17}px`
        } else if (columns.length === 3) {
          let width = Math.max(columns[0], columns[2])
          pickerColumns[0].align = 'right'
          pickerColumns[1].columnWidth = `${columns[1] * 17}px`
          pickerColumns[0].optionsWidth = pickerColumns[2].optionsWidth = `${width * 17}px`
          pickerColumns[2].align = 'left'
        }
      },

      /**
       * @private
       */
      setValue (newData) {
        this.theValue = updateDate(this.theValue, newData)
      },

      /**
       * @private
       */
      checkHasValue (inputValue) {
        if (this.itemComponent) {
          setElementClass(this.itemComponent.$el, 'input-has-value', !!(inputValue && inputValue !== ''))
        }
      },

      /**
       * @private
       */
      updateText () {
        // create the text of the formatted data
        const template = this.displayFormat || this.pickerFormat || DEFAULT_FORMAT
        this.text = renderDateTime(template, this.theValue, this.locale)
      },

      /**
       * @private
       */
      onChange (val) {
        console.debug('datetime, onChange w/out formControlName', val)
        this.setValue(val)
        this.updateText()
        this.checkHasValue(val)
      },

      /**
       * @private
       */
      calcMinMax (now) {
        this.theMin = this.min
        this.theMax = this.max

        const todaysYear = (now || new Date()).getFullYear()
        if (isBlank(this.theMin)) {
          if (isPresent(this.yearValues)) {
            this.theMin = Math.min.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year'))
          } else {
            this.theMin = (todaysYear - 100).toString()
          }
        }

        if (isBlank(this.theMax)) {
          if (isPresent(this.yearValues)) {
            this.theMax = Math.max.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year'))
          } else {
            this.theMax = todaysYear.toString()
          }
        }

        const min = this.theMin = parseDate(this.theMin)
        const max = this.theMax = parseDate(this.theMax)

        if (min.year > max.year) {
          min.year = max.year - 100
        } else if (min.year === max.year) {
          if (min.month > max.month) {
            min.month = 1
          } else if (min.month === max.month && min.day > max.day) {
            min.day = 1
          }
        }

        min.month = min.month || 1
        min.day = min.day || 1
        min.hour = min.hour || 0
        min.minute = min.minute || 0
        min.second = min.second || 0

        max.month = max.month || 12
        max.day = max.day || 31
        max.hour = max.hour || 23
        max.minute = max.minute || 59
        max.second = max.second || 59
      }
    },
    created () {
      this.theValue = parseDate(this.value)
    },
    mounted () {
      if (this.itemComponent) {
        setElementClass(this.itemComponent.$el, 'item-datetime', true)
      }

      // first see if locale names were provided in the inputs
      // then check to see if they're in the config
      // if neither were provided then it will use default English names
      const NAMES = ['monthNames', 'monthShortNames', 'dayNames', 'dayShortNames']
      NAMES.forEach(type => {
        (this).locale[type] = convertToArrayOfStrings(isPresent((this)[type]) ? (this)[type] : this.$config && this.$config.get(type), type)
      })

      // update how the datetime value is displayed as formatted text
      this.updateText()
      this.checkHasValue(this.theValue)
    },
    components: {'vm-button': Button}
  }
</script>