diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js index ee87e3c53d8..8961773f576 100644 --- a/src/components/calendar/calendar.js +++ b/src/components/calendar/calendar.js @@ -27,6 +27,7 @@ import { isLocaleRTL } from '../../utils/locale' import { mathMax } from '../../utils/math' import { toInteger } from '../../utils/number' import { toString } from '../../utils/string' +import attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import { @@ -56,7 +57,8 @@ export const STR_NARROW = 'narrow' // @vue/component export const BCalendar = Vue.extend({ name: NAME, - mixins: [idMixin, normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, idMixin, normalizeSlotMixin], model: { // Even though this is the default that Vue assumes, we need // to add it for the docs to reflect that this is the model @@ -271,6 +273,27 @@ export const BCalendar = Vue.extend({ } }, computed: { + valueId() { + return this.safeId() + }, + widgetId() { + return this.safeId('_calendar-wrapper_') + }, + navId() { + return this.safeId('_calendar-nav_') + }, + gridId() { + return this.safeId('_calendar-grid_') + }, + gridCaptionId() { + return this.safeId('_calendar-grid-caption_') + }, + gridHelpId() { + return this.safeId('_calendar-grid-help_') + }, + activeId() { + return this.activeYMD ? this.safeId(`_cell-${this.activeYMD}_`) : null + }, // TODO: Use computed props to convert `YYYY-MM-DD` to `Date` object selectedDate() { // Selected as a `Date` object @@ -771,24 +794,28 @@ export const BCalendar = Vue.extend({ } }, render(h) { - // If hidden prop is set, render just a placeholder node + // If `hidden` prop is set, render just a placeholder node if (this.hidden) { return h() } - const { isLive, isRTL, activeYMD, selectedYMD, safeId } = this + const { + valueId, + widgetId, + navId, + gridId, + gridCaptionId, + gridHelpId, + activeId, + isLive, + isRTL, + activeYMD, + selectedYMD, + safeId + } = this const hideDecadeNav = !this.showDecadeNav const todayYMD = formatYMD(this.getToday()) const highlightToday = !this.noHighlightToday - // Pre-compute some IDs - // This should be computed props - const idValue = safeId() - const idWidget = safeId('_calendar-wrapper_') - const idNav = safeId('_calendar-nav_') - const idGrid = safeId('_calendar-grid_') - const idGridCaption = safeId('_calendar-grid-caption_') - const idGridHelp = safeId('_calendar-grid-help_') - const idActive = activeYMD ? safeId(`_cell-${activeYMD}_`) : null // Header showing current selected date let $header = h( @@ -797,8 +824,8 @@ export const BCalendar = Vue.extend({ staticClass: 'form-control form-control-sm text-center', class: { 'text-muted': this.disabled, readonly: this.readonly || this.disabled }, attrs: { - id: idValue, - for: idGrid, + id: valueId, + for: gridId, role: 'status', tabindex: this.disabled ? null : '-1', // Mainly for testing purposes, as we do not know @@ -885,11 +912,11 @@ export const BCalendar = Vue.extend({ { staticClass: 'b-calendar-nav d-flex', attrs: { - id: idNav, + id: navId, role: 'group', 'aria-hidden': this.disabled ? 'true' : null, 'aria-label': this.labelNav || null, - 'aria-controls': idGrid + 'aria-controls': gridId } }, [ @@ -957,7 +984,7 @@ export const BCalendar = Vue.extend({ staticClass: 'b-calendar-grid-caption text-center font-weight-bold', class: { 'text-muted': this.disabled }, attrs: { - id: idGridCaption, + id: gridCaptionId, 'aria-live': isLive ? 'polite' : null, 'aria-atomic': isLive ? 'true' : null } @@ -1077,7 +1104,7 @@ export const BCalendar = Vue.extend({ { staticClass: 'b-calendar-grid-help border-top small text-muted text-center bg-light', attrs: { - id: idGridHelp + id: gridHelpId } }, [h('div', { staticClass: 'small' }, this.labelHelp)] @@ -1089,18 +1116,18 @@ export const BCalendar = Vue.extend({ ref: 'grid', staticClass: 'b-calendar-grid form-control h-auto text-center', attrs: { - id: idGrid, + id: gridId, role: 'application', tabindex: this.disabled ? null : '0', 'data-month': activeYMD.slice(0, -3), // `YYYY-MM`, mainly for testing 'aria-roledescription': this.labelCalendar || null, - 'aria-labelledby': idGridCaption, - 'aria-describedby': idGridHelp, + 'aria-labelledby': gridCaptionId, + 'aria-describedby': gridHelpId, // `aria-readonly` is not considered valid on `role="application"` // https://www.w3.org/TR/wai-aria-1.1/#aria-readonly // 'aria-readonly': this.readonly && !this.disabled ? 'true' : null, 'aria-disabled': this.disabled ? 'true' : null, - 'aria-activedescendant': idActive + 'aria-activedescendant': activeId }, on: { keydown: this.onKeydownGrid, @@ -1121,7 +1148,7 @@ export const BCalendar = Vue.extend({ staticClass: 'b-calendar-inner', style: this.block ? {} : { width: this.width }, attrs: { - id: idWidget, + id: widgetId, dir: isRTL ? 'rtl' : 'ltr', lang: this.computedLocale || null, role: 'group', @@ -1133,9 +1160,9 @@ export const BCalendar = Vue.extend({ 'aria-describedby': [ // Should the attr (if present) go last? // Or should this attr be a prop? - this.$attrs['aria-describedby'], - idValue, - idGridHelp + this.bvAttrs['aria-describedby'], + valueId, + gridHelpId ] .filter(identity) .join(' ') diff --git a/src/components/dropdown/dropdown-item-button.js b/src/components/dropdown/dropdown-item-button.js index 232e5c1a8e5..1c5998d2f2e 100644 --- a/src/components/dropdown/dropdown-item-button.js +++ b/src/components/dropdown/dropdown-item-button.js @@ -1,4 +1,5 @@ import Vue from '../../utils/vue' +import attrsMixin from '../../mixins/attrs' import normalizeSlotMixin from '../../mixins/normalize-slot' export const props = { @@ -27,7 +28,7 @@ export const props = { // @vue/component export const BDropdownItemButton = /*#__PURE__*/ Vue.extend({ name: 'BDropdownItemButton', - mixins: [normalizeSlotMixin], + mixins: [attrsMixin, normalizeSlotMixin], inheritAttrs: false, inject: { bvDropdown: { @@ -35,6 +36,16 @@ export const BDropdownItemButton = /*#__PURE__*/ Vue.extend({ } }, props, + computed: { + computedAttrs() { + return { + ...this.bvAttrs, + role: 'menuitem', + type: 'button', + disabled: this.disabled + } + } + }, methods: { closeDropdown() { if (this.bvDropdown) { @@ -59,12 +70,7 @@ export const BDropdownItemButton = /*#__PURE__*/ Vue.extend({ [`text-${this.variant}`]: this.variant && !(this.active || this.disabled) } ], - attrs: { - ...this.$attrs, - role: 'menuitem', - type: 'button', - disabled: this.disabled - }, + attrs: this.computedAttrs, on: { click: this.onClick }, ref: 'button' }, diff --git a/src/components/dropdown/dropdown-item.js b/src/components/dropdown/dropdown-item.js index 156a459691d..992984e4d0a 100644 --- a/src/components/dropdown/dropdown-item.js +++ b/src/components/dropdown/dropdown-item.js @@ -1,5 +1,6 @@ import Vue from '../../utils/vue' import { requestAF } from '../../utils/dom' +import attrsMixin from '../../mixins/attrs' import normalizeSlotMixin from '../../mixins/normalize-slot' import { BLink, propsFactory as linkPropsFactory } from '../link/link' @@ -8,7 +9,7 @@ export const props = linkPropsFactory() // @vue/component export const BDropdownItem = /*#__PURE__*/ Vue.extend({ name: 'BDropdownItem', - mixins: [normalizeSlotMixin], + mixins: [attrsMixin, normalizeSlotMixin], inheritAttrs: false, inject: { bvDropdown: { @@ -26,6 +27,14 @@ export const BDropdownItem = /*#__PURE__*/ Vue.extend({ default: null } }, + computed: { + computedAttrs() { + return { + ...this.bvAttrs, + role: 'menuitem' + } + } + }, methods: { closeDropdown() { // Close on next animation frame to allow time to process @@ -53,7 +62,7 @@ export const BDropdownItem = /*#__PURE__*/ Vue.extend({ [`text-${this.variant}`]: this.variant && !(this.active || this.disabled) } ], - attrs: { ...this.$attrs, role: 'menuitem' }, + attrs: this.computedAttrs, on: { click: this.onClick }, ref: 'item' }, diff --git a/src/components/form-file/form-file.js b/src/components/form-file/form-file.js index 742db0d4c48..3eda52041f0 100644 --- a/src/components/form-file/form-file.js +++ b/src/components/form-file/form-file.js @@ -6,6 +6,7 @@ import { isFile, isFunction, isUndefinedOrNull } from '../../utils/inspect' import { File } from '../../utils/safe-types' import { toString } from '../../utils/string' import { warn } from '../../utils/warn' +import attrsMixin from '../../mixins/attrs' import formCustomMixin from '../../mixins/form-custom' import formMixin from '../../mixins/form' import formStateMixin from '../../mixins/form-state' @@ -26,7 +27,7 @@ const isValidValue = value => isFile(value) || (isArray(value) && value.every(v // @vue/component export const BFormFile = /*#__PURE__*/ Vue.extend({ name: NAME, - mixins: [idMixin, formMixin, formStateMixin, formCustomMixin, normalizeSlotMixin], + mixins: [attrsMixin, idMixin, formMixin, formStateMixin, formCustomMixin, normalizeSlotMixin], inheritAttrs: false, model: { prop: 'value', @@ -127,6 +128,22 @@ export const BFormFile = /*#__PURE__*/ Vue.extend({ ? toString(this.fileNameFormatter(files)) : files.map(file => file.name).join(', ') } + }, + computedAttrs() { + return { + ...this.bvAttrs, + type: 'file', + id: this.safeId(), + name: this.name, + disabled: this.disabled, + required: this.required, + form: this.form || null, + capture: this.capture || null, + accept: this.accept || null, + multiple: this.multiple, + webkitdirectory: this.directory, + 'aria-required': this.required ? 'true' : null + } } }, watch: { @@ -290,20 +307,7 @@ export const BFormFile = /*#__PURE__*/ Vue.extend({ }, this.stateClass ], - attrs: { - ...this.$attrs, - type: 'file', - id: this.safeId(), - name: this.name, - disabled: this.disabled, - required: this.required, - form: this.form || null, - capture: this.capture || null, - accept: this.accept || null, - multiple: this.multiple, - webkitdirectory: this.directory, - 'aria-required': this.required ? 'true' : null - }, + attrs: this.computedAttrs, on: { change: this.onFileChange, focusin: this.focusHandler, diff --git a/src/components/form-input/form-input.js b/src/components/form-input/form-input.js index 626273d0a44..9a9688f848c 100644 --- a/src/components/form-input/form-input.js +++ b/src/components/form-input/form-input.js @@ -1,13 +1,14 @@ import Vue from '../../utils/vue' import { arrayIncludes } from '../../utils/array' import { eventOn, eventOff, eventOnOff } from '../../utils/events' -import idMixin from '../../mixins/id' import formMixin from '../../mixins/form' +import formSelectionMixin from '../../mixins/form-selection' import formSizeMixin from '../../mixins/form-size' import formStateMixin from '../../mixins/form-state' import formTextMixin from '../../mixins/form-text' -import formSelectionMixin from '../../mixins/form-selection' import formValidityMixin from '../../mixins/form-validity' +import idMixin from '../../mixins/id' +import listenersMixin from '../../mixins/listeners' // Valid supported input types const TYPES = [ @@ -31,7 +32,9 @@ const TYPES = [ // @vue/component export const BFormInput = /*#__PURE__*/ Vue.extend({ name: 'BFormInput', + // Mixin order is important! mixins: [ + listenersMixin, idMixin, formMixin, formSizeMixin, @@ -41,15 +44,15 @@ export const BFormInput = /*#__PURE__*/ Vue.extend({ formValidityMixin ], props: { - // value prop defined in form-text mixin - // value: { }, + // `value` prop is defined in form-text mixin type: { type: String, default: 'text', validator: type => arrayIncludes(TYPES, type) }, noWheel: { - // Disable mousewheel to prevent wheel from changing values (i.e. number/date). + // Disable mousewheel to prevent wheel from + // changing values (i.e. number/date) type: Boolean, default: false }, @@ -74,6 +77,35 @@ export const BFormInput = /*#__PURE__*/ Vue.extend({ localType() { // We only allow certain types return arrayIncludes(TYPES, this.type) ? this.type : 'text' + }, + computedAttrs() { + const { localType: type, disabled, placeholder, required, min, max, step } = this + + return { + id: this.safeId(), + name: this.name || null, + form: this.form || null, + type, + disabled, + placeholder, + required, + autocomplete: this.autocomplete || null, + readonly: this.readonly || this.plaintext, + min, + max, + step, + list: type !== 'password' ? this.list : null, + 'aria-required': required ? 'true' : null, + 'aria-invalid': this.computedAriaInvalid + } + }, + computedListeners() { + return { + ...this.bvListeners, + input: this.onInput, + change: this.onChange, + blur: this.onBlur + } } }, watch: { @@ -122,44 +154,12 @@ export const BFormInput = /*#__PURE__*/ Vue.extend({ } }, render(h) { - var self = this return h('input', { ref: 'input', - class: self.computedClass, - directives: [ - { - name: 'model', - rawName: 'v-model', - value: self.localValue, - expression: 'localValue' - } - ], - attrs: { - id: self.safeId(), - name: self.name, - form: self.form || null, - type: self.localType, - disabled: self.disabled, - placeholder: self.placeholder, - required: self.required, - autocomplete: self.autocomplete || null, - readonly: self.readonly || self.plaintext, - min: self.min, - max: self.max, - step: self.step, - list: self.localType !== 'password' ? self.list : null, - 'aria-required': self.required ? 'true' : null, - 'aria-invalid': self.computedAriaInvalid - }, - domProps: { - value: self.localValue - }, - on: { - ...self.$listeners, - input: self.onInput, - change: self.onChange, - blur: self.onBlur - } + class: this.computedClass, + attrs: this.computedAttrs, + domProps: { value: this.localValue }, + on: this.computedListeners }) } }) diff --git a/src/components/form-spinbutton/form-spinbutton.js b/src/components/form-spinbutton/form-spinbutton.js index 9f51807b47d..9618ea8f235 100644 --- a/src/components/form-spinbutton/form-spinbutton.js +++ b/src/components/form-spinbutton/form-spinbutton.js @@ -1,4 +1,6 @@ import Vue from '../../utils/vue' +import KeyCodes from '../../utils/key-codes' +import identity from '../../utils/identity' import { arrayIncludes, concat } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { eventOnOff } from '../../utils/events' @@ -7,8 +9,7 @@ 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 attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import { BIconPlus, BIconDash } from '../../icons/icons' @@ -37,7 +38,8 @@ const DEFAULT_REPEAT_MULTIPLIER = 4 // @vue/component export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ name: NAME, - mixins: [idMixin, normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, idMixin, normalizeSlotMixin], inheritAttrs: false, props: { value: { @@ -151,6 +153,18 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ } }, computed: { + spinId() { + return this.safeId() + }, + computedInline() { + return this.inline && !this.vertical + }, + computedReadonly() { + return this.readonly && !this.disabled + }, + computedRequired() { + return this.required && !this.computedReadonly && !this.disabled + }, computedStep() { return toFloat(this.step, DEFAULT_STEP) }, @@ -211,6 +225,50 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ }) // Return the format method reference return nf.format + }, + computedFormatter() { + return isFunction(this.formatterFn) ? this.formatterFn : this.defaultFormatter + }, + computedAttrs() { + return { + ...this.bvAttrs, + role: 'group', + lang: this.computedLocale, + tabindex: this.disabled ? null : '-1', + title: this.ariaLabel + } + }, + computedSpinAttrs() { + const { + spinId, + localValue: value, + computedRequired: required, + disabled, + state, + computedFormatter + } = this + const hasValue = !isNull(value) + + return { + dir: this.computedRTL ? 'rtl' : 'ltr', + ...this.bvAttrs, + id: spinId, + role: 'spinbutton', + tabindex: disabled ? 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 && required) ? 'true' : null, + 'aria-required': required ? '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 ? computedFormatter(value) : null + } } }, watch: { @@ -419,17 +477,18 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ } }, 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 { + spinId, + localValue: value, + computedInline: inline, + computedReadonly: readonly, + vertical, + disabled, + state, + size, + computedFormatter + } = this 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, { @@ -438,7 +497,7 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ }) const scope = { hasFocus: this.hasFocus } const handler = evt => { - if (!isDisabled && !isReadonly) { + if (!disabled && !readonly) { evt.preventDefault() this.setMouseup(true) try { @@ -454,12 +513,12 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ key: keyRef || null, ref: keyRef, staticClass: 'btn btn-sm border-0 rounded-0', - class: { 'py-0': !isVertical }, + class: { 'py-0': !vertical }, attrs: { tabindex: '-1', type: 'button', - disabled: isDisabled || isReadonly || btnDisabled, - 'aria-disabled': isDisabled || isReadonly || btnDisabled ? 'true' : null, + disabled: disabled || readonly || btnDisabled, + 'aria-disabled': disabled || readonly || btnDisabled ? 'true' : null, 'aria-controls': spinId, 'aria-label': label || null, 'aria-keyshortcuts': shortcut || null @@ -493,7 +552,7 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ ) let $hidden = h() - if (this.name && !isDisabled) { + if (this.name && !disabled) { $hidden = h('input', { key: 'hidden', attrs: { @@ -514,36 +573,17 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ 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 + 'd-flex': vertical, + 'align-self-center': !vertical, + 'align-items-center': vertical, + 'border-top': vertical, + 'border-bottom': vertical, + 'border-left': !vertical, + 'border-right': !vertical }, - 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 - } + attrs: this.computedSpinAttrs }, - [h('bdi', hasValue ? formatter(value) : this.placeholder || '')] + [h('bdi', hasValue ? computedFormatter(value) : this.placeholder || '')] ) return h( @@ -551,23 +591,18 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ { staticClass: 'b-form-spinbutton form-control', class: { - disabled: isDisabled, - readonly: isReadonly, + disabled, + readonly, focus: this.hasFocus, [`form-control-${size}`]: !!size, - 'd-inline-flex': isInline || isVertical, - 'd-flex': !isInline && !isVertical, - 'align-items-stretch': !isVertical, - 'flex-column': isVertical, + 'd-inline-flex': inline || vertical, + 'd-flex': !inline && !vertical, + 'align-items-stretch': !vertical, + 'flex-column': vertical, 'is-valid': state === true, 'is-invalid': state === false }, - attrs: { - role: 'group', - lang: this.computedLocale, - tabindex: isDisabled ? null : '-1', - title: this.ariaLabel - }, + attrs: this.computedAttrs, on: { keydown: this.onKeydown, keyup: this.onKeyup, @@ -576,9 +611,7 @@ export const BFormSpinbutton = /*#__PURE__*/ Vue.extend({ '!blur': this.onFocusBlur } }, - isVertical - ? [$increment, $hidden, $spin, $decrement] - : [$decrement, $hidden, $spin, $increment] + vertical ? [$increment, $hidden, $spin, $decrement] : [$decrement, $hidden, $spin, $increment] ) } }) diff --git a/src/components/form-textarea/form-textarea.js b/src/components/form-textarea/form-textarea.js index dfe9d07dbaf..15ae4b2e166 100644 --- a/src/components/form-textarea/form-textarea.js +++ b/src/components/form-textarea/form-textarea.js @@ -1,17 +1,18 @@ import Vue from '../../utils/vue' -import { VBVisible } from '../../directives/visible/visible' -import idMixin from '../../mixins/id' +import { getCS, isVisible, requestAF } from '../../utils/dom' +import { isNull } from '../../utils/inspect' +import { mathCeil, mathMax, mathMin } from '../../utils/math' +import { toInteger, toFloat } from '../../utils/number' import formMixin from '../../mixins/form' +import formSelectionMixin from '../../mixins/form-selection' import formSizeMixin from '../../mixins/form-size' import formStateMixin from '../../mixins/form-state' import formTextMixin from '../../mixins/form-text' -import formSelectionMixin from '../../mixins/form-selection' import formValidityMixin from '../../mixins/form-validity' +import idMixin from '../../mixins/id' import listenOnRootMixin from '../../mixins/listen-on-root' -import { getCS, isVisible, requestAF } from '../../utils/dom' -import { isNull } from '../../utils/inspect' -import { mathCeil, mathMax, mathMin } from '../../utils/math' -import { toInteger, toFloat } from '../../utils/number' +import listenersMixin from '../../mixins/listeners' +import { VBVisible } from '../../directives/visible/visible' // @vue/component export const BFormTextarea = /*#__PURE__*/ Vue.extend({ @@ -19,7 +20,9 @@ export const BFormTextarea = /*#__PURE__*/ Vue.extend({ directives: { 'b-visible': VBVisible }, + // Mixin order is important! mixins: [ + listenersMixin, idMixin, listenOnRootMixin, formMixin, @@ -89,6 +92,32 @@ export const BFormTextarea = /*#__PURE__*/ Vue.extend({ // This is used to set the attribute 'rows' on the textarea // If auto-height is enabled, then we return `null` as we use CSS to control height return this.computedMinRows === this.computedMaxRows ? this.computedMinRows : null + }, + computedAttrs() { + const { disabled, required } = this + + return { + id: this.safeId(), + name: this.name || null, + form: this.form || null, + disabled, + placeholder: this.placeholder || null, + required, + autocomplete: this.autocomplete || null, + readonly: this.readonly || this.plaintext, + rows: this.computedRows, + wrap: this.wrap || null, + 'aria-required': this.required ? 'true' : null, + 'aria-invalid': this.computedAriaInvalid + } + }, + computedListeners() { + return { + ...this.bvListeners, + input: this.onInput, + change: this.onChange, + blur: this.onBlur + } } }, watch: { @@ -168,17 +197,11 @@ export const BFormTextarea = /*#__PURE__*/ Vue.extend({ } }, render(h) { - // Using self instead of this helps reduce code size during minification - const self = this return h('textarea', { ref: 'input', - class: self.computedClass, - style: self.computedStyle, + class: this.computedClass, + style: this.computedStyle, directives: [ - { - name: 'model', - value: self.localValue - }, { name: 'b-visible', value: this.visibleCallback, @@ -186,29 +209,9 @@ export const BFormTextarea = /*#__PURE__*/ Vue.extend({ modifiers: { '640': true } } ], - attrs: { - id: self.safeId(), - name: self.name || null, - form: self.form || null, - disabled: self.disabled, - placeholder: self.placeholder || null, - required: self.required, - autocomplete: self.autocomplete || null, - readonly: self.readonly || self.plaintext, - rows: self.computedRows, - wrap: self.wrap || null, - 'aria-required': self.required ? 'true' : null, - 'aria-invalid': self.computedAriaInvalid - }, - domProps: { - value: self.localValue - }, - on: { - ...self.$listeners, - input: self.onInput, - change: self.onChange, - blur: self.onBlur - } + attrs: this.computedAttrs, + domProps: { value: this.localValue }, + on: this.computedListeners }) } }) diff --git a/src/components/link/link.js b/src/components/link/link.js index 7dc189f8aed..1da2efb2bd3 100644 --- a/src/components/link/link.js +++ b/src/components/link/link.js @@ -1,18 +1,20 @@ import Vue from '../../utils/vue' -import normalizeSlotMixin from '../../mixins/normalize-slot' import { concat } from '../../utils/array' import { isEvent, isFunction, isUndefined } from '../../utils/inspect' import { computeHref, computeRel, computeTag, isRouterLink } from '../../utils/router' +import { omit } from '../../utils/object' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' +import normalizeSlotMixin from '../../mixins/normalize-slot' /** - * The Link component is used in many other BV components. - * As such, sharing its props makes supporting all its features easier. - * However, some components need to modify the defaults for their own purpose. + * The Link component is used in many other BV components + * As such, sharing its props makes supporting all its features easier + * However, some components need to modify the defaults for their own purpose * Prefer sharing a fresh copy of the props to ensure mutations - * do not affect other component references to the props. + * do not affect other component references to the props * - * https://github.com/vuejs/vue-router/blob/dev/src/components/link.js - * @return {{}} + * See: https://github.com/vuejs/vue-router/blob/dev/src/components/link.js */ export const propsFactory = () => ({ href: { @@ -81,7 +83,8 @@ export const props = propsFactory() // @vue/component export const BLink = /*#__PURE__*/ Vue.extend({ name: 'BLink', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, props: propsFactory(), computed: { @@ -101,14 +104,48 @@ export const BLink = /*#__PURE__*/ Vue.extend({ return computeHref({ to: this.to, href: this.href }, this.computedTag) }, computedProps() { - return this.isRouterLink ? { ...this.$props, tag: this.routerTag } : {} + const props = this.isRouterLink ? { ...this.$props, tag: this.routerTag } : {} + // Ensure the `href` prop does not exist for router links + return this.computedHref ? props : omit(props, ['href']) + }, + computedAttrs() { + const { + bvAttrs, + computedHref: href, + computedRel: rel, + disabled, + target, + routerTag, + isRouterLink + } = this + + return { + ...bvAttrs, + // If `href` attribute exists on `` (even `undefined` or `null`) + // it fails working on SSR, so we explicitly add it here if needed + // (i.e. if `computedHref()` is truthy) + ...(href ? { href } : {}), + // We don't render `rel` or `target` on non link tags when using `vue-router` + ...(isRouterLink && routerTag !== 'a' && routerTag !== 'area' ? {} : { rel, target }), + tabindex: disabled ? '-1' : isUndefined(bvAttrs.tabindex) ? null : bvAttrs.tabindex, + 'aria-disabled': disabled ? 'true' : null + } + }, + computedListeners() { + return { + // Transfer all listeners (native) to the root element + ...this.bvListeners, + // We want to overwrite any click handler since our callback + // will invoke the user supplied handler(s) if `!this.disabled` + click: this.onClick + } } }, methods: { onClick(evt) { const evtIsEvent = isEvent(evt) const isRouterLink = this.isRouterLink - const suppliedHandler = this.$listeners.click + const suppliedHandler = this.bvListeners.click if (evtIsEvent && this.disabled) { // Stop event from bubbling up evt.stopPropagation() @@ -149,41 +186,18 @@ export const BLink = /*#__PURE__*/ Vue.extend({ } }, render(h) { - const { active, disabled, target, routerTag, isRouterLink } = this - const tag = this.computedTag - const rel = this.computedRel - const href = this.computedHref + const { active, disabled } = this - const componentData = { - class: { active, disabled }, - attrs: { - ...this.$attrs, - // We don't render `rel` or `target` on non link tags when using `vue-router` - ...(isRouterLink && routerTag !== 'a' && routerTag !== 'area' ? {} : { rel, target }), - tabindex: disabled ? '-1' : isUndefined(this.$attrs.tabindex) ? null : this.$attrs.tabindex, - 'aria-disabled': disabled ? 'true' : null + return h( + this.computedTag, + { + class: { active, disabled }, + attrs: this.computedAttrs, + props: this.computedProps, + // We must use `nativeOn` for ``/`` instead of `on` + [this.isRouterLink ? 'nativeOn' : 'on']: this.computedListeners }, - props: this.computedProps - } - // Add the event handlers. We must use `nativeOn` for - // ``/`` instead of `on` - componentData[isRouterLink ? 'nativeOn' : 'on'] = { - // Transfer all listeners (native) to the root element - ...this.$listeners, - // We want to overwrite any click handler since our callback - // will invoke the user supplied handler(s) if `!this.disabled` - click: this.onClick - } - - // If href attribute exists on (even undefined or null) it fails working on - // SSR, so we explicitly add it here if needed (i.e. if computedHref() is truthy) - if (href) { - componentData.attrs.href = href - } else { - // Ensure the prop HREF does not exist for router links - delete componentData.props.href - } - - return h(tag, componentData, this.normalizeSlot('default')) + this.normalizeSlot('default') + ) } }) diff --git a/src/components/modal/helpers/bv-modal.js b/src/components/modal/helpers/bv-modal.js index 27d4875bdf9..2c0770ee54c 100644 --- a/src/components/modal/helpers/bv-modal.js +++ b/src/components/modal/helpers/bv-modal.js @@ -5,10 +5,11 @@ import { getComponentConfig } from '../../../utils/config' import { isUndefined, isFunction } from '../../../utils/inspect' import { assign, + defineProperties, + defineProperty, + hasOwnProperty, keys, omit, - defineProperty, - defineProperties, readonlyDescriptor } from '../../../utils/object' import { pluginFactory } from '../../../utils/plugins' @@ -247,8 +248,7 @@ const plugin = Vue => { // Define our read-only `$bvModal` instance property // Placed in an if just in case in HMR mode - // eslint-disable-next-line no-prototype-builtins - if (!Vue.prototype.hasOwnProperty(PROP_NAME)) { + if (!hasOwnProperty(Vue.prototype, PROP_NAME)) { defineProperty(Vue.prototype, PROP_NAME, { get() { /* istanbul ignore next */ diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index db8fbdabc4e..931647c78e4 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -12,6 +12,7 @@ import { stripTags } from '../../utils/html' import { isString, isUndefinedOrNull } from '../../utils/inspect' import { HTMLElement } from '../../utils/safe-types' import { BTransporterSingle } from '../../utils/transporter' +import attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' import listenOnDocumentMixin from '../../mixins/listen-on-document' import listenOnRootMixin from '../../mixins/listen-on-root' @@ -272,6 +273,7 @@ export const props = { export const BModal = /*#__PURE__*/ Vue.extend({ name: NAME, mixins: [ + attrsMixin, idMixin, listenOnDocumentMixin, listenOnRootMixin, @@ -305,6 +307,30 @@ export const BModal = /*#__PURE__*/ Vue.extend({ } }, computed: { + modalId() { + return this.safeId() + }, + modalOuterId() { + return this.safeId('__BV_modal_outer_') + }, + modalHeaderId() { + return this.safeId('__BV_modal_header_') + }, + modalBodyId() { + return this.safeId('__BV_modal_body_') + }, + modalTitleId() { + return this.safeId('__BV_modal_title_') + }, + modalContentId() { + return this.safeId('__BV_modal_content_') + }, + modalFooterId() { + return this.safeId('__BV_modal_footer_') + }, + modalBackdropId() { + return this.safeId('__BV_modal_backdrop_') + }, modalClasses() { return [ { @@ -388,6 +414,34 @@ export const BModal = /*#__PURE__*/ Vue.extend({ .filter(identity) .join(',') .trim() + }, + computedAttrs() { + // If the parent has a scoped style attribute, and the modal + // is portalled, add the scoped attribute to the modal wrapper + const scopedStyleAttrs = !this.static ? this.scopedStyleAttrs : {} + + return { + ...scopedStyleAttrs, + ...this.bvAttrs, + id: this.modalOuterId + } + }, + computedModalAttrs() { + return { + id: this.modalId, + role: 'dialog', + 'aria-hidden': this.isVisible ? null : 'true', + 'aria-modal': this.isVisible ? 'true' : null, + 'aria-label': this.ariaLabel, + 'aria-labelledby': + this.hideHeader || + this.ariaLabel || + // TODO: Rename slot to `title` and deprecate `modal-title` + !(this.hasNormalizedSlot('modal-title') || this.titleHtml || this.title) + ? null + : this.modalTitleId, + 'aria-describedby': this.modalBodyId + } } }, watch: { @@ -448,7 +502,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ ...options, // Options that can't be overridden vueTarget: this, - componentId: this.safeId() + componentId: this.modalId }) }, // Public method to show modal @@ -736,18 +790,18 @@ export const BModal = /*#__PURE__*/ Vue.extend({ }, // Root listener handlers showHandler(id, triggerEl) { - if (id === this.safeId()) { + if (id === this.modalId) { this.return_focus = triggerEl || this.getActiveElement() this.show() } }, hideHandler(id) { - if (id === this.safeId()) { + if (id === this.modalId) { this.hide('event') } }, toggleHandler(id, triggerEl) { - if (id === this.safeId()) { + if (id === this.modalId) { this.toggle(triggerEl) } }, @@ -850,7 +904,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ { staticClass: 'modal-title', class: this.titleClasses, - attrs: { id: this.safeId('__BV_modal_title_') }, + attrs: { id: this.modalTitleId }, domProps }, // TODO: Rename slot to `title` and deprecate `modal-title` @@ -865,7 +919,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ ref: 'header', staticClass: 'modal-header', class: this.headerClasses, - attrs: { id: this.safeId('__BV_modal_header_') } + attrs: { id: this.modalHeaderId } }, [modalHeader] ) @@ -878,7 +932,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ ref: 'body', staticClass: 'modal-body', class: this.bodyClasses, - attrs: { id: this.safeId('__BV_modal_body_') } + attrs: { id: this.modalBodyId } }, this.normalizeSlot('default', this.slotScope) ) @@ -936,7 +990,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ ref: 'footer', staticClass: 'modal-footer', class: this.footerClasses, - attrs: { id: this.safeId('__BV_modal_footer_') } + attrs: { id: this.modalFooterId } }, [modalFooter] ) @@ -951,7 +1005,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ class: this.contentClass, attrs: { role: 'document', - id: this.safeId('__BV_modal_content_'), + id: this.modalContentId, tabindex: '-1' } }, @@ -990,21 +1044,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ directives: [ { name: 'show', rawName: 'v-show', value: this.isVisible, expression: 'isVisible' } ], - attrs: { - id: this.safeId(), - role: 'dialog', - 'aria-hidden': this.isVisible ? null : 'true', - 'aria-modal': this.isVisible ? 'true' : null, - 'aria-label': this.ariaLabel, - 'aria-labelledby': - this.hideHeader || - this.ariaLabel || - // TODO: Rename slot to `title` and deprecate `modal-title` - !(this.hasNormalizedSlot('modal-title') || this.titleHtml || this.title) - ? null - : this.safeId('__BV_modal_title_'), - 'aria-describedby': this.safeId('__BV_modal_body_') - }, + attrs: this.computedModalAttrs, on: { keydown: this.onEsc, click: this.onClickOut } }, [modalDialog] @@ -1042,24 +1082,20 @@ export const BModal = /*#__PURE__*/ Vue.extend({ if (!this.hideBackdrop && this.isVisible) { backdrop = h( 'div', - { staticClass: 'modal-backdrop', attrs: { id: this.safeId('__BV_modal_backdrop_') } }, + { staticClass: 'modal-backdrop', attrs: { id: this.modalBackdropId } }, // TODO: Rename slot to `backdrop` and deprecate `modal-backdrop` [this.normalizeSlot('modal-backdrop')] ) } backdrop = h(BVTransition, { props: { noFade: this.noFade } }, [backdrop]) - // If the parent has a scoped style attribute, and the modal - // is portalled, add the scoped attribute to the modal wrapper - const scopedStyleAttrs = !this.static ? this.scopedStyleAttrs : {} - // Assemble modal and backdrop in an outer
return h( 'div', { key: `modal-outer-${this._uid}`, style: this.modalOuterStyle, - attrs: { ...scopedStyleAttrs, ...this.$attrs, id: this.safeId('__BV_modal_outer_') } + attrs: this.computedAttrs }, [modal, backdrop] ) diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index c18a2059606..dc94bca7a5c 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -4,6 +4,7 @@ import BVTransition from '../../utils/bv-transition' import { contains, getTabables } from '../../utils/dom' import { getComponentConfig } from '../../utils/config' import { toString } from '../../utils/string' +import attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' import listenOnRootMixin from '../../mixins/listen-on-root' import normalizeSlotMixin from '../../mixins/normalize-slot' @@ -124,7 +125,8 @@ const renderBackdrop = (h, ctx) => { // @vue/component export const BSidebar = /*#__PURE__*/ Vue.extend({ name: NAME, - mixins: [idMixin, listenOnRootMixin, normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, idMixin, listenOnRootMixin, normalizeSlotMixin], inheritAttrs: false, model: { prop: 'visible', @@ -259,6 +261,24 @@ export const BSidebar = /*#__PURE__*/ Vue.extend({ right: this.right, hide: this.hide } + }, + computedTile() { + return this.normalizeSlot('title', this.slotScope) || toString(this.title) || null + }, + titleId() { + return this.computedTile ? this.safeId('__title__') : null + }, + computedAttrs() { + return { + ...this.bvAttrs, + id: this.safeId(), + tabindex: '-1', + role: 'dialog', + 'aria-modal': this.backdrop ? 'true' : 'false', + 'aria-hidden': this.localShow ? null : 'true', + 'aria-label': this.ariaLabel || null, + 'aria-labelledby': this.ariaLabelledby || this.titleId || null + } } }, watch: { @@ -379,11 +399,6 @@ export const BSidebar = /*#__PURE__*/ Vue.extend({ render(h) { const localShow = this.localShow const shadow = this.shadow === '' ? true : this.shadow - const title = this.normalizeSlot('title', this.slotScope) || toString(this.title) || null - const titleId = title ? this.safeId('__title__') : null - const ariaLabel = this.ariaLabel || null - // `ariaLabel` takes precedence over `ariaLabelledby` - const ariaLabelledby = this.ariaLabelledby || titleId || null let $sidebar = h( this.tag, @@ -401,16 +416,7 @@ export const BSidebar = /*#__PURE__*/ Vue.extend({ }, this.sidebarClass ], - attrs: { - ...this.$attrs, - id: this.safeId(), - tabindex: '-1', - role: 'dialog', - 'aria-modal': this.backdrop ? 'true' : 'false', - 'aria-hidden': localShow ? null : 'true', - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby - }, + attrs: this.computedAttrs, style: { width: this.width } }, [renderContent(h, this)] diff --git a/src/components/table/helpers/mixin-table-renderer.js b/src/components/table/helpers/mixin-table-renderer.js index 69a7970c8e4..dec85ef717e 100644 --- a/src/components/table/helpers/mixin-table-renderer.js +++ b/src/components/table/helpers/mixin-table-renderer.js @@ -1,6 +1,7 @@ import identity from '../../../utils/identity' import { isBoolean } from '../../../utils/inspect' import { toString } from '../../../utils/string' +import attrsMixin from '../../../mixins/attrs' // Main `` render mixin // Includes all main table styling options @@ -9,6 +10,8 @@ export default { // Don't place attributes on root element automatically, // as table could be wrapped in responsive `
` inheritAttrs: false, + // Mixin order is important! + mixins: [attrsMixin], provide() { return { bvTable: this @@ -130,7 +133,8 @@ export default { tableAttrs() { // Preserve user supplied aria-describedby, if provided in `$attrs` const adb = - [(this.$attrs || {})['aria-describedby'], this.captionId].filter(identity).join(' ') || null + [(this.bvAttrs || {})['aria-describedby'], this.captionId].filter(identity).join(' ') || + null const items = this.computedItems const filteredItems = this.filteredItems const fields = this.computedFields @@ -152,7 +156,7 @@ export default { // in case user has supplied their own 'aria-rowcount': rowCount, // Merge in user supplied `$attrs` if any - ...this.$attrs, + ...this.bvAttrs, // Now we can override any `$attrs` here id: this.safeId(), role: 'table', diff --git a/src/components/table/table.js b/src/components/table/table.js index 2042559d789..8c73445dc29 100644 --- a/src/components/table/table.js +++ b/src/components/table/table.js @@ -1,6 +1,7 @@ import Vue from '../../utils/vue' // Mixins +import attrsMixin from '../../mixins/attrs' import hasListenerMixin from '../../mixins/has-listener' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' @@ -31,19 +32,21 @@ import tableRendererMixin from './helpers/mixin-table-renderer' export const BTable = /*#__PURE__*/ Vue.extend({ name: 'BTable', // Order of mixins is important! - // They are merged from first to last, followed by this component. + // They are merged from first to last, followed by this component mixins: [ - // Required Mixins + // General mixins + attrsMixin, hasListenerMixin, idMixin, normalizeSlotMixin, + // Required table mixins itemsMixin, tableRendererMixin, stackedMixin, theadMixin, tfootMixin, tbodyMixin, - // Features Mixins + // Table features mixins stackedMixin, filteringMixin, sortingMixin, @@ -57,5 +60,5 @@ export const BTable = /*#__PURE__*/ Vue.extend({ busyMixin, providerMixin ] - // render function provided by table-renderer mixin + // Render function is provided by table-renderer mixin }) diff --git a/src/components/table/tbody.js b/src/components/table/tbody.js index 61920237b20..a96918f44c9 100644 --- a/src/components/table/tbody.js +++ b/src/components/table/tbody.js @@ -1,4 +1,6 @@ import Vue from '../../utils/vue' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' import normalizeSlotMixin from '../../mixins/normalize-slot' export const props = { @@ -18,7 +20,8 @@ export const props = { // @vue/component export const BTbody = /*#__PURE__*/ Vue.extend({ name: 'BTbody', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, provide() { return { @@ -71,7 +74,7 @@ export const BTbody = /*#__PURE__*/ Vue.extend({ return this.tbodyTransitionProps || this.tbodyTransitionHandlers }, tbodyAttrs() { - return { role: 'rowgroup', ...this.$attrs } + return { role: 'rowgroup', ...this.bvAttrs } }, tbodyProps() { return this.tbodyTransitionProps ? { ...this.tbodyTransitionProps, tag: 'tbody' } : {} @@ -83,13 +86,12 @@ export const BTbody = /*#__PURE__*/ Vue.extend({ attrs: this.tbodyAttrs } if (this.isTransitionGroup) { - // We use native listeners if a transition group - // for any delegated events + // We use native listeners if a transition group for any delegated events data.on = this.tbodyTransitionHandlers || {} - data.nativeOn = this.$listeners || {} + data.nativeOn = this.bvListeners } else { // Otherwise we place any listeners on the tbody element - data.on = this.$listeners || {} + data.on = this.bvListeners } return h( this.isTransitionGroup ? 'transition-group' : 'tbody', diff --git a/src/components/table/td.js b/src/components/table/td.js index 03b129c71c1..cb6ce671823 100644 --- a/src/components/table/td.js +++ b/src/components/table/td.js @@ -2,6 +2,8 @@ import Vue from '../../utils/vue' import { isUndefinedOrNull } from '../../utils/inspect' import { toInteger } from '../../utils/number' import { toString } from '../../utils/string' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' import normalizeSlotMixin from '../../mixins/normalize-slot' // Parse a rowspan or colspan into a digit (or `null` if < `1` ) @@ -13,6 +15,8 @@ const parseSpan = value => { /* istanbul ignore next */ const spanValidator = val => isUndefinedOrNull(val) || parseSpan(val) > 0 +// --- Props --- + export const props = { variant: { type: String, @@ -44,7 +48,8 @@ export const props = { // @vue/component export const BTd = /*#__PURE__*/ Vue.extend({ name: 'BTableCell', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, inject: { bvTableTr: { @@ -167,7 +172,7 @@ export const BTd = /*#__PURE__*/ Vue.extend({ role: role, scope: scope, // Allow users to override role/scope plus add other attributes - ...this.$attrs, + ...this.bvAttrs, // Add in the stacked cell label data-attribute if in // stacked mode (if a stacked heading label is provided) 'data-label': @@ -185,7 +190,7 @@ export const BTd = /*#__PURE__*/ Vue.extend({ class: this.cellClasses, attrs: this.cellAttrs, // Transfer any native listeners - on: this.$listeners + on: this.bvListeners }, [this.isStackedCell ? h('div', [content]) : content] ) diff --git a/src/components/table/tfoot.js b/src/components/table/tfoot.js index 534d2889c38..9d19f624d3f 100644 --- a/src/components/table/tfoot.js +++ b/src/components/table/tfoot.js @@ -1,9 +1,11 @@ import Vue from '../../utils/vue' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' import normalizeSlotMixin from '../../mixins/normalize-slot' export const props = { footVariant: { - type: String, // supported values: 'lite', 'dark', or null + type: String, // Supported values: 'lite', 'dark', or null default: null } } @@ -14,7 +16,8 @@ export const props = { // @vue/component export const BTfoot = /*#__PURE__*/ Vue.extend({ name: 'BTfoot', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, provide() { return { @@ -67,7 +70,7 @@ export const BTfoot = /*#__PURE__*/ Vue.extend({ return [this.footVariant ? `thead-${this.footVariant}` : null] }, tfootAttrs() { - return { role: 'rowgroup', ...this.$attrs } + return { role: 'rowgroup', ...this.bvAttrs } } }, render(h) { @@ -77,7 +80,7 @@ export const BTfoot = /*#__PURE__*/ Vue.extend({ class: this.tfootClasses, attrs: this.tfootAttrs, // Pass down any native listeners - on: this.$listeners + on: this.bvListeners }, this.normalizeSlot('default') ) diff --git a/src/components/table/thead.js b/src/components/table/thead.js index d7ba068a1d9..812bfac00cd 100644 --- a/src/components/table/thead.js +++ b/src/components/table/thead.js @@ -1,10 +1,12 @@ import Vue from '../../utils/vue' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' import normalizeSlotMixin from '../../mixins/normalize-slot' export const props = { headVariant: { // Also sniffed by / / - type: String, // supported values: 'lite', 'dark', or null + type: String, // Supported values: 'lite', 'dark', or null default: null } } @@ -15,7 +17,8 @@ export const props = { // @vue/component export const BThead = /*#__PURE__*/ Vue.extend({ name: 'BThead', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, provide() { return { @@ -70,7 +73,7 @@ export const BThead = /*#__PURE__*/ Vue.extend({ return [this.headVariant ? `thead-${this.headVariant}` : null] }, theadAttrs() { - return { role: 'rowgroup', ...this.$attrs } + return { role: 'rowgroup', ...this.bvAttrs } } }, render(h) { @@ -80,7 +83,7 @@ export const BThead = /*#__PURE__*/ Vue.extend({ class: this.theadClasses, attrs: this.theadAttrs, // Pass down any native listeners - on: this.$listeners + on: this.bvListeners }, this.normalizeSlot('default') ) diff --git a/src/components/table/tr.js b/src/components/table/tr.js index 47601f04cbb..d9f9e98bc71 100644 --- a/src/components/table/tr.js +++ b/src/components/table/tr.js @@ -1,4 +1,6 @@ import Vue from '../../utils/vue' +import attrsMixin from '../../mixins/attrs' +import listenersMixin from '../../mixins/listeners' import normalizeSlotMixin from '../../mixins/normalize-slot' export const props = { @@ -17,7 +19,8 @@ const DARK = 'dark' // @vue/component export const BTr = /*#__PURE__*/ Vue.extend({ name: 'BTr', - mixins: [normalizeSlotMixin], + // Mixin order is important! + mixins: [attrsMixin, listenersMixin, normalizeSlotMixin], inheritAttrs: false, provide() { return { @@ -92,7 +95,7 @@ export const BTr = /*#__PURE__*/ Vue.extend({ return [this.variant ? `${this.isRowDark ? 'bg' : 'table'}-${this.variant}` : null] }, trAttrs() { - return { role: 'row', ...this.$attrs } + return { role: 'row', ...this.bvAttrs } } }, render(h) { @@ -102,7 +105,7 @@ export const BTr = /*#__PURE__*/ Vue.extend({ class: this.trClasses, attrs: this.trAttrs, // Pass native listeners to child - on: this.$listeners + on: this.bvListeners }, this.normalizeSlot('default') ) diff --git a/src/components/toast/helpers/bv-toast.js b/src/components/toast/helpers/bv-toast.js index 3ab4a2bbb8c..8c159ab9b6f 100644 --- a/src/components/toast/helpers/bv-toast.js +++ b/src/components/toast/helpers/bv-toast.js @@ -10,6 +10,7 @@ import { assign, defineProperties, defineProperty, + hasOwnProperty, keys, omit, readonlyDescriptor @@ -177,8 +178,7 @@ const plugin = Vue => { // Define our read-only `$bvToast` instance property // Placed in an if just in case in HMR mode - // eslint-disable-next-line no-prototype-builtins - if (!Vue.prototype.hasOwnProperty(PROP_NAME)) { + if (!hasOwnProperty(Vue.prototype, PROP_NAME)) { defineProperty(Vue.prototype, PROP_NAME, { get() { /* istanbul ignore next */ diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 4eb11420f37..f3e40224606 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -7,6 +7,7 @@ import { requestAF } from '../../utils/dom' import { EVENT_OPTIONS_NO_CAPTURE, eventOnOff } from '../../utils/events' import { mathMax } from '../../utils/math' import { toInteger } from '../../utils/number' +import attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' import listenOnRootMixin from '../../mixins/listen-on-root' import normalizeSlotMixin from '../../mixins/normalize-slot' @@ -109,7 +110,7 @@ export const props = { // @vue/component export const BToast = /*#__PURE__*/ Vue.extend({ name: NAME, - mixins: [idMixin, listenOnRootMixin, normalizeSlotMixin, scopedStyleAttrsMixin], + mixins: [attrsMixin, idMixin, listenOnRootMixin, normalizeSlotMixin, scopedStyleAttrsMixin], inheritAttrs: false, model: { prop: 'visible', @@ -157,6 +158,13 @@ export const BToast = /*#__PURE__*/ Vue.extend({ beforeLeave: this.onBeforeLeave, afterLeave: this.onAfterLeave } + }, + computedAttrs() { + return { + ...this.bvAttrs, + id: this.safeId(), + tabindex: '0' + } } }, watch: { @@ -396,11 +404,7 @@ export const BToast = /*#__PURE__*/ Vue.extend({ ref: 'toast', staticClass: 'toast', class: this.toastClass, - attrs: { - ...this.$attrs, - tabindex: '0', - id: this.safeId() - } + attrs: this.computedAttrs }, [$header, $body] ) diff --git a/src/directives/scrollspy/scrollspy.class.js b/src/directives/scrollspy/scrollspy.class.js index 4c22040578c..65c82b382ad 100644 --- a/src/directives/scrollspy/scrollspy.class.js +++ b/src/directives/scrollspy/scrollspy.class.js @@ -22,7 +22,7 @@ import { EVENT_OPTIONS_NO_CAPTURE, eventOn, eventOff } from '../../utils/events' import { isString, isUndefined } from '../../utils/inspect' import { mathMax } from '../../utils/math' import { toInteger } from '../../utils/number' -import { toString as objectToString } from '../../utils/object' +import { hasOwnProperty, toString as objectToString } from '../../utils/object' import { warn } from '../../utils/warn' /* @@ -99,7 +99,7 @@ const typeCheckConfig = ( configTypes ) => /* istanbul ignore next: not easy to test */ { for (const property in configTypes) { - if (Object.prototype.hasOwnProperty.call(configTypes, property)) { + if (hasOwnProperty(configTypes, property)) { const expectedTypes = configTypes[property] const value = config[property] let valueType = value && isElement(value) ? 'element' : toType(value) diff --git a/src/mixins/attrs.js b/src/mixins/attrs.js new file mode 100644 index 00000000000..9d63d1d12fa --- /dev/null +++ b/src/mixins/attrs.js @@ -0,0 +1,3 @@ +import { makePropCacheMixin } from '../utils/cache' + +export default makePropCacheMixin('$attrs', 'bvAttrs') diff --git a/src/mixins/attrs.spec.js b/src/mixins/attrs.spec.js new file mode 100644 index 00000000000..e6f44b0d363 --- /dev/null +++ b/src/mixins/attrs.spec.js @@ -0,0 +1,189 @@ +import { mount } from '@vue/test-utils' +import attrsMixin from './attrs' + +// Note: The following tests indirectly test `utils/cache` + +describe('mixins > attrs', () => { + it('works', async () => { + const BTest = { + name: 'BTest', + mixins: [attrsMixin], + inheritAttrs: false, + render(h) { + return h('section', [h('article', { attrs: this.bvAttrs })]) + } + } + const App = { + name: 'App', + props: { + attrs: { + type: Object, + default: () => ({}) + } + }, + render(h) { + return h(BTest, { attrs: this.attrs }) + } + } + + const wrapper = mount(App) + + expect(wrapper).toBeDefined() + expect(wrapper.vm).toBeDefined() + expect(wrapper.element.tagName).toBe('SECTION') + + const $test = wrapper.findComponent(BTest) + + expect($test.exists()).toBe(true) + expect($test.vm).toBeDefined() + + const $section = $test.find('section') + expect($section.exists()).toBe(true) + + const $article = $test.find('article') + expect($article.exists()).toBe(true) + + expect($section.attributes()).toEqual({}) + expect($article.attributes()).toEqual({}) + + expect($test.vm.bvAttrs).toBeDefined() + expect($test.vm.bvAttrs.foo).not.toBeDefined() + expect($test.vm.bvAttrs.baz).not.toBeDefined() + + // Correctly adds new attrs data + await wrapper.setProps({ + attrs: { foo: 'bar' } + }) + + expect($section.attributes()).toEqual({}) + expect($article.attributes()).toEqual({ foo: 'bar' }) + expect($test.vm.bvAttrs.foo).toEqual('bar') + expect($test.vm.bvAttrs.baz).not.toBeDefined() + + // Correctly updates attrs data + await wrapper.setProps({ + attrs: { foo: 'bar', baz: 'biz' } + }) + + expect($section.attributes()).toEqual({}) + expect($article.attributes()).toEqual({ foo: 'bar', baz: 'biz' }) + expect($test.vm.bvAttrs.foo).toEqual('bar') + expect($test.vm.bvAttrs.baz).toEqual('biz') + + // Correctly removes attrs data + await wrapper.setProps({ + attrs: { foo: 'bar' } + }) + + expect($section.attributes()).toEqual({}) + expect($article.attributes()).toEqual({ foo: 'bar' }) + expect($test.vm.bvAttrs.foo).toEqual('bar') + expect($test.vm.bvAttrs.baz).not.toBeDefined() + + // Correctly removes all attrs data + await wrapper.setProps({ attrs: {} }) + + expect($section.attributes()).toEqual({}) + expect($article.attributes()).toEqual({}) + expect($test.vm.bvAttrs.foo).not.toBeDefined() + expect($test.vm.bvAttrs.baz).not.toBeDefined() + + wrapper.destroy() + }) + + it('does not re-render parent child components', async () => { + let input1RenderCount = 0 + let input2RenderCount = 0 + + const Input1 = { + props: ['value'], + render(h) { + input1RenderCount++ + return h('input', { + attrs: { ...this.$attrs, value: this.value }, + domProps: { value: this.value }, + on: { input: e => this.$emit('input', e.target.value) } + }) + } + } + const Input2 = { + props: ['value'], + mixins: [attrsMixin], + render(h) { + input2RenderCount++ + return h('input', { + attrs: { ...this.bvAttrs, value: this.value }, + domProps: { value: this.value }, + on: { input: e => this.$emit('input', e.target.value) } + }) + } + } + + const App1 = { + components: { Input1 }, + props: ['value1', 'value2'], + template: `
+ + +
` + } + const App2 = { + components: { Input2 }, + props: ['value1', 'value2'], + template: `
+ + +
` + } + + const wrapper1 = mount(App1) + const wrapper2 = mount(App2) + + const $inputs1 = wrapper1.findAllComponents(Input1) + expect($inputs1.length).toBe(2) + expect($inputs1.at(0)).toBeDefined() + expect($inputs1.at(0).vm.value).toBe(undefined) + expect($inputs1.at(1)).toBeDefined() + expect($inputs1.at(1).vm.value).toBe(undefined) + expect(input1RenderCount).toBe(2) + + const $inputs2 = wrapper2.findAllComponents(Input2) + expect($inputs2.length).toBe(2) + expect($inputs2.at(0)).toBeDefined() + expect($inputs2.at(0).vm.value).toBe(undefined) + expect($inputs2.at(1)).toBeDefined() + expect($inputs2.at(1).vm.value).toBe(undefined) + expect(input2RenderCount).toBe(2) + + // Update the value for the first `Input1` + await wrapper1.setProps({ value1: 'foo' }) + expect($inputs1.at(0).vm.value).toBe('foo') + expect($inputs1.at(1).vm.value).toBe(undefined) + // Both `Input1`'s are re-rendered (See: https://github.com/vuejs/vue/issues/7257) + expect(input1RenderCount).toBe(4) + + // Update the value for the second `Input1` + await wrapper1.setProps({ value2: 'bar' }) + expect($inputs1.at(0).vm.value).toBe('foo') + expect($inputs1.at(1).vm.value).toBe('bar') + // Both `Input1`'s are re-rendered (See: https://github.com/vuejs/vue/issues/7257) + expect(input1RenderCount).toBe(6) + + // Update the value for the first `Input2` + await wrapper2.setProps({ value1: 'foo' }) + expect($inputs2.at(0).vm.value).toBe('foo') + expect($inputs2.at(1).vm.value).toBe(undefined) + // With `attrsMixin` only the affected `Input2` is re-rendered + expect(input2RenderCount).toBe(3) + + // Update the value for the second `Input2` + await wrapper2.setProps({ value2: 'bar' }) + expect($inputs2.at(0).vm.value).toBe('foo') + expect($inputs2.at(1).vm.value).toBe('bar') + // With `attrsMixin` only the affected `Input2` is re-rendered + expect(input2RenderCount).toBe(4) + + wrapper1.destroy() + wrapper2.destroy() + }) +}) diff --git a/src/mixins/form-radio-check.js b/src/mixins/form-radio-check.js index 5e9993ef5e4..0d97d4be04e 100644 --- a/src/mixins/form-radio-check.js +++ b/src/mixins/form-radio-check.js @@ -1,8 +1,9 @@ +import attrsMixin from './attrs' import normalizeSlotMixin from './normalize-slot' // @vue/component export default { - mixins: [normalizeSlotMixin], + mixins: [attrsMixin, normalizeSlotMixin], inheritAttrs: false, model: { prop: 'checked', @@ -140,6 +141,21 @@ export default { focus: this.hasFocus } ] + }, + computedAttrs() { + return { + ...this.bvAttrs, + id: this.safeId(), + type: this.isRadio ? 'radio' : 'checkbox', + name: this.getName, + form: this.getForm, + disabled: this.isDisabled, + required: this.isRequired, + autocomplete: 'off', + 'aria-required': this.isRequired || null, + 'aria-label': this.ariaLabel || null, + 'aria-labelledby': this.ariaLabelledby || null + } } }, watch: { @@ -200,19 +216,7 @@ export default { expression: 'computedLocalChecked' } ], - attrs: { - ...this.$attrs, - id: this.safeId(), - type: this.isRadio ? 'radio' : 'checkbox', - name: this.getName, - form: this.getForm, - disabled: this.isDisabled, - required: this.isRequired, - autocomplete: 'off', - 'aria-required': this.isRequired || null, - 'aria-label': this.ariaLabel || null, - 'aria-labelledby': this.ariaLabelledby || null - }, + attrs: this.computedAttrs, domProps: { value: this.value, checked: this.isChecked diff --git a/src/mixins/listeners.js b/src/mixins/listeners.js new file mode 100644 index 00000000000..cc61d62f92a --- /dev/null +++ b/src/mixins/listeners.js @@ -0,0 +1,3 @@ +import { makePropCacheMixin } from '../utils/cache' + +export default makePropCacheMixin('$listeners', 'bvListeners') diff --git a/src/mixins/listeners.spec.js b/src/mixins/listeners.spec.js new file mode 100644 index 00000000000..736754dcec2 --- /dev/null +++ b/src/mixins/listeners.spec.js @@ -0,0 +1,204 @@ +import { mount } from '@vue/test-utils' +import listenersMixin from './listeners' + +// Note: The following tests indirectly test `utils/cache` + +describe('mixins > listeners', () => { + it('works', async () => { + const BTest = { + name: 'BTest', + mixins: [listenersMixin], + inheritAttrs: false, + render(h) { + return h('button', { on: this.bvListeners }) + } + } + const App = { + name: 'App', + props: ['listenClick', 'listenFocus', 'listenBlur'], + computed: { + listeners() { + const listeners = {} + if (this.listenClick) { + listeners.click = evt => this.$emit('click', evt) + } + if (this.listenFocus) { + listeners.focus = evt => this.$emit('focus', evt) + } + if (this.listenBlur) { + listeners.blur = evt => this.$emit('blur', evt) + } + return listeners + } + }, + render(h) { + return h(BTest, { on: this.listeners }) + } + } + + const wrapper = mount(App) + + expect(wrapper).toBeDefined() + expect(wrapper.vm).toBeDefined() + expect(wrapper.element.tagName).toBe('BUTTON') + + const $test = wrapper.findComponent(BTest) + + expect($test.exists()).toBe(true) + expect($test.vm).toBeDefined() + + expect($test.vm.bvListeners).toBeDefined() + expect($test.vm.bvListeners.click).not.toBeDefined() + expect($test.vm.bvListeners.focus).not.toBeDefined() + expect($test.vm.bvListeners.blur).not.toBeDefined() + + // Correctly adds new listeners + await wrapper.setProps({ + listenClick: true, + listenFocus: true + }) + + expect($test.vm.bvListeners.click).toBeDefined() + expect($test.vm.bvListeners.focus).toBeDefined() + expect($test.vm.bvListeners.blur).not.toBeDefined() + + // Correctly updates listeners + await wrapper.setProps({ + listenClick: false, + listenBlur: true + }) + + expect($test.vm.bvListeners.click).not.toBeDefined() + expect($test.vm.bvListeners.focus).toBeDefined() + expect($test.vm.bvListeners.blur).toBeDefined() + + // Correctly removes listeners + await wrapper.setProps({ + listenClick: false, + listenFocus: false, + listenBlur: false + }) + + expect($test.vm.bvListeners.click).not.toBeDefined() + expect($test.vm.bvListeners.focus).not.toBeDefined() + expect($test.vm.bvListeners.blur).not.toBeDefined() + + wrapper.destroy() + }) + + it('does not re-render parent child components', async () => { + let input1RenderCount = 0 + let input2RenderCount = 0 + + const Input1 = { + props: ['value'], + render(h) { + input1RenderCount++ + return h('input', { + attrs: { value: this.value }, + domProps: { value: this.value }, + on: { ...this.$listeners, input: e => this.$emit('input', e.target.value) } + }) + } + } + const Input2 = { + props: ['value'], + mixins: [listenersMixin], + render(h) { + input2RenderCount++ + return h('input', { + attrs: { value: this.value }, + domProps: { value: this.value }, + on: { ...this.bvListeners, input: e => this.$emit('input', e.target.value) } + }) + } + } + + const App1 = { + components: { Input1 }, + props: ['listenFocus1', 'listenFocus2'], + template: `
+ + +
` + } + const App2 = { + components: { Input2 }, + props: ['listenFocus1', 'listenFocus2'], + template: `
+ + +
` + } + + const wrapper1 = mount(App1) + const wrapper2 = mount(App2) + + // --- `Input1` tests --- + + const $inputs1 = wrapper1.findAllComponents(Input1) + expect($inputs1.length).toBe(2) + expect($inputs1.at(0)).toBeDefined() + expect($inputs1.at(1)).toBeDefined() + expect(wrapper1.emitted().focus1).not.toBeTruthy() + expect(wrapper1.emitted().focus2).not.toBeTruthy() + expect(input1RenderCount).toBe(2) + + await $inputs1.at(0).trigger('focus') + expect(wrapper1.emitted().focus1).not.toBeTruthy() + await $inputs1.at(1).trigger('focus') + expect(wrapper1.emitted().focus2).not.toBeTruthy() + expect(input1RenderCount).toBe(2) + + // Enable focus events for the first input and trigger it + await wrapper1.setProps({ listenFocus1: true }) + await $inputs1.at(0).trigger('focus') + expect(wrapper1.emitted().focus1).toBeTruthy() + expect(wrapper1.emitted().focus2).not.toBeTruthy() + // Both `Input1`'s are re-rendered (See: https://github.com/vuejs/vue/issues/7257) + expect(input1RenderCount).toBe(4) + + // Enable focus events for the second input and trigger it + await wrapper1.setProps({ listenFocus2: true }) + await $inputs1.at(1).trigger('focus') + expect(wrapper1.emitted().focus1).toBeTruthy() + expect(wrapper1.emitted().focus2).toBeTruthy() + // Both `Input1`'s are re-rendered (See: https://github.com/vuejs/vue/issues/7257) + expect(input1RenderCount).toBe(6) + + // --- `Input2` tests --- + + const $inputs2 = wrapper2.findAllComponents(Input2) + expect($inputs2.length).toBe(2) + expect($inputs2.at(0)).toBeDefined() + expect($inputs2.at(1)).toBeDefined() + expect(wrapper2.emitted().focus1).not.toBeTruthy() + expect(wrapper2.emitted().focus2).not.toBeTruthy() + expect(input2RenderCount).toBe(2) + + await $inputs2.at(0).trigger('focus') + expect(wrapper2.emitted().focus1).not.toBeTruthy() + await $inputs2.at(1).trigger('focus') + expect(wrapper2.emitted().focus2).not.toBeTruthy() + expect(input2RenderCount).toBe(2) + + // Enable focus events for the first input and trigger it + await wrapper2.setProps({ listenFocus1: true }) + await $inputs2.at(0).trigger('focus') + expect(wrapper2.emitted().focus1).toBeTruthy() + expect(wrapper2.emitted().focus2).not.toBeTruthy() + // With `listenersMixin` only the affected `Input2` is re-rendered + expect(input2RenderCount).toBe(2) + + // Enable focus events for the second input and trigger it + await wrapper2.setProps({ listenFocus2: true }) + await $inputs2.at(1).trigger('focus') + expect(wrapper2.emitted().focus1).toBeTruthy() + expect(wrapper2.emitted().focus2).toBeTruthy() + // With `listenersMixin` only the affected `Input2` is re-rendered + expect(input2RenderCount).toBe(2) + + wrapper1.destroy() + wrapper2.destroy() + }) +}) diff --git a/src/utils/cache.js b/src/utils/cache.js new file mode 100644 index 00000000000..c6faef986b0 --- /dev/null +++ b/src/utils/cache.js @@ -0,0 +1,29 @@ +import { hasOwnProperty } from './object' + +export const makePropWatcher = propName => ({ + handler(newVal, oldVal) { + for (const key in oldVal) { + if (!hasOwnProperty(newVal, key)) { + this.$delete(this.$data[propName], key) + } + } + for (const key in newVal) { + this.$set(this.$data[propName], key, newVal[key]) + } + } +}) + +export const makePropCacheMixin = (propName, proxyPropName) => ({ + data() { + return { + [proxyPropName]: {} + } + }, + watch: { + // Work around unwanted re-renders: https://github.com/vuejs/vue/issues/10115 + [propName]: makePropWatcher(proxyPropName) + }, + created() { + this[proxyPropName] = { ...this[propName] } + } +})