bootstrap-vue
Version:
BootstrapVue, with more than 85 custom components, over 45 plugins, several custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated W
648 lines (623 loc) • 18.7 kB
JavaScript
// BTime control (not form input control)
import Vue from '../../utils/vue'
// Utilities
import identity from '../../utils/identity'
import KeyCodes from '../../utils/key-codes'
import looseEqual from '../../utils/loose-equal'
import { concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import { createDate, createDateFormatter } from '../../utils/date'
import { contains, requestAF } from '../../utils/dom'
import { isNull, isUndefinedOrNull } from '../../utils/inspect'
import { isLocaleRTL } from '../../utils/locale'
import { toInteger } from '../../utils/number'
import { toString } from '../../utils/string'
// Mixins
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
// Sub components used
import { BFormSpinbutton } from '../form-spinbutton/form-spinbutton'
import { BIconCircleFill, BIconChevronUp } from '../../icons/icons'
// --- Constants ---
const NAME = 'BTime'
const NUMERIC = 'numeric'
const { LEFT, RIGHT } = KeyCodes
// Time string RegExpr (optional seconds)
const RE_TIME = /^([0-1]?[0-9]|2[0-3]):[0-5]?[0-9](:[0-5]?[0-9])?$/
// --- Helpers ---
// Fallback to BFormSpinbutton prop if no value found
const getConfigFallback = prop => {
return getComponentConfig(NAME, prop) || getComponentConfig('BFormSpinbutton', prop)
}
const padLeftZeros = num => {
return `00${num || ''}`.slice(-2)
}
const parseHMS = hms => {
hms = toString(hms)
let [hh, mm, ss] = [null, null, null]
if (RE_TIME.test(hms)) {
;[hh, mm, ss] = hms.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(':')
}
// @vue/component
export const BTime = /*#__PURE__*/ Vue.extend({
name: NAME,
mixins: [idMixin, normalizeSlotMixin],
model: {
prop: 'value',
event: 'input'
},
props: {
value: {
type: String,
default: ''
},
showSeconds: {
// If true, show the second spinbutton
type: Boolean,
default: false
},
hour12: {
// 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
type: Boolean,
default: null
},
locale: {
type: [String, Array]
// default: null
},
ariaLabelledby: {
// ID of label element
type: String
// default: null
},
secondsStep: {
type: [Number, String],
default: 1
},
minutesStep: {
type: [Number, String],
default: 1
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
hideHeader: {
type: Boolean,
default: false
},
labelNoTimeSelected: {
type: String,
default: () => getComponentConfig(NAME, 'labelNoTimeSelected')
},
labelSelected: {
type: String,
default: () => getComponentConfig(NAME, 'labelSelected')
},
labelHours: {
type: String,
default: () => getComponentConfig(NAME, 'labelHours')
},
labelMinutes: {
type: String,
default: () => getComponentConfig(NAME, 'labelMinutes')
},
labelSeconds: {
type: String,
default: () => getComponentConfig(NAME, 'labelSeconds')
},
labelAmpm: {
type: String,
default: () => getComponentConfig(NAME, 'labelAmpm')
},
labelAm: {
type: String,
default: () => getComponentConfig(NAME, 'labelAm')
},
labelPm: {
type: String,
default: () => getComponentConfig(NAME, 'labelPm')
},
// Passed to the spin buttons
labelIncrement: {
type: String,
// Falls back to BFormSpinbutton label
default: () => getConfigFallback('labelIncrement')
},
labelDecrement: {
type: String,
// Falls back to BFormSpinbutton label
default: () => getConfigFallback('labelDecrement')
},
hidden: {
type: Boolean,
default: false
}
},
data() {
const parsed = parseHMS(this.value || '')
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: hour12,
hourCycle: 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: {
value(newVal, oldVal) {
if (newVal !== oldVal && !looseEqual(parseHMS(newVal), parseHMS(this.computedHMS))) {
const { hours, minutes, seconds, ampm } = parseHMS(newVal)
this.modelHours = hours
this.modelMinutes = minutes
this.modelSeconds = seconds
this.modelAmpm = ampm
}
},
computedHMS(newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit('input', newVal)
}
},
context(newVal, oldVal) {
if (!looseEqual(newVal, oldVal)) {
this.$emit('context', newVal)
}
},
modelAmpm(newVal, oldVal) {
if (newVal !== oldVal) {
const hours = isNull(this.modelHours) ? 0 : this.modelHours
this.$nextTick(() => {
if (newVal === 0 && hours > 11) {
// Switched to AM
this.modelHours = hours - 12
} else if (newVal === 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('context', this.context)
})
},
mounted() {
this.setLive(true)
},
/* istanbul ignore next */
activated() /* istanbul ignore next */ {
this.setLive(true)
},
/* istanbul ignore next */
deactivated() /* istanbul ignore next */ {
this.setLive(false)
},
beforeDestroy() {
this.setLive(false)
},
methods: {
// Public methods
focus() {
if (!this.disabled) {
try {
// We focus the first spin button
this.$refs.spinners[0].focus()
} catch {}
}
},
blur() {
if (!this.disabled) {
try {
if (contains(this.$el, document.activeElement)) {
document.activeElement.blur()
}
} catch {}
}
},
// 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(evt = {}) {
const { type, keyCode } = evt
if (!this.disabled && type === 'keydown' && (keyCode === LEFT || keyCode === RIGHT)) {
evt.preventDefault()
evt.stopPropagation()
const spinners = this.$refs.spinners || []
let index = spinners.map(cmp => !!cmp.hasFocus).indexOf(true)
index = index + (keyCode === LEFT ? -1 : 1)
index = index >= spinners.length ? 0 : index < 0 ? spinners.length - 1 : index
try {
spinners[index].focus()
} catch {}
}
},
setLive(on) {
if (on) {
this.$nextTick(() => {
requestAF(() => {
this.isLive = true
})
})
} else {
this.isLive = false
}
}
},
render(h) {
/* istanbul ignore if */
if (this.hidden) {
// If hidden, we just render a placeholder comment
return h()
}
const valueId = this.valueId
const computedAriaLabelledby = this.computedAriaLabelledby
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, {
key: key,
ref: 'spinners',
refInFor: true,
class: classes,
props: {
id: id,
placeholder: '--',
vertical: true,
required: true,
disabled: this.disabled,
readonly: this.readonly,
locale: this.computedLocale,
labelIncrement: this.labelIncrement,
labelDecrement: this.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
}
})
}
// Helper method to return a "colon" separator
const makeColon = () => {
return h(
'div',
{
staticClass: 'd-flex flex-column',
class: {
'text-muted': this.disabled || this.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 ?
if (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: this.disabled || this.readonly ? null : '-1',
'aria-labelledby': computedAriaLabelledby
},
on: {
keydown: this.onSpinLeftRight,
click /* istanbul ignore next */: evt => /* istanbul ignore next */ {
if (evt.target === evt.currentTarget) {
this.focus()
}
}
}
},
$spinners
)
// Selected type display
const $value = h(
'output',
{
staticClass: 'form-control form-control-sm text-center',
class: {
disabled: this.disabled || this.readonly
},
attrs: {
id: valueId,
role: 'status',
for: spinIds.filter(identity).join(' ') || null,
tabindex: this.disabled ? null : '-1',
'aria-live': this.isLive ? 'polite' : 'off',
'aria-atomic': 'true'
},
on: {
// Transfer focus/click to focus hours spinner
click: this.focus,
focus: this.focus
}
},
[
h('bdi', this.formattedTimeString),
this.computedHMS ? h('span', { staticClass: 'sr-only' }, ` (${this.labelSelected}) `) : ''
]
)
const $header = h(
'header',
{ staticClass: 'b-time-header', class: { 'sr-only': this.hideHeader } },
[$value]
)
// Optional bottom slot
let $slot = this.normalizeSlot('default')
$slot = $slot ? h('footer', { staticClass: 'b-time-footer' }, $slot) : h()
return h(
'div',
{
staticClass: 'b-time d-inline-flex flex-column text-center',
attrs: {
role: 'group',
lang: this.computedLang || null,
'aria-labelledby': computedAriaLabelledby || null,
'aria-disabled': this.disabled ? 'true' : null,
'aria-readonly': this.readonly && !this.disabled ? 'true' : null
}
},
[$header, $spinners, $slot]
)
}
})