form-datepicker.js 9.26 KB
import { extend } from '../../vue'
import { NAME_FORM_DATEPICKER } from '../../constants/components'
import { EVENT_NAME_CONTEXT, EVENT_NAME_HIDDEN, EVENT_NAME_SHOWN } from '../../constants/events'
import { PROP_TYPE_BOOLEAN, PROP_TYPE_DATE_STRING, PROP_TYPE_STRING } from '../../constants/props'
import { SLOT_NAME_BUTTON_CONTENT } from '../../constants/slots'
import { createDate, constrainDate, formatYMD, parseYMD } from '../../utils/date'
import { attemptBlur, attemptFocus } from '../../utils/dom'
import { isUndefinedOrNull } from '../../utils/inspect'
import { makeModelMixin } from '../../utils/model'
import { omit, pick, sortKeys } from '../../utils/object'
import { makeProp, makePropsConfigurable, pluckProps } from '../../utils/props'
import { idMixin, props as idProps } from '../../mixins/id'
import { BIconCalendar, BIconCalendarFill } from '../../icons/icons'
import { BButton } from '../button/button'
import { BCalendar, props as BCalendarProps } from '../calendar/calendar'
import {
  BVFormBtnLabelControl,
  props as BVFormBtnLabelControlProps
} from '../form-btn-label-control/bv-form-btn-label-control'

// --- Constants ---

const {
  mixin: modelMixin,
  props: modelProps,
  prop: MODEL_PROP_NAME,
  event: MODEL_EVENT_NAME
} = makeModelMixin('value', { type: PROP_TYPE_DATE_STRING })

// --- Props ---

const calendarProps = omit(BCalendarProps, [
  'block',
  'hidden',
  'id',
  'noKeyNav',
  'roleDescription',
  'value',
  'width'
])

const formBtnLabelControlProps = omit(BVFormBtnLabelControlProps, [
  'formattedValue',
  'id',
  'lang',
  'rtl',
  'value'
])

export const props = makePropsConfigurable(
  sortKeys({
    ...idProps,
    ...modelProps,
    ...calendarProps,
    ...formBtnLabelControlProps,
    // Width of the calendar dropdown
    calendarWidth: makeProp(PROP_TYPE_STRING, '270px'),
    closeButton: makeProp(PROP_TYPE_BOOLEAN, false),
    closeButtonVariant: makeProp(PROP_TYPE_STRING, 'outline-secondary'),
    // Dark mode
    dark: makeProp(PROP_TYPE_BOOLEAN, false),
    labelCloseButton: makeProp(PROP_TYPE_STRING, 'Close'),
    labelResetButton: makeProp(PROP_TYPE_STRING, 'Reset'),
    labelTodayButton: makeProp(PROP_TYPE_STRING, 'Select today'),
    noCloseOnSelect: makeProp(PROP_TYPE_BOOLEAN, false),
    resetButton: makeProp(PROP_TYPE_BOOLEAN, false),
    resetButtonVariant: makeProp(PROP_TYPE_STRING, 'outline-danger'),
    resetValue: makeProp(PROP_TYPE_DATE_STRING),
    todayButton: makeProp(PROP_TYPE_BOOLEAN, false),
    todayButtonVariant: makeProp(PROP_TYPE_STRING, 'outline-primary')
  }),
  NAME_FORM_DATEPICKER
)

// --- Main component ---

