bootstrap-vue
Version:
With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens
602 lines (576 loc) • 18.4 kB
JavaScript
// BTime control (not form input control)
import { extend, REF_FOR_KEY } from '../../vue'
import { NAME_TIME } from '../../constants/components'
import { EVENT_NAME_CONTEXT } from '../../constants/events'
import { CODE_LEFT, CODE_RIGHT } from '../../constants/key-codes'
import {
PROP_TYPE_ARRAY_STRING,
PROP_TYPE_BOOLEAN,
PROP_TYPE_NUMBER_STRING,
PROP_TYPE_STRING
} from '../../constants/props'
import { RX_TIME } from '../../constants/regex'
import { concat } from '../../utils/array'
import { createDate, createDateFormatter } from '../../utils/date'
import { attemptBlur, attemptFocus, contains, getActiveElement, requestAF } from '../../utils/dom'
import { stopEvent } from '../../utils/events'
import { identity } from '../../utils/identity'
import { isNull, isUndefinedOrNull } from '../../utils/inspect'
import { looseEqual } from '../../utils/loose-equal'
import { isLocaleRTL } from '../../utils/locale'
import { makeModelMixin } from '../../utils/model'
import { toInteger } from '../../utils/number'
import { pick, sortKeys } from '../../utils/object'
import { makeProp, makePropsConfigurable } from '../../utils/props'
import { toString } from '../../utils/string'
import { idMixin, props as idProps } from '../../mixins/id'
import { normalizeSlotMixin } from '../../mixins/normalize-slot'
import { BFormSpinbutton, props as BFormSpinbuttonProps } from '../form-spinbutton/form-spinbutton'
import { BIconCircleFill, BIconChevronUp } from '../../icons/icons'
// --- Constants ---
const {
mixin: modelMixin,
props: modelProps,
prop: MODEL_PROP_NAME,
event: MODEL_EVENT_NAME
} = makeModelMixin('value', {
type: PROP_TYPE_STRING,
defaultValue: ''
})
const NUMERIC = 'numeric'
// --- Helper methods ---
const padLeftZeros = value => `00${value || ''}`.slice(-2)
const parseHMS = value => {
value = toString(value)
let [hh, mm, ss] = [null, null, null]
if (RX_TIME.test(value)) {
;[hh, mm, ss] = value.split(':').map(v => toInteger(v, null))
}
return {
hours: isUndefinedOrNull(hh) ? null : hh,
minutes: isUndefinedOrNull(mm) ? null : mm,
seconds: isUndefinedOrNull(ss) ? null : ss,
ampm: isUndefinedOrNull(hh) || hh < 12 ? 0 : 1
}
}
const formatHMS = ({ hours, minutes, seconds }, requireSeconds = false) => {
if (isNull(hours) || isNull(minutes) || (requireSeconds && isNull(seconds))) {
return ''
}
const hms = [hours, minutes, requireSeconds ? seconds : 0]
return hms.map(padLeftZeros).join(':')
}
// --- Props ---
export const props = makePropsConfigurable(
sortKeys({
...idProps,
...modelProps,
...pick(BFormSpinbuttonProps, ['labelIncrement', 'labelDecrement']),
// ID of label element
ariaLabelledby: makeProp(PROP_TYPE_STRING),
disabled: makeProp(PROP_TYPE_BOOLEAN, false),
footerTag: makeProp(PROP_TYPE_STRING, 'footer'),
headerTag: makeProp(PROP_TYPE_STRING, 'header'),
hidden: makeProp(PROP_TYPE_BOOLEAN, false),
hideHeader: makeProp(PROP_TYPE_BOOLEAN, false),
// Explicitly force 12 or 24 hour time
// Default is to use resolved locale for 12/24 hour display
// Tri-state: `true` = 12, `false` = 24, `null` = auto
hour12: makeProp(PROP_TYPE_BOOLEAN, null),
labelAm: makeProp(PROP_TYPE_STRING, 'AM'),
labelAmpm: makeProp(PROP_TYPE_STRING, 'AM/PM'),
labelHours: makeProp(PROP_TYPE_STRING, 'Hours'),
labelMinutes: makeProp(PROP_TYPE_STRING, 'Minutes'),
labelNoTimeSelected: makeProp(PROP_TYPE_STRING, 'No time selected'),
labelPm: makeProp(PROP_TYPE_STRING, 'PM'),
labelSeconds: makeProp(PROP_TYPE_STRING, 'Seconds'),
labelSelected: makeProp(PROP_TYPE_STRING, 'Selected time'),
locale: makeProp(PROP_TYPE_ARRAY_STRING),
minutesStep: makeProp(PROP_TYPE_NUMBER_STRING, 1),
readonly: makeProp(PROP_TYPE_BOOLEAN, false),
secondsStep: makeProp(PROP_TYPE_NUMBER_STRING, 1),
// If `true`, show the second spinbutton
showSeconds: makeProp(PROP_TYPE_BOOLEAN, false)
}),
NAME_TIME
)
// --- Main component ---
// @vue/component
export const BTime = /*#__PURE__*/ extend({
name: NAME_TIME,
mixins: [idMixin, modelMixin, normalizeSlotMixin],
props,
data() {
const parsed = parseHMS(this[MODEL_PROP_NAME] || '')
return {
// Spin button models
modelHours: parsed.hours,
modelMinutes: parsed.minutes,
modelSeconds: parsed.seconds,
modelAmpm: parsed.ampm,
// Internal flag to enable aria-live regions
isLive: false
}
},
computed: {
computedHMS() {
const hours = this.modelHours
const minutes = this.modelMinutes
const seconds = this.modelSeconds
return formatHMS({ hours, minutes, seconds }, this.showSeconds)
},
resolvedOptions() {
// Resolved locale options
const locale = concat(this.locale).filter(identity)
const options = {
hour: NUMERIC,
minute: NUMERIC,
second: NUMERIC
}
if (!isUndefinedOrNull(this.hour12)) {
// Force 12 or 24 hour clock
options.hour12 = !!this.hour12
}
const dtf = new Intl.DateTimeFormat(locale, options)
const resolved = dtf.resolvedOptions()
const hour12 = resolved.hour12 || false
// IE 11 doesn't resolve the hourCycle, so we make
// an assumption and fall back to common values
const hourCycle = resolved.hourCycle || (hour12 ? 'h12' : 'h23')
return {
locale: resolved.locale,
hour12,
hourCycle
}
},
computedLocale() {
return this.resolvedOptions.locale
},
computedLang() {
return (this.computedLocale || '').replace(/-u-.*$/, '')
},
computedRTL() {
return isLocaleRTL(this.computedLang)
},
computedHourCycle() {
// h11, h12, h23, or h24
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Locale/hourCycle
// h12 - Hour system using 1–12. Corresponds to 'h' in patterns. The 12 hour clock, with midnight starting at 12:00 am
// h23 - Hour system using 0–23. Corresponds to 'H' in patterns. The 24 hour clock, with midnight starting at 0:00
// h11 - Hour system using 0–11. Corresponds to 'K' in patterns. The 12 hour clock, with midnight starting at 0:00 am
// h24 - Hour system using 1–24. Corresponds to 'k' in pattern. The 24 hour clock, with midnight starting at 24:00
// For h12 or h24, we visually format 00 hours as 12
return this.resolvedOptions.hourCycle
},
is12Hour() {
return !!this.resolvedOptions.hour12
},
context() {
return {
locale: this.computedLocale,
isRTL: this.computedRTL,
hourCycle: this.computedHourCycle,
hour12: this.is12Hour,
hours: this.modelHours,
minutes: this.modelMinutes,
seconds: this.showSeconds ? this.modelSeconds : 0,
value: this.computedHMS,
formatted: this.formattedTimeString
}
},
valueId() {
return this.safeId() || null
},
computedAriaLabelledby() {
return [this.ariaLabelledby, this.valueId].filter(identity).join(' ') || null
},
timeFormatter() {
// Returns a formatter function reference
// The formatter converts the time to a localized string
const options = {
hour12: this.is12Hour,
hourCycle: this.computedHourCycle,
hour: NUMERIC,
minute: NUMERIC,
timeZone: 'UTC'
}
if (this.showSeconds) {
options.second = NUMERIC
}
// Formats the time as a localized string
return createDateFormatter(this.computedLocale, options)
},
numberFormatter() {
// Returns a formatter function reference
// The formatter always formats as 2 digits and is localized
const nf = new Intl.NumberFormat(this.computedLocale, {
style: 'decimal',
minimumIntegerDigits: 2,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: 'standard'
})
return nf.format
},
formattedTimeString() {
const hours = this.modelHours
const minutes = this.modelMinutes
const seconds = this.showSeconds ? this.modelSeconds || 0 : 0
if (this.computedHMS) {
return this.timeFormatter(createDate(Date.UTC(0, 0, 1, hours, minutes, seconds)))
}
return this.labelNoTimeSelected || ' '
},
spinScopedSlots() {
const h = this.$createElement
return {
increment: ({ hasFocus }) =>
h(BIconChevronUp, {
props: { scale: hasFocus ? 1.5 : 1.25 },
attrs: { 'aria-hidden': 'true' }
}),
decrement: ({ hasFocus }) =>
h(BIconChevronUp, {
props: { flipV: true, scale: hasFocus ? 1.5 : 1.25 },
attrs: { 'aria-hidden': 'true' }
})
}
}
},
watch: {
[MODEL_PROP_NAME](newValue, oldValue) {
if (newValue !== oldValue && !looseEqual(parseHMS(newValue), parseHMS(this.computedHMS))) {
const { hours, minutes, seconds, ampm } = parseHMS(newValue)
this.modelHours = hours
this.modelMinutes = minutes
this.modelSeconds = seconds
this.modelAmpm = ampm
}
},
computedHMS(newValue, oldValue) {
if (newValue !== oldValue) {
this.$emit(MODEL_EVENT_NAME, newValue)
}
},
context(newValue, oldValue) {
if (!looseEqual(newValue, oldValue)) {
this.$emit(EVENT_NAME_CONTEXT, newValue)
}
},
modelAmpm(newValue, oldValue) {
if (newValue !== oldValue) {
const hours = isNull(this.modelHours) ? 0 : this.modelHours
this.$nextTick(() => {
if (newValue === 0 && hours > 11) {
// Switched to AM
this.modelHours = hours - 12
} else if (newValue === 1 && hours < 12) {
// Switched to PM
this.modelHours = hours + 12
}
})
}
},
modelHours(newHours, oldHours) {
if (newHours !== oldHours) {
this.modelAmpm = newHours > 11 ? 1 : 0
}
}
},
created() {
this.$nextTick(() => {
this.$emit(EVENT_NAME_CONTEXT, this.context)
})
},
mounted() {
this.setLive(true)
},
/* istanbul ignore next */
activated() {
this.setLive(true)
},
/* istanbul ignore next */
deactivated() {
this.setLive(false)
},
beforeDestroy() {
this.setLive(false)
},
methods: {
// Public methods
focus() {
if (!this.disabled) {
// We focus the first spin button
attemptFocus(this.$refs.spinners[0])
}
},
blur() {
if (!this.disabled) {
const activeElement = getActiveElement()
if (contains(this.$el, activeElement)) {
attemptBlur(activeElement)
}
}
},
// Formatters for the spin buttons
formatHours(hh) {
const hourCycle = this.computedHourCycle
// We always store 0-23, but format based on h11/h12/h23/h24 formats
hh = this.is12Hour && hh > 12 ? hh - 12 : hh
// Determine how 00:00 and 12:00 are shown
hh =
hh === 0 && hourCycle === 'h12'
? 12
: hh === 0 && hourCycle === 'h24'
? /* istanbul ignore next */ 24
: hh === 12 && hourCycle === 'h11'
? /* istanbul ignore next */ 0
: hh
return this.numberFormatter(hh)
},
formatMinutes(mm) {
return this.numberFormatter(mm)
},
formatSeconds(ss) {
return this.numberFormatter(ss)
},
formatAmpm(ampm) {
// These should come from label props???
// `ampm` should always be a value of `0` or `1`
return ampm === 0 ? this.labelAm : ampm === 1 ? this.labelPm : ''
},
// Spinbutton on change handlers
setHours(value) {
this.modelHours = value
},
setMinutes(value) {
this.modelMinutes = value
},
setSeconds(value) {
this.modelSeconds = value
},
setAmpm(value) {
this.modelAmpm = value
},
onSpinLeftRight(event = {}) {
const { type, keyCode } = event
if (
!this.disabled &&
type === 'keydown' &&
(keyCode === CODE_LEFT || keyCode === CODE_RIGHT)
) {
stopEvent(event)
const spinners = this.$refs.spinners || []
let index = spinners.map(cmp => !!cmp.hasFocus).indexOf(true)
index = index + (keyCode === CODE_LEFT ? -1 : 1)
index = index >= spinners.length ? 0 : index < 0 ? spinners.length - 1 : index
attemptFocus(spinners[index])
}
},
setLive(on) {
if (on) {
this.$nextTick(() => {
requestAF(() => {
this.isLive = true
})
})
} else {
this.isLive = false
}
}
},
render(h) {
// If hidden, we just render a placeholder comment
/* istanbul ignore if */
if (this.hidden) {
return h()
}
const {
disabled,
readonly,
computedLocale: locale,
computedAriaLabelledby: ariaLabelledby,
labelIncrement,
labelDecrement,
valueId,
focus: focusHandler
} = this
const spinIds = []
// Helper method to render a spinbutton
const makeSpinbutton = (handler, key, classes, spinbuttonProps = {}) => {
const id = this.safeId(`_spinbutton_${key}_`) || null
spinIds.push(id)
return h(BFormSpinbutton, {
class: classes,
props: {
id,
placeholder: '--',
vertical: true,
required: true,
disabled,
readonly,
locale,
labelIncrement,
labelDecrement,
wrap: true,
ariaControls: valueId,
min: 0,
...spinbuttonProps
},
scopedSlots: this.spinScopedSlots,
on: {
// We use `change` event to minimize SR verbosity
// As the spinbutton will announce each value change
// and we don't want the formatted time to be announced
// on each value input if repeat is happening
change: handler
},
key,
ref: 'spinners',
[REF_FOR_KEY]: true
})
}
// Helper method to return a "colon" separator
const makeColon = () => {
return h(
'div',
{
staticClass: 'd-flex flex-column',
class: { 'text-muted': disabled || readonly },
attrs: { 'aria-hidden': 'true' }
},
[
h(BIconCircleFill, { props: { shiftV: 4, scale: 0.5 } }),
h(BIconCircleFill, { props: { shiftV: -4, scale: 0.5 } })
]
)
}
let $spinners = []
// Hours
$spinners.push(
makeSpinbutton(this.setHours, 'hours', 'b-time-hours', {
value: this.modelHours,
max: 23,
step: 1,
formatterFn: this.formatHours,
ariaLabel: this.labelHours
})
)
// Spacer
$spinners.push(makeColon())
// Minutes
$spinners.push(
makeSpinbutton(this.setMinutes, 'minutes', 'b-time-minutes', {
value: this.modelMinutes,
max: 59,
step: this.minutesStep || 1,
formatterFn: this.formatMinutes,
ariaLabel: this.labelMinutes
})
)
if (this.showSeconds) {
// Spacer
$spinners.push(makeColon())
// Seconds
$spinners.push(
makeSpinbutton(this.setSeconds, 'seconds', 'b-time-seconds', {
value: this.modelSeconds,
max: 59,
step: this.secondsStep || 1,
formatterFn: this.formatSeconds,
ariaLabel: this.labelSeconds
})
)
}
// AM/PM ?
// depends on client settings, shouldn't be rendered on server
if (this.isLive && this.is12Hour) {
// TODO:
// If locale is RTL, unshift this instead of push?
// And switch class `ml-2` to `mr-2`
// Note some LTR locales (i.e. zh) also place AM/PM to the left
$spinners.push(
makeSpinbutton(this.setAmpm, 'ampm', 'b-time-ampm', {
value: this.modelAmpm,
max: 1,
formatterFn: this.formatAmpm,
ariaLabel: this.labelAmpm,
// We set `required` as `false`, since this always has a value
required: false
})
)
}
// Assemble spinners
$spinners = h(
'div',
{
staticClass: 'd-flex align-items-center justify-content-center mx-auto',
attrs: {
role: 'group',
tabindex: disabled || readonly ? null : '-1',
'aria-labelledby': ariaLabelledby
},
on: {
keydown: this.onSpinLeftRight,
click: /* istanbul ignore next */ event => {
if (event.target === event.currentTarget) {
focusHandler()
}
}
}
},
$spinners
)
// Selected type display
const $value = h(
'output',
{
staticClass: 'form-control form-control-sm text-center',
class: {
disabled: disabled || readonly
},
attrs: {
id: valueId,
role: 'status',
for: spinIds.filter(identity).join(' ') || null,
tabindex: disabled ? null : '-1',
'aria-live': this.isLive ? 'polite' : 'off',
'aria-atomic': 'true'
},
on: {
// Transfer focus/click to focus hours spinner
click: focusHandler,
focus: focusHandler
}
},
[
h('bdi', this.formattedTimeString),
this.computedHMS ? h('span', { staticClass: 'sr-only' }, ` (${this.labelSelected}) `) : ''
]
)
const $header = h(
this.headerTag,
{
staticClass: 'b-time-header',
class: { 'sr-only': this.hideHeader }
},
[$value]
)
const $content = this.normalizeSlot()
const $footer = $content ? h(this.footerTag, { staticClass: 'b-time-footer' }, $content) : h()
return h(
'div',
{
staticClass: 'b-time d-inline-flex flex-column text-center',
attrs: {
role: 'group',
lang: this.computedLang || null,
'aria-labelledby': ariaLabelledby || null,
'aria-disabled': disabled ? 'true' : null,
'aria-readonly': readonly && !disabled ? 'true' : null
}
},
[$header, $spinners, $footer]
)
}
})