diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48d75068860..f16a470e4a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -207,4 +207,3 @@ jobs: run: yarn run bundlewatch env: BUNDLEWATCH_GITHUB_TOKEN: "${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}" - CI_BRANCH_BASE: "${{ github.base_ref }}" diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index d9974a53b6a..6af4e2b6e20 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -1,9 +1,9 @@ import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { getComponentConfig } from '../../utils/config' import { isNumber, isString, isUndefinedOrNull } from '../../utils/inspect' import { toFloat } from '../../utils/number' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { isLink } from '../../utils/router' import { BButton } from '../button/button' import { BLink, props as BLinkProps } from '../link/link' diff --git a/src/components/badge/badge.js b/src/components/badge/badge.js index 47941d8e2ba..ab6240fef7d 100644 --- a/src/components/badge/badge.js +++ b/src/components/badge/badge.js @@ -1,8 +1,8 @@ -import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { mergeData } from 'vue-functional-data-merge' +import Vue from '../../utils/vue' import { getComponentConfig } from '../../utils/config' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { isLink } from '../../utils/router' import { BLink, props as BLinkProps } from '../link/link' diff --git a/src/components/breadcrumb/breadcrumb-link.js b/src/components/breadcrumb/breadcrumb-link.js index 232d1f99d85..e4621bb12c3 100644 --- a/src/components/breadcrumb/breadcrumb-link.js +++ b/src/components/breadcrumb/breadcrumb-link.js @@ -1,10 +1,12 @@ import { mergeData } from 'vue-functional-data-merge' import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { htmlOrText } from '../../utils/html' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { BLink, props as BLinkProps } from '../link/link' +// --- Props --- + export const props = { text: { type: String, @@ -21,17 +23,19 @@ export const props = { ...omit(BLinkProps, ['event', 'routerTag']) } +// --- Main component --- // @vue/component export const BBreadcrumbLink = /*#__PURE__*/ Vue.extend({ name: 'BBreadcrumbLink', functional: true, props, render(h, { props: suppliedProps, data, children }) { - const tag = suppliedProps.active ? 'span' : BLink + const { active } = suppliedProps + const tag = active ? 'span' : BLink - const componentData = { props: pluckProps(props, suppliedProps) } - if (suppliedProps.active) { - componentData.attrs = { 'aria-current': suppliedProps.ariaCurrent } + const componentData = { + attrs: { 'aria-current': active ? suppliedProps.ariaCurrent : null }, + props: pluckProps(props, suppliedProps) } if (!children) { diff --git a/src/components/button/button.js b/src/components/button/button.js index 7704e66bfbf..c98add29382 100644 --- a/src/components/button/button.js +++ b/src/components/button/button.js @@ -1,12 +1,12 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' +import Vue from '../../utils/vue' import KeyCodes from '../../utils/key-codes' -import pluckProps from '../../utils/pluck-props' import { concat } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { addClass, isTag, removeClass } from '../../utils/dom' import { isBoolean, isEvent, isFunction } from '../../utils/inspect' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { isLink as isLinkStrict } from '../../utils/router' import { BLink, props as BLinkProps } from '../link/link' diff --git a/src/components/card/card-body.js b/src/components/card/card-body.js index fd66c408c8d..76cbd1a08f0 100644 --- a/src/components/card/card-body.js +++ b/src/components/card/card-body.js @@ -1,8 +1,6 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' -import prefixPropName from '../../utils/prefix-prop-name' -import copyProps from '../../utils/copy-props' -import pluckProps from '../../utils/pluck-props' +import Vue from '../../utils/vue' +import { copyProps, pluckProps, prefixPropName } from '../../utils/props' import cardMixin from '../../mixins/card' import { BCardTitle, props as titleProps } from './card-title' import { BCardSubTitle, props as subTitleProps } from './card-sub-title' diff --git a/src/components/card/card-footer.js b/src/components/card/card-footer.js index cd1cf4550a7..459e2ff9739 100644 --- a/src/components/card/card-footer.js +++ b/src/components/card/card-footer.js @@ -1,11 +1,11 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' - -import prefixPropName from '../../utils/prefix-prop-name' -import copyProps from '../../utils/copy-props' +import Vue from '../../utils/vue' import { htmlOrText } from '../../utils/html' +import { copyProps, prefixPropName } from '../../utils/props' import cardMixin from '../../mixins/card' +// --- Props --- + export const props = { ...copyProps(cardMixin.props, prefixPropName.bind(null, 'footer')), footer: { @@ -22,12 +22,15 @@ export const props = { } } +// --- Main component --- // @vue/component export const BCardFooter = /*#__PURE__*/ Vue.extend({ name: 'BCardFooter', functional: true, props, render(h, { props, data, children }) { + const { footerBgVariant, footerBorderVariant, footerTextVariant } = props + return h( props.footerTag, mergeData(data, { @@ -35,13 +38,14 @@ export const BCardFooter = /*#__PURE__*/ Vue.extend({ class: [ props.footerClass, { - [`bg-${props.footerBgVariant}`]: props.footerBgVariant, - [`border-${props.footerBorderVariant}`]: props.footerBorderVariant, - [`text-${props.footerTextVariant}`]: props.footerTextVariant + [`bg-${footerBgVariant}`]: footerBgVariant, + [`border-${footerBorderVariant}`]: footerBorderVariant, + [`text-${footerTextVariant}`]: footerTextVariant } - ] + ], + domProps: children ? {} : htmlOrText(props.footerHtml, props.footer) }), - children || [h('div', { domProps: htmlOrText(props.footerHtml, props.footer) })] + children ) } }) diff --git a/src/components/card/card-header.js b/src/components/card/card-header.js index cf43ec65956..73f2927464d 100644 --- a/src/components/card/card-header.js +++ b/src/components/card/card-header.js @@ -1,10 +1,11 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' -import prefixPropName from '../../utils/prefix-prop-name' -import copyProps from '../../utils/copy-props' +import Vue from '../../utils/vue' import { htmlOrText } from '../../utils/html' +import { copyProps, prefixPropName } from '../../utils/props' import cardMixin from '../../mixins/card' +// --- Props --- + export const props = { ...copyProps(cardMixin.props, prefixPropName.bind(null, 'header')), header: { @@ -21,12 +22,15 @@ export const props = { } } +// --- Main component --- // @vue/component export const BCardHeader = /*#__PURE__*/ Vue.extend({ name: 'BCardHeader', functional: true, props, render(h, { props, data, children }) { + const { headerBgVariant, headerBorderVariant, headerTextVariant } = props + return h( props.headerTag, mergeData(data, { @@ -34,13 +38,14 @@ export const BCardHeader = /*#__PURE__*/ Vue.extend({ class: [ props.headerClass, { - [`bg-${props.headerBgVariant}`]: props.headerBgVariant, - [`border-${props.headerBorderVariant}`]: props.headerBorderVariant, - [`text-${props.headerTextVariant}`]: props.headerTextVariant + [`bg-${headerBgVariant}`]: headerBgVariant, + [`border-${headerBorderVariant}`]: headerBorderVariant, + [`text-${headerTextVariant}`]: headerTextVariant } - ] + ], + domProps: children ? {} : htmlOrText(props.headerHtml, props.header) }), - children || [h('div', { domProps: htmlOrText(props.headerHtml, props.header) })] + children ) } }) diff --git a/src/components/card/card.js b/src/components/card/card.js index 071de824ae4..aff8496f823 100644 --- a/src/components/card/card.js +++ b/src/components/card/card.js @@ -1,10 +1,8 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' -import prefixPropName from '../../utils/prefix-prop-name' -import unPrefixPropName from '../../utils/unprefix-prop-name' -import copyProps from '../../utils/copy-props' -import pluckProps from '../../utils/pluck-props' +import Vue from '../../utils/vue' +import { htmlOrText } from '../../utils/html' import { hasNormalizedSlot, normalizeSlot } from '../../utils/normalize-slot' +import { copyProps, pluckProps, prefixPropName, unprefixPropName } from '../../utils/props' import cardMixin from '../../mixins/card' import { BCardBody, props as bodyProps } from './card-body' import { BCardHeader, props as headerProps } from './card-header' @@ -36,49 +34,68 @@ export const BCard = /*#__PURE__*/ Vue.extend({ functional: true, props, render(h, { props, data, slots, scopedSlots }) { - const $slots = slots() - // Vue < 2.6.x may return undefined for scopedSlots + const { + imgLeft, + imgRight, + imgStart, + imgEnd, + header, + headerHtml, + footer, + footerHtml, + align, + textVariant, + bgVariant, + borderVariant + } = props const $scopedSlots = scopedSlots || {} + const $slots = slots() + const slotScope = {} - // Create placeholder elements for each section - let imgFirst = h() - let header = h() - let content = h() - let footer = h() - let imgLast = h() - + let $imgFirst = h() + let $imgLast = h() if (props.imgSrc) { - const img = h(BCardImg, { - props: pluckProps(cardImgProps, props, unPrefixPropName.bind(null, 'img')) + const $img = h(BCardImg, { + props: pluckProps(cardImgProps, props, unprefixPropName.bind(null, 'img')) }) + if (props.imgBottom) { - imgLast = img + $imgLast = $img } else { - imgFirst = img + $imgFirst = $img } } - if (props.header || props.headerHtml || hasNormalizedSlot('header', $scopedSlots, $slots)) { - header = h( + let $header = h() + const hasHeaderSlot = hasNormalizedSlot('header', $scopedSlots, $slots) + if (hasHeaderSlot || header || headerHtml) { + $header = h( BCardHeader, - { props: pluckProps(headerProps, props) }, - normalizeSlot('header', {}, $scopedSlots, $slots) + { + props: pluckProps(headerProps, props), + domProps: hasHeaderSlot ? {} : htmlOrText(headerHtml, header) + }, + normalizeSlot('header', slotScope, $scopedSlots, $slots) ) } - content = normalizeSlot('default', {}, $scopedSlots, $slots) || [] + let $content = normalizeSlot('default', slotScope, $scopedSlots, $slots) + + // Wrap content in when `noBody` prop set if (!props.noBody) { - // Wrap content in card-body - content = [h(BCardBody, { props: pluckProps(bodyProps, props) }, [...content])] + $content = h(BCardBody, { props: pluckProps(bodyProps, props) }, $content) } - if (props.footer || props.footerHtml || hasNormalizedSlot('footer', $scopedSlots, $slots)) { - footer = h( + let $footer = h() + const hasFooterSlot = hasNormalizedSlot('footer', $scopedSlots, $slots) + if (hasFooterSlot || footer || footerHtml) { + $footer = h( BCardFooter, { - props: pluckProps(footerProps, props) + props: pluckProps(footerProps, props), + domProps: hasHeaderSlot ? {} : htmlOrText(footerHtml, footer) }, - normalizeSlot('footer', {}, $scopedSlots, $slots) + normalizeSlot('footer', slotScope, $scopedSlots, $slots) ) } @@ -87,16 +104,15 @@ export const BCard = /*#__PURE__*/ Vue.extend({ mergeData(data, { staticClass: 'card', class: { - 'flex-row': props.imgLeft || props.imgStart, - 'flex-row-reverse': - (props.imgRight || props.imgEnd) && !(props.imgLeft || props.imgStart), - [`text-${props.align}`]: props.align, - [`bg-${props.bgVariant}`]: props.bgVariant, - [`border-${props.borderVariant}`]: props.borderVariant, - [`text-${props.textVariant}`]: props.textVariant + 'flex-row': imgLeft || imgStart, + 'flex-row-reverse': (imgRight || imgEnd) && !(imgLeft || imgStart), + [`text-${align}`]: align, + [`bg-${bgVariant}`]: bgVariant, + [`border-${borderVariant}`]: borderVariant, + [`text-${textVariant}`]: textVariant } }), - [imgFirst, header, ...content, footer, imgLast] + [$imgFirst, $header, $content, $footer, $imgLast] ) } }) diff --git a/src/components/carousel/carousel-slide.js b/src/components/carousel/carousel-slide.js index 0f2bdb15181..e5953afa83c 100644 --- a/src/components/carousel/carousel-slide.js +++ b/src/components/carousel/carousel-slide.js @@ -1,11 +1,14 @@ import Vue from '../../utils/vue' -import idMixin from '../../mixins/id' -import normalizeSlotMixin from '../../mixins/normalize-slot' import { hasTouchSupport } from '../../utils/env' import { htmlOrText } from '../../utils/html' +import { pluckProps, unprefixPropName } from '../../utils/props' +import idMixin from '../../mixins/id' +import normalizeSlotMixin from '../../mixins/normalize-slot' import { BImg } from '../image/img' -export const props = { +// --- Props --- + +const imgProps = { imgSrc: { type: String // default: undefined @@ -29,7 +32,11 @@ export const props = { imgBlankColor: { type: String, default: 'transparent' - }, + } +} + +export const props = { + ...imgProps, contentVisibleUp: { type: String }, @@ -62,6 +69,7 @@ export const props = { } } +// --- Main component --- // @vue/component export const BCarouselSlide = /*#__PURE__*/ Vue.extend({ name: 'BCarouselSlide', @@ -94,55 +102,51 @@ export const BCarouselSlide = /*#__PURE__*/ Vue.extend({ } }, render(h) { - const noDrag = !this.bvCarousel.noTouch && hasTouchSupport + let $img = this.normalizeSlot('img') + if (!$img && (this.imgSrc || this.imgBlank)) { + const on = {} + // Touch support event handler + /* istanbul ignore if: difficult to test in JSDOM */ + if (!this.bvCarousel.noTouch && hasTouchSupport) { + on.dragstart = evt => { + evt.preventDefault() + } + } - let img = this.normalizeSlot('img') - if (!img && (this.imgSrc || this.imgBlank)) { - img = h(BImg, { + $img = h(BImg, { props: { - fluidGrow: true, - block: true, - src: this.imgSrc, - blank: this.imgBlank, - blankColor: this.imgBlankColor, + ...pluckProps(imgProps, this.$props, unprefixPropName.bind(null, 'img')), width: this.computedWidth, height: this.computedHeight, - alt: this.imgAlt + fluidGrow: true, + block: true }, - // Touch support event handler - on: noDrag - ? /* istanbul ignore next */ { - dragstart /* istanbul ignore next */: e => { - /* istanbul ignore next: difficult to test in JSDOM */ - e.preventDefault() - } - } - : {} + on }) } - if (!img) { - img = h() - } - let content = h() - - const contentChildren = [ + const $contentChildren = [ + // Caption this.caption || this.captionHtml - ? h(this.captionTag, { - domProps: htmlOrText(this.captionHtml, this.caption) - }) + ? h(this.captionTag, { domProps: htmlOrText(this.captionHtml, this.caption) }) : false, + // Text this.text || this.textHtml ? h(this.textTag, { domProps: htmlOrText(this.textHtml, this.text) }) : false, + // Children this.normalizeSlot('default') || false ] - if (contentChildren.some(Boolean)) { - content = h( + let $content = h() + if ($contentChildren.some(Boolean)) { + $content = h( this.contentTag, - { staticClass: 'carousel-caption', class: this.contentClasses }, - contentChildren.map(i => i || h()) + { + staticClass: 'carousel-caption', + class: this.contentClasses + }, + $contentChildren.map($child => $child || h()) ) } @@ -153,7 +157,7 @@ export const BCarouselSlide = /*#__PURE__*/ Vue.extend({ style: { background: this.background || this.bvCarousel.background || null }, attrs: { id: this.safeId(), role: 'listitem' } }, - [img, content] + [$img, $content] ) } }) diff --git a/src/components/dropdown/dropdown.js b/src/components/dropdown/dropdown.js index 9490b05e490..611223f4c3a 100644 --- a/src/components/dropdown/dropdown.js +++ b/src/components/dropdown/dropdown.js @@ -1,14 +1,18 @@ import Vue from '../../utils/vue' import { arrayIncludes } from '../../utils/array' -import { stripTags } from '../../utils/html' import { getComponentConfig } from '../../utils/config' -import idMixin from '../../mixins/id' +import { htmlOrText } from '../../utils/html' import dropdownMixin from '../../mixins/dropdown' +import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import { BButton } from '../button/button' +// --- Constants --- + const NAME = 'BDropdown' +// --- Props --- + export const props = { text: { // Button label @@ -20,14 +24,14 @@ export const props = { type: String // default: undefined }, - size: { - type: String, - default: () => getComponentConfig(NAME, 'size') - }, variant: { type: String, default: () => getComponentConfig(NAME, 'variant') }, + size: { + type: String, + default: () => getComponentConfig(NAME, 'size') + }, block: { type: Boolean, default: false @@ -89,6 +93,7 @@ export const props = { } } +// --- Main component --- // @vue/component export const BDropdown = /*#__PURE__*/ Vue.extend({ name: NAME, @@ -96,6 +101,7 @@ export const BDropdown = /*#__PURE__*/ Vue.extend({ props, computed: { dropdownClasses() { + const { block, split, boundary } = this return [ this.directionClass, { @@ -103,14 +109,14 @@ export const BDropdown = /*#__PURE__*/ Vue.extend({ // The 'btn-group' class is required in `split` mode for button alignment // It needs also to be applied when `block` is disabled to allow multiple // dropdowns to be aligned one line - 'btn-group': this.split || !this.block, + 'btn-group': split || !block, // When `block` is enabled and we are in `split` mode the 'd-flex' class // needs to be applied to allow the buttons to stretch to full width - 'd-flex': this.block && this.split, + 'd-flex': block && split, // Position `static` is needed to allow menu to "breakout" of the `scrollParent` // boundaries when boundary is anything other than `scrollParent` // See: https://github.com/twbs/bootstrap/issues/24251#issuecomment-341413786 - 'position-static': this.boundary !== 'scrollParent' || !this.boundary + 'position-static': boundary !== 'scrollParent' || !boundary } ] }, @@ -124,92 +130,99 @@ export const BDropdown = /*#__PURE__*/ Vue.extend({ ] }, toggleClasses() { + const { split } = this return [ this.toggleClass, { - 'dropdown-toggle-split': this.split, - 'dropdown-toggle-no-caret': this.noCaret && !this.split + 'dropdown-toggle-split': split, + 'dropdown-toggle-no-caret': this.noCaret && !split } ] } }, render(h) { - let split = h() - const buttonContent = this.normalizeSlot('button-content') || this.html || stripTags(this.text) - if (this.split) { + const { variant, size, block, disabled, split, role } = this + const commonProps = { variant, size, block, disabled } + + const $buttonContent = this.normalizeSlot('button-content') + const buttonContentProps = this.hasNormalizedSlot('button-content') + ? {} + : htmlOrText(this.html, this.text) + + let $split = h() + if (split) { + const { splitTo, splitHref, splitButtonType } = this const btnProps = { - variant: this.splitVariant || this.variant, - size: this.size, - block: this.block, - disabled: this.disabled + ...commonProps, + variant: this.splitVariant || this.variant } - // We add these as needed due to router-link issues with defined property with undefined/null values - if (this.splitTo) { - btnProps.to = this.splitTo - } else if (this.splitHref) { - btnProps.href = this.splitHref - } else if (this.splitButtonType) { - btnProps.type = this.splitButtonType + // We add these as needed due to issues with + // defined property with `undefined`/`null` values + if (splitTo) { + btnProps.to = splitTo + } else if (splitHref) { + btnProps.href = splitHref + } else if (splitButtonType) { + btnProps.type = splitButtonType } - split = h( + $split = h( BButton, { - ref: 'button', - props: btnProps, class: this.splitClass, - attrs: { - id: this.safeId('_BV_button_') - }, - on: { - click: this.onSplitClick - } + attrs: { id: this.safeId('_BV_button_') }, + props: btnProps, + domProps: buttonContentProps, + on: { click: this.onSplitClick }, + ref: 'button' }, - [buttonContent] + [$buttonContent] ) } - const toggle = h( + + const $toggle = h( BButton, { - ref: 'toggle', staticClass: 'dropdown-toggle', class: this.toggleClasses, - props: { - tag: this.toggleTag, - variant: this.variant, - size: this.size, - block: this.block && !this.split, - disabled: this.disabled - }, attrs: { id: this.safeId('_BV_toggle_'), 'aria-haspopup': 'true', 'aria-expanded': this.visible ? 'true' : 'false' }, + props: { + ...commonProps, + tag: this.toggleTag, + block: block && !split + }, + domProps: split ? {} : buttonContentProps, on: { mousedown: this.onMousedown, click: this.toggle, keydown: this.toggle // Handle ENTER, SPACE and DOWN - } + }, + ref: 'toggle' }, - [this.split ? h('span', { class: ['sr-only'] }, [this.toggleText]) : buttonContent] + [split ? h('span', { class: ['sr-only'] }, [this.toggleText]) : $buttonContent] ) - const menu = h( + + const $menu = h( 'ul', { - ref: 'menu', staticClass: 'dropdown-menu', class: this.menuClasses, attrs: { - role: this.role, + role, tabindex: '-1', - 'aria-labelledby': this.safeId(this.split ? '_BV_button_' : '_BV_toggle_') + 'aria-labelledby': this.safeId(split ? '_BV_button_' : '_BV_toggle_') }, on: { keydown: this.onKeydown // Handle UP, DOWN and ESC - } + }, + ref: 'menu' }, !this.lazy || this.visible ? this.normalizeSlot('default', { hide: this.hide }) : [h()] ) + return h( 'div', { @@ -217,7 +230,7 @@ export const BDropdown = /*#__PURE__*/ Vue.extend({ class: this.dropdownClasses, attrs: { id: this.safeId() } }, - [split, toggle, menu] + [$split, $toggle, $menu] ) } }) diff --git a/src/components/dropdown/dropdown.spec.js b/src/components/dropdown/dropdown.spec.js index 651199f70e3..4ed362e18ba 100644 --- a/src/components/dropdown/dropdown.spec.js +++ b/src/components/dropdown/dropdown.spec.js @@ -199,6 +199,52 @@ describe('dropdown', () => { wrapper.destroy() }) + it('renders button-content slot inside toggle button', async () => { + const wrapper = mount(BDropdown, { + attachTo: createContainer(), + slots: { + 'button-content': 'foobar' + } + }) + + expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.vm).toBeDefined() + + expect(wrapper.findAll('button').length).toBe(1) + expect(wrapper.findAll('.dropdown-toggle').length).toBe(1) + const $toggle = wrapper.find('.dropdown-toggle') + expect($toggle.text()).toEqual('foobar') + + wrapper.destroy() + }) + + it('renders button-content slot inside split button', async () => { + const wrapper = mount(BDropdown, { + attachTo: createContainer(), + propsData: { + split: true + }, + slots: { + 'button-content': 'foobar' + } + }) + + expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.vm).toBeDefined() + + expect(wrapper.findAll('button').length).toBe(2) + const $buttons = wrapper.findAll('button') + const $split = $buttons.at(0) + const $toggle = $buttons.at(1) + + expect($split.text()).toEqual('foobar') + expect($toggle.classes()).toContain('dropdown-toggle') + // Toggle has `sr-only` hidden text + expect($toggle.text()).toEqual('Toggle Dropdown') + + wrapper.destroy() + }) + it('does not render default slot inside menu when prop lazy set', async () => { const wrapper = mount(BDropdown, { attachTo: createContainer(), diff --git a/src/components/form-select/form-select-option-group.js b/src/components/form-select/form-select-option-group.js index ba2c8156d02..6c3e0622fcc 100644 --- a/src/components/form-select/form-select-option-group.js +++ b/src/components/form-select/form-select-option-group.js @@ -15,15 +15,19 @@ const BFormSelectOptionGroup = /*#__PURE__*/ Vue.extend({ } }, render(h) { + const $options = this.formOptions.map((option, index) => { + const { value, text, html, disabled } = option + + return h(BFormSelectOption, { + attrs: { value, disabled }, + domProps: htmlOrText(html, text), + key: `option_${index}` + }) + }) + return h('optgroup', { attrs: { label: this.label } }, [ this.normalizeSlot('first'), - this.formOptions.map((option, index) => - h(BFormSelectOption, { - props: { value: option.value, disabled: option.disabled }, - domProps: htmlOrText(option.html, option.text), - key: `option_${index}_opt` - }) - ), + $options, this.normalizeSlot('default') ]) } diff --git a/src/components/form-select/form-select.js b/src/components/form-select/form-select.js index 81342a5988d..e56f84ef4a2 100644 --- a/src/components/form-select/form-select.js +++ b/src/components/form-select/form-select.js @@ -2,11 +2,11 @@ import Vue from '../../utils/vue' import { from as arrayFrom, isArray } from '../../utils/array' import { attemptBlur, attemptFocus } from '../../utils/dom' import { htmlOrText } from '../../utils/html' -import idMixin from '../../mixins/id' +import formCustomMixin from '../../mixins/form-custom' import formMixin from '../../mixins/form' import formSizeMixin from '../../mixins/form-size' import formStateMixin from '../../mixins/form-state' -import formCustomMixin from '../../mixins/form-custom' +import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' import optionsMixin from './helpers/mixin-options' import { BFormSelectOption } from './form-select-option' @@ -88,61 +88,54 @@ export const BFormSelect = /*#__PURE__*/ Vue.extend({ }, blur() { attemptBlur(this.$refs.input) + }, + onChange(evt) { + const { target } = evt + const selectedVal = arrayFrom(target.options) + .filter(o => o.selected) + .map(o => ('_value' in o ? o._value : o.value)) + this.localValue = target.multiple ? selectedVal : selectedVal[0] + this.$nextTick(() => { + this.$emit('change', this.localValue) + }) } }, render(h) { + const { name, disabled, required, computedSelectSize: size, localValue: value } = this + + const $options = this.formOptions.map((option, index) => { + const { value, label, options, disabled } = option + const key = `option_${index}` + + return isArray(options) + ? h(BFormSelectOptionGroup, { props: { label, options }, key }) + : h(BFormSelectOption, { + props: { value, disabled }, + domProps: htmlOrText(option.html, option.text), + key + }) + }) + return h( 'select', { - ref: 'input', class: this.inputClass, - directives: [ - { - name: 'model', - rawName: 'v-model', - value: this.localValue, - expression: 'localValue' - } - ], attrs: { id: this.safeId(), - name: this.name, + name, form: this.form || null, multiple: this.multiple || null, - size: this.computedSelectSize, - disabled: this.disabled, - required: this.required, - 'aria-required': this.required ? 'true' : null, + size, + disabled, + required, + 'aria-required': required ? 'true' : null, 'aria-invalid': this.computedAriaInvalid }, - on: { - change: evt => { - const target = evt.target - const selectedVal = arrayFrom(target.options) - .filter(o => o.selected) - .map(o => ('_value' in o ? o._value : o.value)) - this.localValue = target.multiple ? selectedVal : selectedVal[0] - this.$nextTick(() => { - this.$emit('change', this.localValue) - }) - } - } + on: { change: this.onChange }, + directives: [{ name: 'model', value }], + ref: 'input' }, - [ - this.normalizeSlot('first'), - this.formOptions.map((option, index) => { - const key = `option_${index}_opt` - const options = option.options - return isArray(options) - ? h(BFormSelectOptionGroup, { props: { label: option.label, options }, key }) - : h(BFormSelectOption, { - props: { value: option.value, disabled: option.disabled }, - domProps: htmlOrText(option.html, option.text), - key - }) - }), - this.normalizeSlot('default') - ] + [this.normalizeSlot('first'), $options, this.normalizeSlot('default')] ) } }) diff --git a/src/components/form/form-datalist.js b/src/components/form/form-datalist.js index c9c17f18432..6cabccdcf42 100644 --- a/src/components/form/form-datalist.js +++ b/src/components/form/form-datalist.js @@ -1,7 +1,7 @@ import Vue from '../../utils/vue' +import { htmlOrText } from '../../utils/html' import formOptionsMixin from '../../mixins/form-options' import normalizeSlotMixin from '../../mixins/normalize-slot' -import { htmlOrText } from '../../utils/html' // @vue/component export const BFormDatalist = /*#__PURE__*/ Vue.extend({ @@ -14,13 +14,16 @@ export const BFormDatalist = /*#__PURE__*/ Vue.extend({ } }, render(h) { - const options = this.formOptions.map((option, index) => { + const $options = this.formOptions.map((option, index) => { + const { value, text, html, disabled } = option + return h('option', { - key: `option_${index}_opt`, - attrs: { disabled: option.disabled }, - domProps: { ...htmlOrText(option.html, option.text), value: option.value } + attrs: { value, disabled }, + domProps: htmlOrText(html, text), + key: `option_${index}` }) }) - return h('datalist', { attrs: { id: this.id } }, [options, this.normalizeSlot('default')]) + + return h('datalist', { attrs: { id: this.id } }, [$options, this.normalizeSlot('default')]) } }) diff --git a/src/components/input-group/input-group.js b/src/components/input-group/input-group.js index 8f566bb7874..f5a97f0f47e 100644 --- a/src/components/input-group/input-group.js +++ b/src/components/input-group/input-group.js @@ -1,14 +1,18 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' +import Vue from '../../utils/vue' import { getComponentConfig } from '../../utils/config' import { htmlOrText } from '../../utils/html' import { hasNormalizedSlot, normalizeSlot } from '../../utils/normalize-slot' -import { BInputGroupPrepend } from './input-group-prepend' import { BInputGroupAppend } from './input-group-append' +import { BInputGroupPrepend } from './input-group-prepend' import { BInputGroupText } from './input-group-text' +// --- Constants --- + const NAME = 'BInputGroup' +// --- Props --- + export const props = { id: { type: String @@ -35,67 +39,49 @@ export const props = { } } +// --- Main component --- // @vue/component export const BInputGroup = /*#__PURE__*/ Vue.extend({ name: NAME, functional: true, props, render(h, { props, data, slots, scopedSlots }) { - const $slots = slots() + const { prepend, prependHtml, append, appendHtml, size } = props const $scopedSlots = scopedSlots || {} + const $slots = slots() + const slotScope = {} - const childNodes = [] - - // Prepend prop/slot - if (props.prepend || props.prependHtml || hasNormalizedSlot('prepend', $scopedSlots, $slots)) { - childNodes.push( - h(BInputGroupPrepend, [ - // Prop - props.prepend || props.prependHtml - ? h(BInputGroupText, { domProps: htmlOrText(props.prependHtml, props.prepend) }) - : h(), - // Slot - normalizeSlot('prepend', {}, $scopedSlots, $slots) || h() - ]) - ) - } else { - childNodes.push(h()) - } - - // Default slot - if (hasNormalizedSlot('default', $scopedSlots, $slots)) { - childNodes.push(...normalizeSlot('default', {}, $scopedSlots, $slots)) - } else { - childNodes.push(h()) + let $prepend = h() + const hasPrependSlot = hasNormalizedSlot('prepend', $scopedSlots, $slots) + if (hasPrependSlot || prepend || prependHtml) { + $prepend = h(BInputGroupPrepend, [ + hasPrependSlot + ? normalizeSlot('prepend', slotScope, $scopedSlots, $slots) + : h(BInputGroupText, { domProps: htmlOrText(prependHtml, prepend) }) + ]) } - // Append prop - if (props.append || props.appendHtml || hasNormalizedSlot('append', $scopedSlots, $slots)) { - childNodes.push( - h(BInputGroupAppend, [ - // prop - props.append || props.appendHtml - ? h(BInputGroupText, { domProps: htmlOrText(props.appendHtml, props.append) }) - : h(), - // Slot - normalizeSlot('append', {}, $scopedSlots, $slots) || h() - ]) - ) - } else { - childNodes.push(h()) + let $append = h() + const hasAppendSlot = hasNormalizedSlot('append', $scopedSlots, $slots) + if (hasAppendSlot || append || appendHtml) { + $append = h(BInputGroupAppend, [ + hasAppendSlot + ? normalizeSlot('append', slotScope, $scopedSlots, $slots) + : h(BInputGroupText, { domProps: htmlOrText(appendHtml, append) }) + ]) } return h( props.tag, mergeData(data, { staticClass: 'input-group', - class: { [`input-group-${props.size}`]: props.size }, + class: { [`input-group-${size}`]: size }, attrs: { id: props.id || null, role: 'group' } }), - childNodes + [$prepend, normalizeSlot('default', slotScope, $scopedSlots, $slots), $append] ) } }) diff --git a/src/components/jumbotron/jumbotron.js b/src/components/jumbotron/jumbotron.js index bcfc81bffb5..9bcc585e168 100644 --- a/src/components/jumbotron/jumbotron.js +++ b/src/components/jumbotron/jumbotron.js @@ -1,12 +1,16 @@ import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' import { getComponentConfig } from '../../utils/config' -import { stripTags } from '../../utils/html' +import { htmlOrText } from '../../utils/html' import { hasNormalizedSlot, normalizeSlot } from '../../utils/normalize-slot' import { BContainer } from '../layout/container' +// --- Constants --- + const NAME = 'BJumbotron' +// --- Props --- + export const props = { fluid: { type: Boolean, @@ -62,70 +66,66 @@ export const props = { } } +// --- Main component --- // @vue/component export const BJumbotron = /*#__PURE__*/ Vue.extend({ name: NAME, functional: true, props, render(h, { props, data, slots, scopedSlots }) { - // The order of the conditionals matter. - // We are building the component markup in order. - let childNodes = [] - const $slots = slots() + const { header, headerHtml, lead, leadHtml, textVariant, bgVariant, borderVariant } = props const $scopedSlots = scopedSlots || {} + const $slots = slots() + const slotScope = {} - // Header - if (props.header || hasNormalizedSlot('header', $scopedSlots, $slots) || props.headerHtml) { - childNodes.push( - h( - props.headerTag, - { - class: { - [`display-${props.headerLevel}`]: props.headerLevel - } - }, - normalizeSlot('header', {}, $scopedSlots, $slots) || - props.headerHtml || - stripTags(props.header) - ) + let $header = h() + const hasHeaderSlot = hasNormalizedSlot('header', $scopedSlots, $slots) + if (hasHeaderSlot || header || headerHtml) { + const { headerLevel } = props + + $header = h( + props.headerTag, + { + class: { [`display-${headerLevel}`]: headerLevel }, + domProps: hasHeaderSlot ? {} : htmlOrText(headerHtml, header) + }, + normalizeSlot('header', slotScope, $scopedSlots, $slots) ) } - // Lead - if (props.lead || hasNormalizedSlot('lead', $scopedSlots, $slots) || props.leadHtml) { - childNodes.push( - h( - props.leadTag, - { staticClass: 'lead' }, - normalizeSlot('lead', {}, $scopedSlots, $slots) || props.leadHtml || stripTags(props.lead) - ) + let $lead = h() + const hasLeadSlot = hasNormalizedSlot('lead', $scopedSlots, $slots) + if (hasLeadSlot || lead || leadHtml) { + $lead = h( + props.leadTag, + { + staticClass: 'lead', + domProps: hasLeadSlot ? {} : htmlOrText(leadHtml, lead) + }, + normalizeSlot('lead', slotScope, $scopedSlots, $slots) ) } - // Default slot - if (hasNormalizedSlot('default', $scopedSlots, $slots)) { - childNodes.push(normalizeSlot('default', {}, $scopedSlots, $slots)) - } + let $children = [$header, $lead, normalizeSlot('default', slotScope, $scopedSlots, $slots)] - // If fluid, wrap content in a container/container-fluid + // If fluid, wrap content in a container if (props.fluid) { - // Children become a child of a container - childNodes = [h(BContainer, { props: { fluid: props.containerFluid } }, childNodes)] + $children = [h(BContainer, { props: { fluid: props.containerFluid } }, $children)] } - // Return the jumbotron + return h( props.tag, mergeData(data, { staticClass: 'jumbotron', class: { 'jumbotron-fluid': props.fluid, - [`text-${props.textVariant}`]: props.textVariant, - [`bg-${props.bgVariant}`]: props.bgVariant, - [`border-${props.borderVariant}`]: props.borderVariant, - border: props.borderVariant + [`text-${textVariant}`]: textVariant, + [`bg-${bgVariant}`]: bgVariant, + [`border-${borderVariant}`]: borderVariant, + border: borderVariant } }), - childNodes + $children ) } }) diff --git a/src/components/jumbotron/jumbotron.spec.js b/src/components/jumbotron/jumbotron.spec.js index 194d3b781b5..1165061a204 100644 --- a/src/components/jumbotron/jumbotron.spec.js +++ b/src/components/jumbotron/jumbotron.spec.js @@ -13,7 +13,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('renders with custom root element when props tag is set', async () => { + it('renders with custom root element when prop "tag" is set', async () => { const wrapper = mount(BJumbotron, { propsData: { tag: 'article' @@ -28,7 +28,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('has border when prop border-variant is set', async () => { + it('has border when prop "border-variant" is set', async () => { const wrapper = mount(BJumbotron, { propsData: { borderVariant: 'danger' @@ -44,7 +44,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('has background variant when prop bg-variant is set', async () => { + it('has background variant when prop "bg-variant" is set', async () => { const wrapper = mount(BJumbotron, { propsData: { bgVariant: 'info' @@ -59,7 +59,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('has text variant when prop text-variant is set', async () => { + it('has text variant when prop "text-variant" is set', async () => { const wrapper = mount(BJumbotron, { propsData: { textVariant: 'primary' @@ -90,7 +90,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('renders default slot content inside container when fluid prop set', async () => { + it('renders default slot content inside container when "fluid" prop set', async () => { const wrapper = mount(BJumbotron, { propsData: { fluid: true @@ -113,7 +113,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('renders default slot content inside container-fluid when fluid prop and container-fluid set', async () => { + it('renders default slot content inside ".container-fluid" when props "fluid" and "container-fluid" set', async () => { const wrapper = mount(BJumbotron, { propsData: { fluid: true, @@ -138,7 +138,7 @@ describe('jumbotron', () => { wrapper.destroy() }) - it('renders header lead and content when using props', async () => { + it('renders header and lead content by props', async () => { const wrapper = mount(BJumbotron, { propsData: { header: 'foo', @@ -153,25 +153,35 @@ describe('jumbotron', () => { expect(wrapper.classes()).toContain('jumbotron') expect(wrapper.classes().length).toBe(1) expect(wrapper.findAll('h1').length).toBe(1) - expect(wrapper.find('h1').classes()).toContain('display-3') - expect(wrapper.find('h1').classes().length).toBe(1) - expect(wrapper.find('h1').text()).toEqual('foo') expect(wrapper.findAll('p').length).toBe(1) - expect(wrapper.find('p').classes()).toContain('lead') - expect(wrapper.find('p').classes().length).toBe(1) - expect(wrapper.find('p').text()).toEqual('bar') expect(wrapper.findAll('span').length).toBe(1) - expect(wrapper.find('span').text()).toEqual('baz') expect(wrapper.find('.jumbotron > h1 + p + span').exists()).toBe(true) + const $header = wrapper.find('h1') + expect($header.classes()).toContain('display-3') + expect($header.classes().length).toBe(1) + expect($header.text()).toEqual('foo') + + const $lead = wrapper.find('p') + expect($lead.classes()).toContain('lead') + expect($lead.classes().length).toBe(1) + expect($lead.text()).toEqual('bar') + + expect(wrapper.find('span').text()).toEqual('baz') + wrapper.destroy() }) - it('renders header lead and content when using slots', async () => { + it('renders header and lead content by html props', async () => { const wrapper = mount(BJumbotron, { - slots: { + propsData: { + // We also pass non-html props to ensure html props have precedence header: 'foo', + headerHtml: 'baz', lead: 'bar', + leadHtml: 'bat' + }, + slots: { default: 'baz' } }) @@ -180,17 +190,65 @@ describe('jumbotron', () => { expect(wrapper.classes()).toContain('jumbotron') expect(wrapper.classes().length).toBe(1) expect(wrapper.findAll('h1').length).toBe(1) - expect(wrapper.find('h1').classes()).toContain('display-3') - expect(wrapper.find('h1').classes().length).toBe(1) - expect(wrapper.find('h1').text()).toEqual('foo') expect(wrapper.findAll('p').length).toBe(1) - expect(wrapper.find('p').classes()).toContain('lead') - expect(wrapper.find('p').classes().length).toBe(1) - expect(wrapper.find('p').text()).toEqual('bar') expect(wrapper.findAll('span').length).toBe(1) + expect(wrapper.find('.jumbotron > h1 + p + span').exists()).toBe(true) + + const $header = wrapper.find('h1') + expect($header.classes()).toContain('display-3') + expect($header.classes().length).toBe(1) + expect($header.find('strong').exists()).toBe(true) + expect($header.text()).toEqual('baz') + + const $lead = wrapper.find('p') + expect($lead.classes()).toContain('lead') + expect($lead.classes().length).toBe(1) + expect($lead.find('strong').exists()).toBe(true) + expect($lead.text()).toEqual('bat') + expect(wrapper.find('span').text()).toEqual('baz') + + wrapper.destroy() + }) + + it('renders header and lead content by slots', async () => { + const wrapper = mount(BJumbotron, { + propsData: { + // We also pass as props to ensure slots have precedence + header: 'foo', + headerHtml: 'baz', + lead: 'bar', + leadHtml: 'bat' + }, + slots: { + default: 'baz', + header: 'foo', + lead: 'bar' + } + }) + + expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.classes()).toContain('jumbotron') + expect(wrapper.classes().length).toBe(1) + expect(wrapper.findAll('h1').length).toBe(1) + expect(wrapper.findAll('p').length).toBe(1) + expect(wrapper.findAll('span').length).toBe(1) expect(wrapper.find('.jumbotron > h1 + p + span').exists()).toBe(true) + const $header = wrapper.find('h1') + expect($header.classes()).toContain('display-3') + expect($header.classes().length).toBe(1) + expect($header.find('small').exists()).toBe(true) + expect($header.text()).toEqual('foo') + + const $lead = wrapper.find('p') + expect($lead.classes()).toContain('lead') + expect($lead.classes().length).toBe(1) + expect($lead.find('small').exists()).toBe(true) + expect($lead.text()).toEqual('bar') + + expect(wrapper.find('span').text()).toEqual('baz') + wrapper.destroy() }) }) diff --git a/src/components/layout/col.js b/src/components/layout/col.js index c10b1de2beb..ffdc1aad1aa 100644 --- a/src/components/layout/col.js +++ b/src/components/layout/col.js @@ -1,11 +1,11 @@ import { mergeData } from 'vue-functional-data-merge' import identity from '../../utils/identity' import memoize from '../../utils/memoize' -import suffixPropName from '../../utils/suffix-prop-name' import { arrayIncludes } from '../../utils/array' import { getBreakpointsUpCached } from '../../utils/config' import { isUndefinedOrNull } from '../../utils/inspect' import { assign, create, keys } from '../../utils/object' +import { suffixPropName } from '../../utils/props' import { lowerCase } from '../../utils/string' const RX_COL_CLASS = /^col-/ diff --git a/src/components/layout/row.js b/src/components/layout/row.js index f1e027627b7..3c549e2d45c 100644 --- a/src/components/layout/row.js +++ b/src/components/layout/row.js @@ -1,10 +1,10 @@ import { mergeData } from 'vue-functional-data-merge' import identity from '../../utils/identity' import memoize from '../../utils/memoize' -import suffixPropName from '../../utils/suffix-prop-name' import { arrayIncludes, concat } from '../../utils/array' import { getBreakpointsUpCached } from '../../utils/config' import { create, keys } from '../../utils/object' +import { suffixPropName } from '../../utils/props' import { lowerCase, toString, trim } from '../../utils/string' const COMMON_ALIGNMENT = ['start', 'end', 'center'] diff --git a/src/components/link/link.js b/src/components/link/link.js index 60e5c58036a..c695258bad1 100644 --- a/src/components/link/link.js +++ b/src/components/link/link.js @@ -1,9 +1,9 @@ import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { concat } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { attemptBlur, attemptFocus } from '../../utils/dom' import { isBoolean, isEvent, isFunction, isUndefined } from '../../utils/inspect' +import { pluckProps } from '../../utils/props' import { computeHref, computeRel, computeTag, isRouterLink } from '../../utils/router' import attrsMixin from '../../mixins/attrs' import listenersMixin from '../../mixins/listeners' diff --git a/src/components/list-group/list-group-item.js b/src/components/list-group/list-group-item.js index dcfdb6fcae5..c6ed6ce9537 100644 --- a/src/components/list-group/list-group-item.js +++ b/src/components/list-group/list-group-item.js @@ -1,10 +1,10 @@ import { mergeData } from 'vue-functional-data-merge' import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { arrayIncludes } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { isTag } from '../../utils/dom' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { isLink } from '../../utils/router' import { BLink, props as BLinkProps } from '../link/link' diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index 36ce83a9e2b..f233d937154 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -16,7 +16,7 @@ import { } from '../../utils/dom' import { isBrowser } from '../../utils/env' import { EVENT_OPTIONS_NO_CAPTURE, eventOn, eventOff } from '../../utils/events' -import { stripTags } from '../../utils/html' +import { htmlOrText } from '../../utils/html' import { isString, isUndefinedOrNull } from '../../utils/inspect' import { HTMLElement } from '../../utils/safe-types' import { BTransporterSingle } from '../../utils/transporter' @@ -863,190 +863,191 @@ export const BModal = /*#__PURE__*/ Vue.extend({ }, makeModal(h) { // Modal header - let header = h() + let $header = h() if (!this.hideHeader) { // TODO: Rename slot to `header` and deprecate `modal-header` - let modalHeader = this.normalizeSlot('modal-header', this.slotScope) - if (!modalHeader) { - let closeButton = h() + let $modalHeader = this.normalizeSlot('modal-header', this.slotScope) + if (!$modalHeader) { + let $closeButton = h() if (!this.hideHeaderClose) { - closeButton = h( + $closeButton = h( BButtonClose, { - ref: 'close-button', props: { content: this.headerCloseContent, disabled: this.isTransitioning, ariaLabel: this.headerCloseLabel, textVariant: this.headerCloseVariant || this.headerTextVariant }, - on: { click: this.onClose } + on: { click: this.onClose }, + ref: 'close-button' }, // TODO: Rename slot to `header-close` and deprecate `modal-header-close` [this.normalizeSlot('modal-header-close')] ) } - const domProps = - // TODO: Rename slot to `title` and deprecate `modal-title` - !this.hasNormalizedSlot('modal-title') && this.titleHtml - ? { innerHTML: this.titleHtml } - : {} - modalHeader = [ + + $modalHeader = [ h( this.titleTag, { staticClass: 'modal-title', class: this.titleClasses, attrs: { id: this.modalTitleId }, - domProps + // TODO: Rename slot to `title` and deprecate `modal-title` + domProps: this.hasNormalizedSlot('modal-title') + ? {} + : htmlOrText(this.titleHtml, this.title) }, // TODO: Rename slot to `title` and deprecate `modal-title` - [this.normalizeSlot('modal-title', this.slotScope) || stripTags(this.title)] + [this.normalizeSlot('modal-title', this.slotScope)] ), - closeButton + $closeButton ] } - header = h( + + $header = h( 'header', { - ref: 'header', staticClass: 'modal-header', class: this.headerClasses, - attrs: { id: this.modalHeaderId } + attrs: { id: this.modalHeaderId }, + ref: 'header' }, - [modalHeader] + [$modalHeader] ) } // Modal body - const body = h( + const $body = h( 'div', { - ref: 'body', staticClass: 'modal-body', class: this.bodyClasses, - attrs: { id: this.modalBodyId } + attrs: { id: this.modalBodyId }, + ref: 'body' }, this.normalizeSlot('default', this.slotScope) ) // Modal footer - let footer = h() + let $footer = h() if (!this.hideFooter) { // TODO: Rename slot to `footer` and deprecate `modal-footer` - let modalFooter = this.normalizeSlot('modal-footer', this.slotScope) - if (!modalFooter) { - let cancelButton = h() + let $modalFooter = this.normalizeSlot('modal-footer', this.slotScope) + if (!$modalFooter) { + let $cancelButton = h() if (!this.okOnly) { - const cancelHtml = this.cancelTitleHtml ? { innerHTML: this.cancelTitleHtml } : null - cancelButton = h( + $cancelButton = h( BButton, { - ref: 'cancel-button', props: { variant: this.cancelVariant, size: this.buttonSize, disabled: this.cancelDisabled || this.busy || this.isTransitioning }, - on: { click: this.onCancel } - }, - [ // TODO: Rename slot to `cancel-button` and deprecate `modal-cancel` - this.normalizeSlot('modal-cancel') || - (cancelHtml ? h('span', { domProps: cancelHtml }) : stripTags(this.cancelTitle)) - ] + domProps: this.hasNormalizedSlot('modal-cancel') + ? {} + : htmlOrText(this.cancelTitleHtml, this.cancelTitle), + on: { click: this.onCancel }, + ref: 'cancel-button' + }, + // TODO: Rename slot to `cancel-button` and deprecate `modal-cancel` + this.normalizeSlot('modal-cancel') ) } - const okHtml = this.okTitleHtml ? { innerHTML: this.okTitleHtml } : null - const okButton = h( + + const $okButton = h( BButton, { - ref: 'ok-button', props: { variant: this.okVariant, size: this.buttonSize, disabled: this.okDisabled || this.busy || this.isTransitioning }, - on: { click: this.onOk } - }, - [ // TODO: Rename slot to `ok-button` and deprecate `modal-ok` - this.normalizeSlot('modal-ok') || - (okHtml ? h('span', { domProps: okHtml }) : stripTags(this.okTitle)) - ] + domProps: this.hasNormalizedSlot('modal-ok') + ? {} + : htmlOrText(this.okTitleHtml, this.okTitle), + on: { click: this.onOk }, + ref: 'ok-button' + }, + // TODO: Rename slot to `ok-button` and deprecate `modal-ok` + this.normalizeSlot('modal-ok') ) - modalFooter = [cancelButton, okButton] + + $modalFooter = [$cancelButton, $okButton] } - footer = h( + + $footer = h( 'footer', { - ref: 'footer', staticClass: 'modal-footer', class: this.footerClasses, - attrs: { id: this.modalFooterId } + attrs: { id: this.modalFooterId }, + ref: 'footer' }, - [modalFooter] + [$modalFooter] ) } // Assemble modal content - const modalContent = h( + const $modalContent = h( 'div', { - ref: 'content', staticClass: 'modal-content', class: this.contentClass, attrs: { id: this.modalContentId, tabindex: '-1' - } + }, + ref: 'content' }, - [header, body, footer] + [$header, $body, $footer] ) - // Tab trap to prevent page from scrolling to next element in - // tab index during enforce focus tab cycle - let tabTrapTop = h() - let tabTrapBottom = h() + // Tab traps to prevent page from scrolling to next element in + // tab index during enforce-focus tab cycle + let $tabTrapTop = h() + let $tabTrapBottom = h() if (this.isVisible && !this.noEnforceFocus) { - tabTrapTop = h('span', { ref: 'topTrap', attrs: { tabindex: '0' } }) - tabTrapBottom = h('span', { ref: 'bottomTrap', attrs: { tabindex: '0' } }) + $tabTrapTop = h('span', { ref: 'topTrap', attrs: { tabindex: '0' } }) + $tabTrapBottom = h('span', { ref: 'bottomTrap', attrs: { tabindex: '0' } }) } // Modal dialog wrapper - const modalDialog = h( + const $modalDialog = h( 'div', { - ref: 'dialog', staticClass: 'modal-dialog', class: this.dialogClasses, - on: { mousedown: this.onDialogMousedown } + on: { mousedown: this.onDialogMousedown }, + ref: 'dialog' }, - [tabTrapTop, modalContent, tabTrapBottom] + [$tabTrapTop, $modalContent, $tabTrapBottom] ) // Modal - let modal = h( + let $modal = h( 'div', { - ref: 'modal', staticClass: 'modal', class: this.modalClasses, style: this.modalStyles, - directives: [ - { name: 'show', rawName: 'v-show', value: this.isVisible, expression: 'isVisible' } - ], attrs: this.computedModalAttrs, - on: { keydown: this.onEsc, click: this.onClickOut } + on: { keydown: this.onEsc, click: this.onClickOut }, + directives: [{ name: 'show', value: this.isVisible }], + ref: 'modal' }, - [modalDialog] + [$modalDialog] ) // Wrap modal in transition - // Sadly, we can't use BVTransition here due to the differences in - // transition durations for .modal and .modal-dialog. Not until - // issue https://github.com/vuejs/vue/issues/9986 is resolved - modal = h( + // Sadly, we can't use `BVTransition` here due to the differences in + // transition durations for `.modal` and `.modal-dialog` + // At least until https://github.com/vuejs/vue/issues/9986 is resolved + $modal = h( 'transition', { props: { @@ -1066,30 +1067,33 @@ export const BModal = /*#__PURE__*/ Vue.extend({ afterLeave: this.onAfterLeave } }, - [modal] + [$modal] ) // Modal backdrop - let backdrop = h() + let $backdrop = h() if (!this.hideBackdrop && this.isVisible) { - backdrop = h( + $backdrop = h( 'div', - { staticClass: 'modal-backdrop', attrs: { id: this.modalBackdropId } }, + { + staticClass: 'modal-backdrop', + attrs: { id: this.modalBackdropId } + }, // TODO: Rename slot to `backdrop` and deprecate `modal-backdrop` - [this.normalizeSlot('modal-backdrop')] + this.normalizeSlot('modal-backdrop') ) } - backdrop = h(BVTransition, { props: { noFade: this.noFade } }, [backdrop]) + $backdrop = h(BVTransition, { props: { noFade: this.noFade } }, [$backdrop]) // Assemble modal and backdrop in an outer
return h( 'div', { - key: `modal-outer-${this._uid}`, style: this.modalOuterStyle, - attrs: this.computedAttrs + attrs: this.computedAttrs, + key: `modal-outer-${this._uid}` }, - [modal, backdrop] + [$modal, $backdrop] ) } }, diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js index 1a3019d7cee..153cfe15cc0 100644 --- a/src/components/modal/modal.spec.js +++ b/src/components/modal/modal.spec.js @@ -335,14 +335,47 @@ describe('modal', () => { expect($cancel.attributes('type')).toBe('button') expect($cancel.text()).toContain('cancel') // `v-html` is applied to a span - expect($cancel.html()).toContain('cancel') + expect($cancel.html()).toContain('cancel') // OK button (right-most button) const $ok = $buttons.at(1) expect($ok.attributes('type')).toBe('button') expect($ok.text()).toContain('ok') // `v-html` is applied to a span - expect($ok.html()).toContain('ok') + expect($ok.html()).toContain('ok') + + wrapper.destroy() + }) + + it('modal-ok and modal-cancel button content slots works', async () => { + const wrapper = mount(BModal, { + attachTo: createContainer(), + propsData: { + static: true + }, + slots: { + 'modal-ok': 'bar ok', + 'modal-cancel': 'foo cancel' + } + }) + expect(wrapper).toBeDefined() + + const $buttons = wrapper.findAll('footer button') + expect($buttons.length).toBe(2) + + // Cancel button (left-most button) + const $cancel = $buttons.at(0) + expect($cancel.attributes('type')).toBe('button') + expect($cancel.text()).toContain('foo cancel') + // `v-html` is applied to a span + expect($cancel.html()).toContain('foo cancel') + + // OK button (right-most button) + const $ok = $buttons.at(1) + expect($ok.attributes('type')).toBe('button') + expect($ok.text()).toContain('bar ok') + // `v-html` is applied to a span + expect($ok.html()).toContain('bar ok') wrapper.destroy() }) diff --git a/src/components/nav/nav-item-dropdown.js b/src/components/nav/nav-item-dropdown.js index 199d552951b..3facb61e9d2 100644 --- a/src/components/nav/nav-item-dropdown.js +++ b/src/components/nav/nav-item-dropdown.js @@ -1,6 +1,6 @@ import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { htmlOrText } from '../../utils/html' +import { pluckProps } from '../../utils/props' import dropdownMixin from '../../mixins/dropdown' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' diff --git a/src/components/navbar/navbar-brand.js b/src/components/navbar/navbar-brand.js index 835b8801192..f4bb1c71a1a 100644 --- a/src/components/navbar/navbar-brand.js +++ b/src/components/navbar/navbar-brand.js @@ -1,7 +1,7 @@ import { mergeData } from 'vue-functional-data-merge' import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { BLink, props as BLinkProps } from '../link/link' // --- Props --- diff --git a/src/components/navbar/navbar-nav.js b/src/components/navbar/navbar-nav.js index ad325d74b52..7a9e4fe89db 100644 --- a/src/components/navbar/navbar-nav.js +++ b/src/components/navbar/navbar-nav.js @@ -1,6 +1,6 @@ -import Vue from '../../utils/vue' import { mergeData } from 'vue-functional-data-merge' -import pluckProps from '../../utils/pluck-props' +import Vue from '../../utils/vue' +import { pluckProps } from '../../utils/props' import { props as BNavProps } from '../nav/nav' // -- Constants -- diff --git a/src/components/pagination-nav/pagination-nav.js b/src/components/pagination-nav/pagination-nav.js index fbcccf45c87..880831426c9 100644 --- a/src/components/pagination-nav/pagination-nav.js +++ b/src/components/pagination-nav/pagination-nav.js @@ -1,6 +1,5 @@ import Vue from '../../utils/vue' import looseEqual from '../../utils/loose-equal' -import pluckProps from '../../utils/pluck-props' import { getComponentConfig } from '../../utils/config' import { attemptBlur, requestAF } from '../../utils/dom' import { isBrowser } from '../../utils/env' @@ -8,6 +7,7 @@ import { isArray, isUndefined, isFunction, isObject } from '../../utils/inspect' import { mathMax } from '../../utils/math' import { toInteger } from '../../utils/number' import { omit } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { computeHref, parseQuery } from '../../utils/router' import { toString } from '../../utils/string' import { warn } from '../../utils/warn' diff --git a/src/components/progress/progress-bar.js b/src/components/progress/progress-bar.js index 8d853b5d585..c49cad1ce10 100644 --- a/src/components/progress/progress-bar.js +++ b/src/components/progress/progress-bar.js @@ -7,8 +7,11 @@ import { toFixed, toFloat, toInteger } from '../../utils/number' import { toString } from '../../utils/string' import normalizeSlotMixin from '../../mixins/normalize-slot' +// --- Constants --- + const NAME = 'BProgressBar' +// --- Main component --- // @vue/component export const BProgressBar = /*#__PURE__*/ Vue.extend({ name: NAME, @@ -119,16 +122,20 @@ export const BProgressBar = /*#__PURE__*/ Vue.extend({ } }, render(h) { - let childNodes = h() + const { label, labelHtml, computedValue, computedPrecision } = this + + let $content = h() + let domProps = {} if (this.hasNormalizedSlot('default')) { - childNodes = this.normalizeSlot('default') - } else if (this.label || this.labelHtml) { - childNodes = h('span', { domProps: htmlOrText(this.labelHtml, this.label) }) + $content = this.normalizeSlot('default') + } else if (label || labelHtml) { + domProps = htmlOrText(labelHtml, label) } else if (this.computedShowProgress) { - childNodes = this.computedProgress + $content = this.computedProgress } else if (this.computedShowValue) { - childNodes = toFixed(this.computedValue, this.computedPrecision) + $content = toFixed(computedValue, computedPrecision) } + return h( 'div', { @@ -139,10 +146,11 @@ export const BProgressBar = /*#__PURE__*/ Vue.extend({ role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': toString(this.computedMax), - 'aria-valuenow': toFixed(this.computedValue, this.computedPrecision) - } + 'aria-valuenow': toFixed(computedValue, computedPrecision) + }, + domProps }, - [childNodes] + [$content] ) } }) diff --git a/src/components/table/helpers/mixin-caption.js b/src/components/table/helpers/mixin-caption.js index 33e98e49c28..4c02b4f63fa 100644 --- a/src/components/table/helpers/mixin-caption.js +++ b/src/components/table/helpers/mixin-caption.js @@ -2,7 +2,7 @@ import { htmlOrText } from '../../../utils/html' export default { props: { - // `caption-top` is part of table-redere mixin (styling) + // `caption-top` is part of table-render mixin (styling) // captionTop: { // type: Boolean, // default: false @@ -24,21 +24,21 @@ export default { }, methods: { renderCaption() { + const { caption, captionHtml } = this const h = this.$createElement - // Build the caption - const $captionSlot = this.normalizeSlot('table-caption') let $caption = h() - - if ($captionSlot || this.caption || this.captionHtml) { - const data = { - key: 'caption', - attrs: { id: this.captionId } - } - if (!$captionSlot) { - data.domProps = htmlOrText(this.captionHtml, this.caption) - } - $caption = h('caption', data, [$captionSlot]) + const hasCaptionSlot = this.hasNormalizedSlot('table-caption') + if (hasCaptionSlot || caption || captionHtml) { + $caption = h( + 'caption', + { + key: 'caption', + attrs: { id: this.captionId }, + domProps: hasCaptionSlot ? {} : htmlOrText(captionHtml, caption) + }, + this.normalizeSlot('table-caption') + ) } return $caption diff --git a/src/components/table/helpers/mixin-empty.js b/src/components/table/helpers/mixin-empty.js index a1d984838a5..1022b16b7ea 100644 --- a/src/components/table/helpers/mixin-empty.js +++ b/src/components/table/helpers/mixin-empty.js @@ -28,52 +28,66 @@ export default { renderEmpty() { const h = this.$createElement const items = this.computedItems - let $empty + let $empty = h() if ( this.showEmpty && (!items || items.length === 0) && !(this.computedBusy && this.hasNormalizedSlot('table-busy')) ) { + const { + isFiltered, + emptyText, + emptyHtml, + emptyFilteredText, + emptyFilteredHtml, + computedFields, + tbodyTrClass, + tbodyTrAttr + } = this + $empty = this.normalizeSlot(this.isFiltered ? 'emptyfiltered' : 'empty', { - emptyFilteredHtml: this.emptyFilteredHtml, - emptyFilteredText: this.emptyFilteredText, - emptyHtml: this.emptyHtml, - emptyText: this.emptyText, - fields: this.computedFields, + emptyFilteredHtml, + emptyFilteredText, + emptyHtml, + emptyText, + fields: computedFields, // Not sure why this is included, as it will always be an empty array items: this.computedItems }) + if (!$empty) { $empty = h('div', { class: ['text-center', 'my-2'], - domProps: this.isFiltered - ? htmlOrText(this.emptyFilteredHtml, this.emptyFilteredText) - : htmlOrText(this.emptyHtml, this.emptyText) + domProps: isFiltered + ? htmlOrText(emptyFilteredHtml, emptyFilteredText) + : htmlOrText(emptyHtml, emptyText) }) } - $empty = h(BTd, { props: { colspan: this.computedFields.length || null } }, [ + + $empty = h(BTd, { props: { colspan: computedFields.length || null } }, [ h('div', { attrs: { role: 'alert', 'aria-live': 'polite' } }, [$empty]) ]) + $empty = h( BTr, { - key: this.isFiltered ? 'b-empty-filtered-row' : 'b-empty-row', staticClass: 'b-table-empty-row', class: [ - isFunction(this.tbodyTrClass) + isFunction(tbodyTrClass) ? /* istanbul ignore next */ this.tbodyTrClass(null, 'row-empty') - : this.tbodyTrClass + : tbodyTrClass ], - attrs: isFunction(this.tbodyTrAttr) + attrs: isFunction(tbodyTrAttr) ? /* istanbul ignore next */ this.tbodyTrAttr(null, 'row-empty') - : this.tbodyTrAttr + : tbodyTrAttr, + key: isFiltered ? 'b-empty-filtered-row' : 'b-empty-row' }, [$empty] ) } - return $empty || h() + return $empty } } } diff --git a/src/components/table/helpers/mixin-thead.js b/src/components/table/helpers/mixin-thead.js index 17848abb801..527f3f97271 100644 --- a/src/components/table/helpers/mixin-thead.js +++ b/src/components/table/helpers/mixin-thead.js @@ -1,5 +1,6 @@ import identity from '../../../utils/identity' import KeyCodes from '../../../utils/key-codes' +import noop from '../../../utils/noop' import startCase from '../../../utils/startcase' import { getComponentConfig } from '../../../utils/config' import { htmlOrText } from '../../../utils/html' @@ -56,18 +57,30 @@ export default { const h = this.$createElement const fields = this.computedFields || [] + // In always stacked mode, we don't bother rendering the head/foot + // Or if no field headings (empty table) if (this.isStackedAlways || fields.length === 0) { - // In always stacked mode, we don't bother rendering the head/foot - // Or if no field headings (empty table) return h() } + const { + isSortable, + isSelectable, + headVariant, + footVariant, + headRowVariant, + footRowVariant + } = this + const hasHeadClickListener = isSortable || this.hasListener('head-clicked') + // Reference to `selectAllRows` and `clearSelected()`, if table is selectable - const selectAllRows = this.isSelectable ? this.selectAllRows : () => {} - const clearSelected = this.isSelectable ? this.clearSelected : () => {} + const selectAllRows = isSelectable ? this.selectAllRows : noop + const clearSelected = isSelectable ? this.clearSelected : noop // Helper function to generate a field cell const makeCell = (field, colIndex) => { + const { label, labelHtml, variant, stickyColumn, key } = field + let ariaLabel = null if (!field.label.trim() && !field.headerTitle) { // In case field's label and title are empty/blank @@ -75,29 +88,27 @@ export default { /* istanbul ignore next */ ariaLabel = startCase(field.key) } - const hasHeadClickListener = this.hasListener('head-clicked') || this.isSortable - const handlers = {} + + const on = {} if (hasHeadClickListener) { - handlers.click = evt => { + on.click = evt => { this.headClicked(evt, field, isFoot) } - handlers.keydown = evt => { + on.keydown = evt => { const keyCode = evt.keyCode if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) { this.headClicked(evt, field, isFoot) } } } - const sortAttrs = this.isSortable ? this.sortTheadThAttrs(field.key, field, isFoot) : {} - const sortClass = this.isSortable ? this.sortTheadThClasses(field.key, field, isFoot) : null - const sortLabel = this.isSortable ? this.sortTheadThLabel(field.key, field, isFoot) : null + + const sortAttrs = isSortable ? this.sortTheadThAttrs(key, field, isFoot) : {} + const sortClass = isSortable ? this.sortTheadThClasses(key, field, isFoot) : null + const sortLabel = isSortable ? this.sortTheadThLabel(key, field, isFoot) : null + const data = { - key: field.key, class: [this.fieldClasses(field), sortClass], - props: { - variant: field.variant, - stickyColumn: field.stickyColumn - }, + props: { variant, stickyColumn }, style: field.thStyle || {}, attrs: { // We only add a tabindex of 0 if there is a head-clicked listener @@ -106,41 +117,42 @@ export default { title: field.headerTitle || null, 'aria-colindex': colIndex + 1, 'aria-label': ariaLabel, - ...this.getThValues(null, field.key, field.thAttr, isFoot ? 'foot' : 'head', {}), + ...this.getThValues(null, key, field.thAttr, isFoot ? 'foot' : 'head', {}), ...sortAttrs }, - on: handlers + on, + key } + // Handle edge case where in-document templates are used with new // `v-slot:name` syntax where the browser lower-cases the v-slot's // name (attributes become lower cased when parsed by the browser) // We have replaced the square bracket syntax with round brackets // to prevent confusion with dynamic slot names - let slotNames = [`head(${field.key})`, `head(${field.key.toLowerCase()})`, 'head()'] + let slotNames = [`head(${key})`, `head(${key.toLowerCase()})`, 'head()'] + // Footer will fallback to header slot names if (isFoot) { - // Footer will fallback to header slot names - slotNames = [ - `foot(${field.key})`, - `foot(${field.key.toLowerCase()})`, - 'foot()', - ...slotNames - ] + slotNames = [`foot(${key})`, `foot(${key.toLowerCase()})`, 'foot()', ...slotNames] } + const scope = { - label: field.label, - column: field.key, + label, + column: key, field, isFoot, // Add in row select methods selectAllRows, clearSelected } - const content = + + const $content = this.normalizeSlot(slotNames, scope) || - (field.labelHtml ? h('div', { domProps: htmlOrText(field.labelHtml) }) : field.label) - const srLabel = sortLabel ? h('span', { staticClass: 'sr-only' }, ` (${sortLabel})`) : null + h('div', { domProps: htmlOrText(labelHtml, label) }) + + const $srLabel = sortLabel ? h('span', { staticClass: 'sr-only' }, ` (${sortLabel})`) : null + // Return the header cell - return h(BTh, data, [content, srLabel].filter(identity)) + return h(BTh, data, [$content, $srLabel].filter(identity)) } // Generate the array of cells @@ -149,23 +161,39 @@ export default { // Generate the row(s) const $trs = [] if (isFoot) { - const trProps = { - variant: isUndefinedOrNull(this.footRowVariant) - ? this.headRowVariant - : /* istanbul ignore next */ this.footRowVariant - } - $trs.push(h(BTr, { class: this.tfootTrClass, props: trProps }, $cells)) + $trs.push( + h( + BTr, + { + class: this.tfootTrClass, + props: { + variant: isUndefinedOrNull(footRowVariant) + ? headRowVariant + : /* istanbul ignore next */ footRowVariant + } + }, + $cells + ) + ) } else { const scope = { columns: fields.length, - fields: fields, + fields, // Add in row select methods selectAllRows, clearSelected } $trs.push(this.normalizeSlot('thead-top', scope) || h()) + $trs.push( - h(BTr, { class: this.theadTrClass, props: { variant: this.headRowVariant } }, $cells) + h( + BTr, + { + class: this.theadTrClass, + props: { variant: headRowVariant } + }, + $cells + ) ) } @@ -175,8 +203,8 @@ export default { key: isFoot ? 'bv-tfoot' : 'bv-thead', class: (isFoot ? this.tfootClass : this.theadClass) || null, props: isFoot - ? { footVariant: this.footVariant || this.headVariant || null } - : { headVariant: this.headVariant || null } + ? { footVariant: footVariant || headVariant || null } + : { headVariant: headVariant || null } }, $trs ) diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 3f62de144e5..a028e71e870 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -1,7 +1,6 @@ import { Portal, Wormhole } from 'portal-vue' import BVTransition from '../../utils/bv-transition' import Vue from '../../utils/vue' -import pluckProps from '../../utils/pluck-props' import { BvEvent } from '../../utils/bv-event.class' import { getComponentConfig } from '../../utils/config' import { requestAF } from '../../utils/dom' @@ -9,6 +8,7 @@ import { EVENT_OPTIONS_NO_CAPTURE, eventOnOff } from '../../utils/events' import { mathMax } from '../../utils/math' import { toInteger } from '../../utils/number' import { pick } from '../../utils/object' +import { pluckProps } from '../../utils/props' import { isLink } from '../../utils/router' import attrsMixin from '../../mixins/attrs' import idMixin from '../../mixins/id' diff --git a/src/mixins/form-radio-check-group.js b/src/mixins/form-radio-check-group.js index 4d79d68e171..3c7991bebee 100644 --- a/src/mixins/form-radio-check-group.js +++ b/src/mixins/form-radio-check-group.js @@ -75,14 +75,14 @@ export default { } }, render(h) { - const inputs = this.formOptions.map((option, idx) => { - const uid = `_BV_option_${idx}_` + const $inputs = this.formOptions.map((option, index) => { + const key = `BV_option_${index}` + return h( this.isRadioGroup ? BFormRadio : BFormCheckbox, { - key: uid, props: { - id: this.safeId(uid), + id: this.safeId(key), value: option.value, // Individual radios or checks can be disabled in a group disabled: option.disabled || false @@ -90,11 +90,13 @@ export default { // name: this.groupName, // form: this.form || null, // required: Boolean(this.name && this.required) - } + }, + key }, [h('span', { domProps: htmlOrText(option.html, option.text) })] ) }) + return h( 'div', { @@ -102,14 +104,13 @@ export default { attrs: { id: this.safeId(), role: this.isRadioGroup ? 'radiogroup' : 'group', - // Tabindex to allow group to be focused - // if needed by screen readers + // Add `tabindex="-1"` to allow group to be focused if needed by screen readers tabindex: '-1', 'aria-required': this.required ? 'true' : null, 'aria-invalid': this.computedAriaInvalid } }, - [this.normalizeSlot('first'), inputs, this.normalizeSlot('default')] + [this.normalizeSlot('first'), $inputs, this.normalizeSlot('default')] ) } } diff --git a/src/utils/copy-props.js b/src/utils/copy-props.js deleted file mode 100644 index 31768e7e550..00000000000 --- a/src/utils/copy-props.js +++ /dev/null @@ -1,33 +0,0 @@ -import identity from './identity' -import { isArray, isObject } from './inspect' -import { clone } from './object' - -/** - * Copies props from one array/object to a new array/object. Prop values - * are also cloned as new references to prevent possible mutation of original - * prop object values. Optionally accepts a function to transform the prop name. - * - * @param {[]|{}} props - * @param {Function} transformFn - */ -const copyProps = (props, transformFn = identity) => { - if (isArray(props)) { - return props.map(transformFn) - } - // Props as an object. - const copied = {} - - for (const prop in props) { - /* istanbul ignore else */ - // eslint-disable-next-line no-prototype-builtins - if (props.hasOwnProperty(prop)) { - // If the prop value is an object, do a shallow clone to prevent - // potential mutations to the original object. - copied[transformFn(prop)] = isObject(props[prop]) ? clone(props[prop]) : props[prop] - } - } - - return copied -} - -export default copyProps diff --git a/src/utils/html.js b/src/utils/html.js index ea5d2181659..4bd325a0a59 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -1,9 +1,9 @@ -const stripTagsRegex = /(<([^>]+)>)/gi +const RX_HTML_TAGS = /(<([^>]+)>)/gi -// Removes any thing that looks like an HTML tag from the supplied string -export const stripTags = (text = '') => String(text).replace(stripTagsRegex, '') +// Removes anything that looks like an HTML tag from the supplied string +export const stripTags = (text = '') => String(text).replace(RX_HTML_TAGS, '') -// Generate a domProps object for either innerHTML, textContent or nothing +// Generate a `domProps` object for either `innerHTML`, `textContent` or an empty object export const htmlOrText = (innerHTML, textContent) => { return innerHTML ? { innerHTML } : textContent ? { textContent } : {} } diff --git a/src/utils/pluck-props.js b/src/utils/pluck-props.js deleted file mode 100644 index f20260397fb..00000000000 --- a/src/utils/pluck-props.js +++ /dev/null @@ -1,21 +0,0 @@ -import identity from './identity' -import { isArray } from './inspect' -import { keys } from './object' - -/** - * Given an array of properties or an object of property keys, - * plucks all the values off the target object, returning a new object - * that has props that reference the original prop values - * - * @param {{}|string[]} keysToPluck - * @param {{}} objToPluck - * @param {Function} transformFn - * @return {{}} - */ -const pluckProps = (keysToPluck, objToPluck, transformFn = identity) => - (isArray(keysToPluck) ? keysToPluck.slice() : keys(keysToPluck)).reduce((memo, prop) => { - memo[transformFn(prop)] = objToPluck[prop] - return memo - }, {}) - -export default pluckProps diff --git a/src/utils/prefix-prop-name.js b/src/utils/prefix-prop-name.js deleted file mode 100644 index 3dcb1c5e2b6..00000000000 --- a/src/utils/prefix-prop-name.js +++ /dev/null @@ -1,9 +0,0 @@ -import { upperFirst } from './string' - -/** - * @param {string} prefix - * @param {string} value - */ -const prefixPropName = (prefix, value) => prefix + upperFirst(value) - -export default prefixPropName diff --git a/src/utils/props.js b/src/utils/props.js new file mode 100644 index 00000000000..09bd3ee05c5 --- /dev/null +++ b/src/utils/props.js @@ -0,0 +1,44 @@ +import identity from './identity' +import { isArray, isObject } from './inspect' +import { clone, hasOwnProperty, keys } from './object' +import { lowerFirst, upperFirst } from './string' + +// Prefix a property +export const prefixPropName = (prefix, value) => prefix + upperFirst(value) + +// Remove a prefix from a property +export const unprefixPropName = (prefix, value) => lowerFirst(value.replace(prefix, '')) + +// Suffix can be a falsey value so nothing is appended to string +// (helps when looping over props & some shouldn't change) +// Use data last parameters to allow for currying +export const suffixPropName = (suffix, str) => str + (suffix ? upperFirst(suffix) : '') + +// Copies props from one array/object to a new array/object +// Prop values are also cloned as new references to prevent possible +// mutation of original prop object values +// Optionally accepts a function to transform the prop name +export const copyProps = (props, transformFn = identity) => { + if (isArray(props)) { + return props.map(transformFn) + } + const copied = {} + for (const prop in props) { + /* istanbul ignore else */ + if (hasOwnProperty(props, prop)) { + // If the prop value is an object, do a shallow clone + // to prevent potential mutations to the original object + copied[transformFn(prop)] = isObject(props[prop]) ? clone(props[prop]) : props[prop] + } + } + return copied +} + +// Given an array of properties or an object of property keys, +// plucks all the values off the target object, returning a new object +// that has props that reference the original prop values +export const pluckProps = (keysToPluck, objToPluck, transformFn = identity) => + (isArray(keysToPluck) ? keysToPluck.slice() : keys(keysToPluck)).reduce((memo, prop) => { + memo[transformFn(prop)] = objToPluck[prop] + return memo + }, {}) diff --git a/src/utils/copy-props.spec.js b/src/utils/props.spec.js similarity index 81% rename from src/utils/copy-props.spec.js rename to src/utils/props.spec.js index a9695f5433c..30d13ff6bcd 100644 --- a/src/utils/copy-props.spec.js +++ b/src/utils/props.spec.js @@ -1,7 +1,7 @@ -import copyProps from './copy-props' +import { copyProps } from './props' -describe('utils/copyProps', () => { - it('works with array props', async () => { +describe('utils/props', () => { + it('copyProps() works with array props', async () => { const props = ['a', 'b', 'c'] expect(copyProps(props)).toEqual(props) @@ -9,7 +9,7 @@ describe('utils/copyProps', () => { expect(copyProps(props)).not.toBe(props) }) - it('works with object props', async () => { + it('copyProps() works with object props', async () => { const props = { a: { type: String, default: 'foobar' }, b: { type: [Object, Array], default: null }, diff --git a/src/utils/suffix-prop-name.js b/src/utils/suffix-prop-name.js deleted file mode 100644 index 0bd1ec96b43..00000000000 --- a/src/utils/suffix-prop-name.js +++ /dev/null @@ -1,12 +0,0 @@ -import { upperFirst } from './string' - -/** - * Suffix can be a falsey value so nothing is appended to string. - * (helps when looping over props & some shouldn't change) - * Use data last parameters to allow for currying. - * @param {string} suffix - * @param {string} str - */ -const suffixPropName = (suffix, str) => str + (suffix ? upperFirst(suffix) : '') - -export default suffixPropName diff --git a/src/utils/unprefix-prop-name.js b/src/utils/unprefix-prop-name.js deleted file mode 100644 index df4b15b9f68..00000000000 --- a/src/utils/unprefix-prop-name.js +++ /dev/null @@ -1,9 +0,0 @@ -import { lowerFirst } from './string' - -/** - * @param {string} prefix - * @param {string} value - */ -const unprefixPropName = (prefix, value) => lowerFirst(value.replace(prefix, '')) - -export default unprefixPropName