UNPKG

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

585 lines (574 loc) 17.5 kB
import Vue from '../../utils/vue' import { arrayIncludes, concat } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { eventOnOff } from '../../utils/events' import { isFunction, isNull } from '../../utils/inspect' import { isLocaleRTL } from '../../utils/locale' import { mathFloor, mathMax, mathPow, mathRound } from '../../utils/math' import { toFloat, toInteger } from '../../utils/number' import { toString } from '../../utils/string' import identity from '../../utils/identity' import KeyCodes from '../../utils/key-codes' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import { BIconPlus, BIconDash } from '../../icons/icons' // --- Constants --- const NAME = 'BFormSpinbutton' const { UP, DOWN, HOME, END, PAGEUP, PAGEDOWN } = KeyCodes // Default for spin button range and step const DEFAULT_MIN = 1 const DEFAULT_MAX = 100 const DEFAULT_STEP = 1 // Delay before auto-repeat in ms const DEFAULT_REPEAT_DELAY = 500 // Repeat interval in ms const DEFAULT_REPEAT_INTERVAL = 100 // Repeat rate increased after number of repeats const DEFAULT_REPEAT_THRESHOLD = 10 // Repeat speed multiplier (step multiplier, must be an integer) const DEFAULT_REPEAT_MULTIPLIER = 4 // --- BFormSpinbutton --- // @vue/component export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ name: NAME, mixins: [idMixin, normalizeSlotMixin], inheritAttrs: false, props: { value: { // Should this really be String, to match native number inputs? type: Number, default: null }, min: { type: [Number, String], default: DEFAULT_MIN }, max: { type: [Number, String], default: DEFAULT_MAX }, step: { type: [Number, String], default: DEFAULT_STEP }, wrap: { type: Boolean, default: false }, formatterFn: { type: Function // default: null }, size: { type: String // default: null }, placeholder: { type: String // default: null }, disabled: { type: Boolean, default: false }, readonly: { type: Boolean, default: false }, required: { // Only affects the `aria-invalid` attribute type: Boolean, default: false }, name: { type: String // default: null }, form: { type: String // default: null }, state: { // Tri-state prop: `true`, `false`, or `null` type: Boolean, default: null }, inline: { type: Boolean, default: false }, vertical: { type: Boolean, default: false }, ariaLabel: { type: String // default: null }, ariaControls: { type: String // default: null }, labelDecrement: { type: String, default: () => getComponentConfig(NAME, 'labelDecrement') }, labelIncrement: { type: String, default: () => getComponentConfig(NAME, 'labelIncrement') }, locale: { type: [String, Array] // default: null }, repeatDelay: { type: [Number, String], default: DEFAULT_REPEAT_DELAY }, repeatInterval: { type: [Number, String], default: DEFAULT_REPEAT_INTERVAL }, repeatThreshold: { type: [Number, String], default: DEFAULT_REPEAT_THRESHOLD }, repeatStepMultiplier: { type: [Number, String], default: DEFAULT_REPEAT_MULTIPLIER } }, data() { return { localValue: toFloat(this.value, null), hasFocus: false } }, computed: { computedStep() { return toFloat(this.step, DEFAULT_STEP) }, computedMin() { return toFloat(this.min, DEFAULT_MIN) }, computedMax() { // We round down to the nearest maximum step value const max = toFloat(this.max, DEFAULT_MAX) const step = this.computedStep const min = this.computedMin return mathFloor((max - min) / step) * step + min }, computedDelay() { const delay = toInteger(this.repeatDelay, 0) return delay > 0 ? delay : DEFAULT_REPEAT_DELAY }, computedInterval() { const interval = toInteger(this.repeatInterval, 0) return interval > 0 ? interval : DEFAULT_REPEAT_INTERVAL }, computedThreshold() { return mathMax(toInteger(this.repeatThreshold, DEFAULT_REPEAT_THRESHOLD), 1) }, computedStepMultiplier() { return mathMax(toInteger(this.repeatStepMultiplier, DEFAULT_REPEAT_MULTIPLIER), 1) }, computedPrecision() { // Quick and dirty way to get the number of decimals const step = this.computedStep return mathFloor(step) === step ? 0 : (step.toString().split('.')[1] || '').length }, computedMultiplier() { return mathPow(10, this.computedPrecision || 0) }, valueAsFixed() { const value = this.localValue return isNull(value) ? '' : value.toFixed(this.computedPrecision) }, computedLocale() { const locales = concat(this.locale).filter(identity) const nf = new Intl.NumberFormat(locales) return nf.resolvedOptions().locale }, computedRTL() { return isLocaleRTL(this.computedLocale) }, defaultFormatter() { // Returns and `Intl.NumberFormat` formatter method reference const precision = this.computedPrecision const nf = new Intl.NumberFormat(this.computedLocale, { style: 'decimal', useGrouping: false, minimumIntegerDigits: 1, minimumFractionDigits: precision, maximumFractionDigits: precision, notation: 'standard' }) // Return the format method reference return nf.format } }, watch: { value(value) { this.localValue = toFloat(value, null) }, localValue(value) { this.$emit('input', value) }, disabled(disabled) { if (disabled) { this.clearRepeat() } }, readonly(readonly) { if (readonly) { this.clearRepeat() } } }, created() { // Create non reactive properties this.$_autoDelayTimer = null this.$_autoRepeatTimer = null this.$_keyIsDown = false }, beforeDestroy() { this.clearRepeat() }, /* istanbul ignore next */ deactivated() /* istanbul ignore next */ { this.clearRepeat() }, methods: { // --- Public methods --- focus() { if (!this.disabled) { try { this.$refs.spinner.focus() } catch {} } }, blur() { if (!this.disabled) { try { this.$refs.spinner.blur() } catch {} } }, // --- Private methods --- emitChange() { this.$emit('change', this.localValue) }, stepValue(direction) { // Sets a new incremented or decremented value, supporting optional wrapping // Direction is either +1 or -1 (or a multiple thereof) let value = this.localValue if (!this.disabled && !isNull(value)) { const step = this.computedStep * direction const min = this.computedMin const max = this.computedMax const multiplier = this.computedMultiplier const wrap = this.wrap // We ensure that the value steps like a native input value = mathRound((value - min) / step) * step + min + step // We ensure that precision is maintained (decimals) value = mathRound(value * multiplier) / multiplier // Handle if wrapping is enabled this.localValue = value > max ? (wrap ? min : max) : value < min ? (wrap ? max : min) : value } }, onFocusBlur(evt) { if (!this.disabled) { this.hasFocus = evt.type === 'focus' } else { this.hasFocus = false } }, stepUp(multiplier = 1) { const value = this.localValue if (isNull(value)) { this.localValue = this.computedMin } else { this.stepValue(+1 * multiplier) } }, stepDown(multiplier = 1) { const value = this.localValue if (isNull(value)) { this.localValue = this.wrap ? this.computedMax : this.computedMin } else { this.stepValue(-1 * multiplier) } }, onKeydown(evt) { const { keyCode, altKey, ctrlKey, metaKey } = evt /* istanbul ignore if */ if (this.disabled || this.readonly || altKey || ctrlKey || metaKey) { return } if (arrayIncludes([UP, DOWN, HOME, END, PAGEUP, PAGEDOWN], keyCode)) { // https://w3c.github.io/aria-practices/#spinbutton evt.preventDefault() /* istanbul ignore if */ if (this.$_keyIsDown) { // Keypress is already in progress return } this.resetTimers() if (arrayIncludes([UP, DOWN], keyCode)) { // The following use the custom auto-repeat handling this.$_keyIsDown = true if (keyCode === UP) { this.handleStepRepeat(evt, this.stepUp) } else if (keyCode === DOWN) { this.handleStepRepeat(evt, this.stepDown) } } else { // These use native OS key repeating if (keyCode === PAGEUP) { this.stepUp(this.computedStepMultiplier) } else if (keyCode === PAGEDOWN) { this.stepDown(this.computedStepMultiplier) } else if (keyCode === HOME) { this.localValue = this.computedMin } else if (keyCode === END) { this.localValue = this.computedMax } } } }, onKeyup(evt) { // Emit a change event when the keyup happens const { keyCode, altKey, ctrlKey, metaKey } = evt /* istanbul ignore if */ if (this.disabled || this.readonly || altKey || ctrlKey || metaKey) { return } if (arrayIncludes([UP, DOWN, HOME, END, PAGEUP, PAGEDOWN], keyCode)) { this.resetTimers() this.$_keyIsDown = false evt.preventDefault() this.emitChange() } }, handleStepRepeat(evt, stepper) { const { type, button } = evt || {} if (!this.disabled && !this.readonly) { /* istanbul ignore if */ if (type === 'mousedown' && button) { // We only respond to left (main === 0) button clicks return } this.resetTimers() // Step the counter initially stepper(1) const threshold = this.computedThreshold const multiplier = this.computedStepMultiplier const delay = this.computedDelay const interval = this.computedInterval // Initiate the delay/repeat interval this.$_autoDelayTimer = setTimeout(() => { let count = 0 this.$_autoRepeatTimer = setInterval(() => { // After N initial repeats, we increase the incrementing step amount // We do this to minimize screen reader announcements of the value // (values are announced every change, which can be chatty for SR users) // And to make it easer to select a value when the range is large stepper(count < threshold ? 1 : multiplier) count++ }, interval) }, delay) } }, onMouseup(evt) { // `<body>` listener, only enabled when mousedown starts const { type, button } = evt || {} /* istanbul ignore if */ if (type === 'mouseup' && button) { // Ignore non left button (main === 0) mouse button click return } evt.preventDefault() this.resetTimers() this.setMouseup(false) // Trigger the change event this.emitChange() }, setMouseup(on) { // Enable or disabled the body mouseup/touchend handlers // Use try/catch to handle case when called server side try { eventOnOff(on, document.body, 'mouseup', this.onMouseup, false) eventOnOff(on, document.body, 'touchend', this.onMouseup, false) } catch {} }, resetTimers() { clearTimeout(this.$_autoDelayTimer) clearInterval(this.$_autoRepeatTimer) }, clearRepeat() { this.resetTimers() this.setMouseup(false) this.$_keyIsDown = false } }, render(h) { const spinId = this.safeId() const value = this.localValue const isVertical = this.vertical const isInline = this.inline && !isVertical const isDisabled = this.disabled const isReadonly = this.readonly && !isDisabled const isRequired = this.required && !isReadonly && !isDisabled const state = this.state const size = this.size const hasValue = !isNull(value) const formatter = isFunction(this.formatterFn) ? this.formatterFn : this.defaultFormatter const makeButton = (stepper, label, IconCmp, keyRef, shortcut, btnDisabled, slotName) => { const $icon = h(IconCmp, { props: { scale: this.hasFocus ? 1.5 : 1.25 }, attrs: { 'aria-hidden': 'true' } }) const scope = { hasFocus: this.hasFocus } const handler = evt => { if (!isDisabled && !isReadonly) { evt.preventDefault() this.setMouseup(true) try { // Since we `preventDefault()`, we must manually focus the button evt.currentTarget.focus() } catch {} this.handleStepRepeat(evt, stepper) } } return h( 'button', { key: keyRef || null, ref: keyRef, staticClass: 'btn btn-sm border-0 rounded-0', class: { 'py-0': !isVertical }, attrs: { tabindex: '-1', type: 'button', disabled: isDisabled || isReadonly || btnDisabled, 'aria-disabled': isDisabled || isReadonly || btnDisabled ? 'true' : null, 'aria-controls': spinId, 'aria-label': label || null, 'aria-keyshortcuts': shortcut || null }, on: { mousedown: handler, touchstart: handler } }, [h('div', [this.normalizeSlot(slotName, scope) || $icon])] ) } // TODO: Add button disabled state when `wrap` is `false` and at value max/min const $increment = makeButton( this.stepUp, this.labelIncrement, BIconPlus, 'inc', 'ArrowUp', false, 'increment' ) const $decrement = makeButton( this.stepDown, this.labelDecrement, BIconDash, 'dec', 'ArrowDown', false, 'decrement' ) let $hidden = h() if (this.name && !isDisabled) { $hidden = h('input', { key: 'hidden', attrs: { type: 'hidden', name: this.name, form: this.form || null, // TODO: Should this be set to '' if value is out of range? value: this.valueAsFixed } }) } const $spin = h( // We use 'output' element to make this accept a `<label for="id">` (Except IE) 'output', { ref: 'spinner', key: 'output', staticClass: 'flex-grow-1', class: { 'd-flex': isVertical, 'align-self-center': !isVertical, 'align-items-center': isVertical, 'border-top': isVertical, 'border-bottom': isVertical, 'border-left': !isVertical, 'border-right': !isVertical }, attrs: { dir: this.computedRTL ? 'rtl' : 'ltr', ...this.$attrs, id: spinId, role: 'spinbutton', tabindex: isDisabled ? null : '0', 'aria-live': 'off', 'aria-label': this.ariaLabel || null, 'aria-controls': this.ariaControls || null, // TODO: May want to check if the value is in range 'aria-invalid': state === false || (!hasValue && isRequired) ? 'true' : null, 'aria-required': isRequired ? 'true' : null, // These attrs are required for role spinbutton 'aria-valuemin': toString(this.computedMin), 'aria-valuemax': toString(this.computedMax), // These should be `null` if the value is out of range // They must also be non-existent attrs if the value is out of range or `null` 'aria-valuenow': hasValue ? value : null, 'aria-valuetext': hasValue ? formatter(value) : null } }, [h('bdi', hasValue ? formatter(value) : this.placeholder || '')] ) return h( 'div', { staticClass: 'b-form-spinbutton form-control', class: { disabled: isDisabled, readonly: isReadonly, focus: this.hasFocus, [`form-control-${size}`]: !!size, 'd-inline-flex': isInline || isVertical, 'd-flex': !isInline && !isVertical, 'align-items-stretch': !isVertical, 'flex-column': isVertical, 'is-valid': state === true, 'is-invalid': state === false }, attrs: { role: 'group', lang: this.computedLocale, tabindex: isDisabled ? null : '-1', title: this.ariaLabel }, on: { keydown: this.onKeydown, keyup: this.onKeyup, // We use capture phase (`!` prefix) since focus and blur do not bubble '!focus': this.onFocusBlur, '!blur': this.onFocusBlur } }, isVertical ? [$increment, $hidden, $spin, $decrement] : [$decrement, $hidden, $spin, $increment] ) } })