// @vue/component
export const BFormDatepicker = /*#__PURE__*/ extend({
  name: NAME_FORM_DATEPICKER,
  mixins: [idMixin, modelMixin],
  props,
  data() {
    return {
      // We always use `YYYY-MM-DD` value internally
      localYMD: formatYMD(this[MODEL_PROP_NAME]) || '',
      // If the popup is open
      isVisible: false,
      // Context data from BCalendar
      localLocale: null,
      isRTL: false,
      formattedValue: '',
      activeYMD: ''
    }
  },
  computed: {
    calendarYM() {
      // Returns the calendar year/month
      // Returns the `YYYY-MM` portion of the active calendar date
      return this.activeYMD.slice(0, -3)
    },
    computedLang() {
      return (this.localLocale || '').replace(/-u-.*$/i, '') || null
    },
    computedResetValue() {
      return formatYMD(constrainDate(this.resetValue)) || ''
    }
  },
  watch: {
    [MODEL_PROP_NAME](newValue) {
      this.localYMD = formatYMD(newValue) || ''
    },
    localYMD(newValue) {
      // We only update the v-model when the datepicker is open
      if (this.isVisible) {
        this.$emit(MODEL_EVENT_NAME, this.valueAsDate ? parseYMD(newValue) || null : newValue || '')
      }
    },
    calendarYM(newValue, oldValue) {
      // Displayed calendar month has changed
      // So possibly the calendar height has changed...
      // We need to update popper computed position
      if (newValue !== oldValue && oldValue) {
        try {
          this.$refs.control.updatePopper()
        } catch {}
      }
    }
  },
  methods: {
    // Public methods
    focus() {
      if (!this.disabled) {
        attemptFocus(this.$refs.control)
      }
    },
    blur() {
      if (!this.disabled) {
        attemptBlur(this.$refs.control)
      }
    },
    // Private methods
    setAndClose(ymd) {
      this.localYMD = ymd
      // Close calendar popup, unless `noCloseOnSelect`
      if (!this.noCloseOnSelect) {
        this.$nextTick(() => {
          this.$refs.control.hide(true)
        })
      }
    },
    onSelected(ymd) {
      this.$nextTick(() => {
        this.setAndClose(ymd)
      })
    },
    onInput(ymd) {
      if (this.localYMD !== ymd) {
        this.localYMD = ymd
      }
    },
    onContext(ctx) {
      const { activeYMD, isRTL, locale, selectedYMD, selectedFormatted } = ctx
      this.isRTL = isRTL
      this.localLocale = locale
      this.formattedValue = selectedFormatted
      this.localYMD = selectedYMD
      this.activeYMD = activeYMD
      // Re-emit the context event
      this.$emit(EVENT_NAME_CONTEXT, ctx)
    },
    onTodayButton() {
      // Set to today (or min/max if today is out of range)
      this.setAndClose(formatYMD(constrainDate(createDate(), this.min, this.max)))
    },
    onResetButton() {
      this.setAndClose(this.computedResetValue)
    },
    onCloseButton() {
      this.$refs.control.hide(true)
    },
    // Menu handlers
    onShow() {
      this.isVisible = true
    },
    onShown() {
      this.$nextTick(() => {
        attemptFocus(this.$refs.calendar)
        this.$emit(EVENT_NAME_SHOWN)
      })
    },
    onHidden() {
      this.isVisible = false
      this.$emit(EVENT_NAME_HIDDEN)
    },
    // Render helpers
    defaultButtonFn({ isHovered, hasFocus }) {
      return this.$createElement(isHovered || hasFocus ? BIconCalendarFill : BIconCalendar, {
        attrs: { 'aria-hidden': 'true' }
      })
    }
  },
  render(h) {
    const { localYMD, disabled, readonly, dark, $props, $scopedSlots } = this
    const placeholder = isUndefinedOrNull(this.placeholder)
      ? this.labelNoDateSelected
      : this.placeholder

    // Optional footer buttons
    let $footer = []

    if (this.todayButton) {
      const label = this.labelTodayButton
      $footer.push(
        h(
          BButton,
          {
            props: {
              disabled: disabled || readonly,
              size: 'sm',
              variant: this.todayButtonVariant
            },
            attrs: { 'aria-label': label || null },
            on: { click: this.onTodayButton }
          },
          label
        )
      )
    }

    if (this.resetButton) {
      const label = this.labelResetButton
      $footer.push(
        h(
          BButton,
          {
            props: {
              disabled: disabled || readonly,
              size: 'sm',
              variant: this.resetButtonVariant
            },
            attrs: { 'aria-label': label || null },
            on: { click: this.onResetButton }
          },
          label
        )
      )
    }

    if (this.closeButton) {
      const label = this.labelCloseButton
      $footer.push(
        h(
          BButton,
          {
            props: {
              disabled,
              size: 'sm',
              variant: this.closeButtonVariant
            },
            attrs: { 'aria-label': label || null },
            on: { click: this.onCloseButton }
          },
          label
        )
      )
    }

    if ($footer.length > 0) {
      $footer = [
        h(
          'div',
          {
            staticClass: 'b-form-date-controls d-flex flex-wrap',
            class: {
              'justify-content-between': $footer.length > 1,
              'justify-content-end': $footer.length < 2
            }
          },
          $footer
        )
      ]
    }

    const $calendar = h(
      BCalendar,
      {
        staticClass: 'b-form-date-calendar w-100',
        props: {
          ...pluckProps(calendarProps, $props),
          hidden: !this.isVisible,
          value: localYMD,
          valueAsDate: false,
          width: this.calendarWidth
        },
        on: {
          selected: this.onSelected,
          input: this.onInput,
          context: this.onContext
        },
        scopedSlots: pick($scopedSlots, [
          'nav-prev-decade',
          'nav-prev-year',
          'nav-prev-month',
          'nav-this-month',
          'nav-next-month',
          'nav-next-year',
          'nav-next-decade'
        ]),
        key: 'calendar',
        ref: 'calendar'
      },
      $footer
    )

    return h(
      BVFormBtnLabelControl,
      {
        staticClass: 'b-form-datepicker',
        props: {
          ...pluckProps(formBtnLabelControlProps, $props),
          formattedValue: localYMD ? this.formattedValue : '',
          id: this.safeId(),
          lang: this.computedLang,
          menuClass: [{ 'bg-dark': dark, 'text-light': dark }, this.menuClass],
          placeholder,
          rtl: this.isRTL,
          value: localYMD
        },
        on: {
          show: this.onShow,
          shown: this.onShown,
          hidden: this.onHidden
        },
        scopedSlots: {
          [SLOT_NAME_BUTTON_CONTENT]: $scopedSlots[SLOT_NAME_BUTTON_CONTENT] || this.defaultButtonFn
        },
        ref: 'control'
      },
      [$calendar]
    )
  }
})