From dd3e956dc2264b9639116d1e3f27f1c1660d18ff Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:28:48 -0300 Subject: [PATCH 01/17] feat(b-avatar): if img `src` fails to load, show icon or text or fallback icon --- src/components/avatar/avatar.js | 68 +++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index 0724fa1347c..fc836da0b4d 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -1,4 +1,3 @@ -import { mergeData } from 'vue-functional-data-merge' import Vue from '../../utils/vue' import pluckProps from '../../utils/pluck-props' import { getComponentConfig } from '../../utils/config' @@ -8,6 +7,7 @@ import { BButton } from '../button/button' import { BLink } from '../link/link' import { BIcon } from '../../icons/icon' import { BIconPersonFill } from '../../icons/icons' +import normalizeSlotMixin from '../../mixins/normalize-slot' // --- Constants --- const NAME = 'BAvatar' @@ -135,30 +135,66 @@ const computeSize = value => { // @vue/component export const BAvatar = /*#__PURE__*/ Vue.extend({ name: NAME, - functional: true, + mixins: [normalizeSlotMixin], props, - render(h, { props, data, children }) { - const { variant, disabled, square, icon, src, text, button: isButton, buttonType: type } = props - const isBLink = !isButton && (props.href || props.to) + data() { + return { + localSrc: this.src || null + } + }, + computed: { + computedSize() { + return computeSize(this.size) + }, + fontSize() { + const size = this.computedSize + return size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null + } + }, + watch: { + src(newSrc, oldSrc) { + if (newSrc !== oldSrc) { + this.localSrc = newSrc || null + } + } + }, + methods: { + onImgError() /* istanbul ignore next: hard to fake an image error */ { + this.localSrc = null + this.$emit('img-error') + } + }, + render(h) { + const { + variant, + disabled, + square, + icon, + localSrc: src, + text, + fontSize, + computedSize: size, + button: isButton, + buttonType: type + } = this + const isBLink = !isButton && (this.href || this.to) const tag = isButton ? BButton : isBLink ? BLink : 'span' - const rounded = square ? false : props.rounded === '' ? true : props.rounded || 'circle' - const size = computeSize(props.size) - const alt = props.alt || null - const ariaLabel = props.ariaLabel || null + const rounded = square ? false : this.rounded === '' ? true : this.rounded || 'circle' + const alt = this.alt || null + const ariaLabel = this.ariaLabel || null let $content = null - if (children) { + if (this.hasNormalizedSlot('default')) { // Default slot overrides props - $content = children + $content = this.normalizeSlot('default') + } else if (src) { + $content = h('img', { attrs: { src, alt }, on: { error: this.onImgError } }) } else if (icon) { $content = h(BIcon, { props: { icon }, attrs: { 'aria-hidden': 'true', alt } }) - } else if (src) { - $content = h('img', { attrs: { src, alt } }) } else if (text) { - const fontSize = size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null $content = h('span', { style: { fontSize } }, text) } else { // Fallback default avatar content @@ -179,9 +215,9 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ }, style: { width: size, height: size }, attrs: { 'aria-label': ariaLabel }, - props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, props) : {} + props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {} } - return h(tag, mergeData(data, componentData), [$content]) + return h(tag, componentData, [$content]) } }) From 106e9996e40271322d911b54c2f58c34937916ae Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:38:39 -0300 Subject: [PATCH 02/17] Update avatar.spec.js --- src/components/avatar/avatar.spec.js | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/components/avatar/avatar.spec.js b/src/components/avatar/avatar.spec.js index ccd187e7d0a..c11d40952a4 100644 --- a/src/components/avatar/avatar.spec.js +++ b/src/components/avatar/avatar.spec.js @@ -1,16 +1,19 @@ import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' import { BIconPerson } from '../../icons/icons' import { BAvatar } from './avatar' +import { waitNT } from '../../../tests/utils' describe('avatar', () => { it('should have expected default structure', async () => { const wrapper = mount(BAvatar) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') expect(wrapper.classes()).not.toContain('disabled') expect(wrapper.attributes('href')).not.toBeDefined() expect(wrapper.attributes('type')).not.toBeDefined() + wrapper.destroy() }) it('should have expected structure when prop `button` set', async () => { @@ -19,6 +22,7 @@ describe('avatar', () => { button: true } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('button')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('btn-secondary') @@ -29,6 +33,7 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when prop `href` set', async () => { @@ -37,6 +42,7 @@ describe('avatar', () => { href: '#foo' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('a')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -48,6 +54,7 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when prop `text` set', async () => { @@ -56,6 +63,7 @@ describe('avatar', () => { text: 'BV' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -65,6 +73,7 @@ describe('avatar', () => { expect(wrapper.text()).toContain('BV') expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when default slot used', async () => { @@ -76,6 +85,7 @@ describe('avatar', () => { default: 'BAR' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -86,6 +96,7 @@ describe('avatar', () => { expect(wrapper.text()).not.toContain('FOO') expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when prop `src` set', async () => { @@ -94,6 +105,7 @@ describe('avatar', () => { src: '/foo/bar' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -104,9 +116,19 @@ describe('avatar', () => { expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(true) expect(wrapper.find('img').attributes('src')).toEqual('/foo/bar') + + wrapper.setProps({ + src: '/foo/baz' + }) + await waitNT(wrapper.vm) + + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('img').attributes('src')).toEqual('/foo/baz') + + wrapper.destroy() }) - it('should have expected structure when prop `src` set', async () => { + it('should have expected structure when prop `icon` set', async () => { const localVue = new CreateLocalVue() localVue.component('BIconPerson', BIconPerson) const wrapper = mount(BAvatar, { @@ -115,6 +137,7 @@ describe('avatar', () => { icon: 'person' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -125,31 +148,40 @@ describe('avatar', () => { const $icon = wrapper.find('.b-icon') expect($icon.exists()).toBe(true) expect($icon.classes()).toContain('bi-person') + wrapper.destroy() }) it('`size` prop should work as expected', async () => { const wrapper1 = mount(BAvatar) expect(wrapper1.attributes('style')).toEqual('width: 2.5em; height: 2.5em;') + wrapper1.destroy() const wrapper2 = mount(BAvatar, { propsData: { size: 'sm' } }) expect(wrapper2.attributes('style')).toEqual('width: 1.5em; height: 1.5em;') + wrapper2.destroy() const wrapper3 = mount(BAvatar, { propsData: { size: 'md' } }) expect(wrapper3.attributes('style')).toEqual('width: 2.5em; height: 2.5em;') + wrapper3.destroy() const wrapper4 = mount(BAvatar, { propsData: { size: 'lg' } }) expect(wrapper4.attributes('style')).toEqual('width: 3.5em; height: 3.5em;') + wrapper4.destroy() const wrapper5 = mount(BAvatar, { propsData: { size: 20 } }) expect(wrapper5.attributes('style')).toEqual('width: 20px; height: 20px;') + wrapper5.destroy() const wrapper6 = mount(BAvatar, { propsData: { size: '24.5' } }) expect(wrapper6.attributes('style')).toEqual('width: 24.5px; height: 24.5px;') + wrapper6.destroy() const wrapper7 = mount(BAvatar, { propsData: { size: '5em' } }) expect(wrapper7.attributes('style')).toEqual('width: 5em; height: 5em;') + wrapper7.destroy() const wrapper8 = mount(BAvatar, { propsData: { size: '36px' } }) expect(wrapper8.attributes('style')).toEqual('width: 36px; height: 36px;') + wrapper8.destroy() }) }) From 2c8f75e6f8a45c8ffc718d6319508d5a36b5adba Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:42:11 -0300 Subject: [PATCH 03/17] lint --- src/components/avatar/avatar.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/avatar/avatar.spec.js b/src/components/avatar/avatar.spec.js index c11d40952a4..65b23a9ee9d 100644 --- a/src/components/avatar/avatar.spec.js +++ b/src/components/avatar/avatar.spec.js @@ -1,7 +1,7 @@ import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' import { BIconPerson } from '../../icons/icons' import { BAvatar } from './avatar' -import { waitNT } from '../../../tests/utils' +import { waitNT } from '../../../tests/utils' describe('avatar', () => { it('should have expected default structure', async () => { @@ -124,7 +124,7 @@ describe('avatar', () => { expect(wrapper.find('img').exists()).toBe(true) expect(wrapper.find('img').attributes('src')).toEqual('/foo/baz') - + wrapper.destroy() }) From c7fab65d9eeccb1ecc029cc79fb9c231cc7874f0 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:49:13 -0300 Subject: [PATCH 04/17] Update package.json --- src/components/avatar/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index e1ec1db2022..a09ce520ce6 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -69,6 +69,11 @@ "description": "Native event object" } ] + }, + { + "event": "img-error", + "version": "2.11.0", + "description": "Emitted if an image `src` is provided and the image fails to load" } ] } From 6378d6eab964dec2e255becd43d7ce549992d9ec Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:52:31 -0300 Subject: [PATCH 05/17] Update avatar.js --- src/components/avatar/avatar.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index fc836da0b4d..493c340b58c 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -162,6 +162,9 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ onImgError() /* istanbul ignore next: hard to fake an image error */ { this.localSrc = null this.$emit('img-error') + }, + onClick(evt) { + this.$emit('click', evt) } }, render(h) { @@ -215,7 +218,8 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ }, style: { width: size, height: size }, attrs: { 'aria-label': ariaLabel }, - props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {} + props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {}, + on: isBLink || isButton ? { click: this.onClick } : {} } return h(tag, componentData, [$content]) From a33685dd29cdced0b49c6c54f930053fbcd51fe3 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:58:05 -0300 Subject: [PATCH 06/17] Update avatar.spec.js --- src/components/avatar/avatar.spec.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/avatar/avatar.spec.js b/src/components/avatar/avatar.spec.js index 65b23a9ee9d..f9385f7f297 100644 --- a/src/components/avatar/avatar.spec.js +++ b/src/components/avatar/avatar.spec.js @@ -33,6 +33,16 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + + expect(wrapper.emitted('click')).toBeUndefined() + + wrapper.trigger('click') + await waitNT(wrapper.vm) + + expect(wrapper.emitted('click')).not.toBeUndefined() + expect(wrapper.emitted('click').length).toBe(1) + expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event) + wrapper.destroy() }) @@ -54,6 +64,16 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + + expect(wrapper.emitted('click')).toBeUndefined() + + wrapper.trigger('click') + await waitNT(wrapper.vm) + + expect(wrapper.emitted('click')).not.toBeUndefined() + expect(wrapper.emitted('click').length).toBe(1) + expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event) + wrapper.destroy() }) From b1ee0e145bb2a0f03c2c4896d126e59d61e340b8 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 00:58:46 -0300 Subject: [PATCH 07/17] Update package.json --- src/components/avatar/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index a09ce520ce6..e65d10ddafa 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -62,7 +62,7 @@ "events": [ { "event": "click", - "description": "Emitted when the avatar is clicked (when rendered as a button or link)", + "description": "Emitted when the avatar is clicked when rendered as a button or link. Not emitted if not a button or link", "args": [ { "arg": "evt", From 8624f10938aeca5877aead73d083ae5b6aab8447 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 01:11:47 -0300 Subject: [PATCH 08/17] Update avatar.spec.js --- src/components/avatar/avatar.spec.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/avatar/avatar.spec.js b/src/components/avatar/avatar.spec.js index f9385f7f297..d93a3e47dc5 100644 --- a/src/components/avatar/avatar.spec.js +++ b/src/components/avatar/avatar.spec.js @@ -122,7 +122,8 @@ describe('avatar', () => { it('should have expected structure when prop `src` set', async () => { const wrapper = mount(BAvatar, { propsData: { - src: '/foo/bar' + src: '/foo/bar', + text: 'BV' } }) expect(wrapper.isVueInstance()).toBe(true) @@ -136,6 +137,7 @@ describe('avatar', () => { expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(true) expect(wrapper.find('img').attributes('src')).toEqual('/foo/bar') + expect(wrapper.text()).not.toContain('BV') wrapper.setProps({ src: '/foo/baz' @@ -144,6 +146,17 @@ describe('avatar', () => { expect(wrapper.find('img').exists()).toBe(true) expect(wrapper.find('img').attributes('src')).toEqual('/foo/baz') + expect(wrapper.text()).not.toContain('BV') + expect(wrapper.emitted('img-error')).not.toBeDefined() + expect(wrapper.text()).not.toContain('BV') + + // Fake an image error + wrapper.find('img').trigger('error') + await waitNT(wrapper.vm) + expect(wrapper.emitted('img-error')).toBeDefined() + expect(wrapper.emitted('img-error').length).toBe(1) + expect(wrapper.find('img').exists()).toBe(false) + expect(wrapper.text()).toContain('BV') wrapper.destroy() }) From 45ec874ddb04fd1801bbf7c41f0cc90fbc48d31a Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 01:12:12 -0300 Subject: [PATCH 09/17] Update avatar.js --- src/components/avatar/avatar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index 493c340b58c..01e66068ab1 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -159,7 +159,7 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ } }, methods: { - onImgError() /* istanbul ignore next: hard to fake an image error */ { + onImgError() { this.localSrc = null this.$emit('img-error') }, From 7148996f1aca38ac3f1f9fb866f6547b80714c62 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 01:52:50 -0300 Subject: [PATCH 10/17] Update README.md --- src/components/avatar/README.md | 48 ++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/components/avatar/README.md b/src/components/avatar/README.md index b7f1acbf958..2e1e7d61216 100644 --- a/src/components/avatar/README.md +++ b/src/components/avatar/README.md @@ -52,27 +52,11 @@ components. ## Avatar types -The avatar content can be either a short text string, an image, or an icon. Avatar content defaults +The avatar content can be either a an image, an icon, or short text string. Avatar content defaults to the [`'person-fill'` icon](/docs/icons) when no other content is specified. -### Text content - -You can specify a short string as the content of an avatar via the `text` prop. The string should be -short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will -be scaled relative to the [`size` prop setting](#sizing). - -```html - - - -``` +You can also supply cunstom content via the default slot, although you may need to apply additional +styling on the content. ### Image content @@ -96,7 +80,10 @@ and will be sized to show the avatar's [variant background](#variants) around th - When using a module bundler and project relative image URLs, please refer to the [Component img src resolving](/docs/reference/images) reference section for additional details. -- The `src` prop takes precedence over the `text` prop. +- The `src` prop takes precedence over the `icon` and `text` props. +- If the image fails to load, the avatar will fallback to the value of the `icon` or `text` props. + If neither the `icon` or `text` props are provided, then the default avatar icon will be shown. + Alos, when the image fails to load, the `img-error` event will be emitted. ### Icon content @@ -121,10 +108,29 @@ prop should be set to a valid icon name. Icons will scale respective to the [`si - When providing a BootstrapVue icon name, you _must_ ensure that you have registered the corresponding icon component (either locally to your component/page, or globally), if not using the full [`BootstrapVueIcons` plugin](/docs/icons). -- The `icon` prop takes precedence over the `text` and `src` props. +- The `icon` prop takes precedence over the `text` prop. - If the `text`, `src`, or `icon` props are not provided _and_ the [default slot](#custom-content) has no content, then the `person-fill` icon will be used. +### Text content + +You can specify a short string as the content of an avatar via the `text` prop. The string should be +short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will +be scaled relative to the [`size` prop setting](#sizing). + +```html + + + +``` + ### Custom content Use the `default` slot to render custom content in the avatar, for finer grained control of its From 357da3963938eac0fa2413fca35c175bb6797abd Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 01:57:39 -0300 Subject: [PATCH 11/17] Update README.md --- src/components/avatar/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/avatar/README.md b/src/components/avatar/README.md index 2e1e7d61216..bc98cc2faf6 100644 --- a/src/components/avatar/README.md +++ b/src/components/avatar/README.md @@ -81,9 +81,10 @@ and will be sized to show the avatar's [variant background](#variants) around th - When using a module bundler and project relative image URLs, please refer to the [Component img src resolving](/docs/reference/images) reference section for additional details. - The `src` prop takes precedence over the `icon` and `text` props. -- If the image fails to load, the avatar will fallback to the value of the `icon` or `text` props. - If neither the `icon` or `text` props are provided, then the default avatar icon will be shown. - Alos, when the image fails to load, the `img-error` event will be emitted. +- 2.11.0+ If the image fails to load, the avatar will + fallback to the value of the `icon` or `text` props. If neither the `icon` or `text` props are + provided, then the default avatar icon will be shown. Also, when the image fails to load, the + `img-error` event will be emitted. ### Icon content From 28d246d9eb4b4dc1856f560d684ed66a70246de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20M=C3=BCller?= Date: Thu, 2 Apr 2020 09:58:10 +0200 Subject: [PATCH 12/17] Update README.md --- src/components/avatar/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/avatar/README.md b/src/components/avatar/README.md index bc98cc2faf6..d7e915f75f3 100644 --- a/src/components/avatar/README.md +++ b/src/components/avatar/README.md @@ -55,7 +55,7 @@ components. The avatar content can be either a an image, an icon, or short text string. Avatar content defaults to the [`'person-fill'` icon](/docs/icons) when no other content is specified. -You can also supply cunstom content via the default slot, although you may need to apply additional +You can also supply custom content via the default slot, although you may need to apply additional styling on the content. ### Image content From 39e820a157c011cd1af0937e88fb0382171398da Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Thu, 2 Apr 2020 14:15:18 -0300 Subject: [PATCH 13/17] restore changes --- src/components/avatar/README.md | 51 +++++++++++--------- src/components/avatar/avatar.js | 72 +++++++++++++++++++++------- src/components/avatar/avatar.spec.js | 69 +++++++++++++++++++++++++- src/components/avatar/package.json | 7 ++- 4 files changed, 158 insertions(+), 41 deletions(-) diff --git a/src/components/avatar/README.md b/src/components/avatar/README.md index b7f1acbf958..87923df9d9c 100644 --- a/src/components/avatar/README.md +++ b/src/components/avatar/README.md @@ -52,27 +52,11 @@ components. ## Avatar types -The avatar content can be either a short text string, an image, or an icon. Avatar content defaults +The avatar content can be either a an image, an icon, or short text string. Avatar content defaults to the [`'person-fill'` icon](/docs/icons) when no other content is specified. -### Text content - -You can specify a short string as the content of an avatar via the `text` prop. The string should be -short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will -be scaled relative to the [`size` prop setting](#sizing). - -```html - - - -``` +You can also supply custom content via the default slot, although you may need to apply additional +styling on the content. ### Image content @@ -96,7 +80,11 @@ and will be sized to show the avatar's [variant background](#variants) around th - When using a module bundler and project relative image URLs, please refer to the [Component img src resolving](/docs/reference/images) reference section for additional details. -- The `src` prop takes precedence over the `text` prop. +- The `src` prop takes precedence over the `icon` and `text` props. +- 2.11.0+ If the image fails to load, the avatar will + fallback to the value of the `icon` or `text` props. If neither the `icon` or `text` props are + provided, then the default avatar icon will be shown. Also, when the image fails to load, the + `img-error` event will be emitted. ### Icon content @@ -121,10 +109,29 @@ prop should be set to a valid icon name. Icons will scale respective to the [`si - When providing a BootstrapVue icon name, you _must_ ensure that you have registered the corresponding icon component (either locally to your component/page, or globally), if not using the full [`BootstrapVueIcons` plugin](/docs/icons). -- The `icon` prop takes precedence over the `text` and `src` props. +- The `icon` prop takes precedence over the `text` prop. - If the `text`, `src`, or `icon` props are not provided _and_ the [default slot](#custom-content) has no content, then the `person-fill` icon will be used. +### Text content + +You can specify a short string as the content of an avatar via the `text` prop. The string should be +short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will +be scaled relative to the [`size` prop setting](#sizing). + +```html + + + +``` + ### Custom content Use the `default` slot to render custom content in the avatar, for finer grained control of its @@ -323,4 +330,4 @@ Avatars are based upon `` and `` components, and as such, rel `badge-*` and `btn-*` variant classes, as well as the `rounded-*` [utility classes](/docs/reference/utility-classes). -`` also requires BootstrapVue's custom CSS for proper styling. +`` also requires BootstrapVue's custom CSS for proper styling. \ No newline at end of file diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index 0724fa1347c..01e66068ab1 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -1,4 +1,3 @@ -import { mergeData } from 'vue-functional-data-merge' import Vue from '../../utils/vue' import pluckProps from '../../utils/pluck-props' import { getComponentConfig } from '../../utils/config' @@ -8,6 +7,7 @@ import { BButton } from '../button/button' import { BLink } from '../link/link' import { BIcon } from '../../icons/icon' import { BIconPersonFill } from '../../icons/icons' +import normalizeSlotMixin from '../../mixins/normalize-slot' // --- Constants --- const NAME = 'BAvatar' @@ -135,30 +135,69 @@ const computeSize = value => { // @vue/component export const BAvatar = /*#__PURE__*/ Vue.extend({ name: NAME, - functional: true, + mixins: [normalizeSlotMixin], props, - render(h, { props, data, children }) { - const { variant, disabled, square, icon, src, text, button: isButton, buttonType: type } = props - const isBLink = !isButton && (props.href || props.to) + data() { + return { + localSrc: this.src || null + } + }, + computed: { + computedSize() { + return computeSize(this.size) + }, + fontSize() { + const size = this.computedSize + return size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null + } + }, + watch: { + src(newSrc, oldSrc) { + if (newSrc !== oldSrc) { + this.localSrc = newSrc || null + } + } + }, + methods: { + onImgError() { + this.localSrc = null + this.$emit('img-error') + }, + onClick(evt) { + this.$emit('click', evt) + } + }, + render(h) { + const { + variant, + disabled, + square, + icon, + localSrc: src, + text, + fontSize, + computedSize: size, + button: isButton, + buttonType: type + } = this + const isBLink = !isButton && (this.href || this.to) const tag = isButton ? BButton : isBLink ? BLink : 'span' - const rounded = square ? false : props.rounded === '' ? true : props.rounded || 'circle' - const size = computeSize(props.size) - const alt = props.alt || null - const ariaLabel = props.ariaLabel || null + const rounded = square ? false : this.rounded === '' ? true : this.rounded || 'circle' + const alt = this.alt || null + const ariaLabel = this.ariaLabel || null let $content = null - if (children) { + if (this.hasNormalizedSlot('default')) { // Default slot overrides props - $content = children + $content = this.normalizeSlot('default') + } else if (src) { + $content = h('img', { attrs: { src, alt }, on: { error: this.onImgError } }) } else if (icon) { $content = h(BIcon, { props: { icon }, attrs: { 'aria-hidden': 'true', alt } }) - } else if (src) { - $content = h('img', { attrs: { src, alt } }) } else if (text) { - const fontSize = size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null $content = h('span', { style: { fontSize } }, text) } else { // Fallback default avatar content @@ -179,9 +218,10 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ }, style: { width: size, height: size }, attrs: { 'aria-label': ariaLabel }, - props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, props) : {} + props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {}, + on: isBLink || isButton ? { click: this.onClick } : {} } - return h(tag, mergeData(data, componentData), [$content]) + return h(tag, componentData, [$content]) } }) diff --git a/src/components/avatar/avatar.spec.js b/src/components/avatar/avatar.spec.js index ccd187e7d0a..d93a3e47dc5 100644 --- a/src/components/avatar/avatar.spec.js +++ b/src/components/avatar/avatar.spec.js @@ -1,16 +1,19 @@ import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' import { BIconPerson } from '../../icons/icons' import { BAvatar } from './avatar' +import { waitNT } from '../../../tests/utils' describe('avatar', () => { it('should have expected default structure', async () => { const wrapper = mount(BAvatar) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') expect(wrapper.classes()).not.toContain('disabled') expect(wrapper.attributes('href')).not.toBeDefined() expect(wrapper.attributes('type')).not.toBeDefined() + wrapper.destroy() }) it('should have expected structure when prop `button` set', async () => { @@ -19,6 +22,7 @@ describe('avatar', () => { button: true } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('button')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('btn-secondary') @@ -29,6 +33,17 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + + expect(wrapper.emitted('click')).toBeUndefined() + + wrapper.trigger('click') + await waitNT(wrapper.vm) + + expect(wrapper.emitted('click')).not.toBeUndefined() + expect(wrapper.emitted('click').length).toBe(1) + expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event) + + wrapper.destroy() }) it('should have expected structure when prop `href` set', async () => { @@ -37,6 +52,7 @@ describe('avatar', () => { href: '#foo' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('a')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -48,6 +64,17 @@ describe('avatar', () => { expect(wrapper.text()).toEqual('') expect(wrapper.find('.b-icon').exists()).toBe(true) expect(wrapper.find('img').exists()).toBe(false) + + expect(wrapper.emitted('click')).toBeUndefined() + + wrapper.trigger('click') + await waitNT(wrapper.vm) + + expect(wrapper.emitted('click')).not.toBeUndefined() + expect(wrapper.emitted('click').length).toBe(1) + expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event) + + wrapper.destroy() }) it('should have expected structure when prop `text` set', async () => { @@ -56,6 +83,7 @@ describe('avatar', () => { text: 'BV' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -65,6 +93,7 @@ describe('avatar', () => { expect(wrapper.text()).toContain('BV') expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when default slot used', async () => { @@ -76,6 +105,7 @@ describe('avatar', () => { default: 'BAR' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -86,14 +116,17 @@ describe('avatar', () => { expect(wrapper.text()).not.toContain('FOO') expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(false) + wrapper.destroy() }) it('should have expected structure when prop `src` set', async () => { const wrapper = mount(BAvatar, { propsData: { - src: '/foo/bar' + src: '/foo/bar', + text: 'BV' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -104,9 +137,31 @@ describe('avatar', () => { expect(wrapper.find('.b-icon').exists()).toBe(false) expect(wrapper.find('img').exists()).toBe(true) expect(wrapper.find('img').attributes('src')).toEqual('/foo/bar') + expect(wrapper.text()).not.toContain('BV') + + wrapper.setProps({ + src: '/foo/baz' + }) + await waitNT(wrapper.vm) + + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('img').attributes('src')).toEqual('/foo/baz') + expect(wrapper.text()).not.toContain('BV') + expect(wrapper.emitted('img-error')).not.toBeDefined() + expect(wrapper.text()).not.toContain('BV') + + // Fake an image error + wrapper.find('img').trigger('error') + await waitNT(wrapper.vm) + expect(wrapper.emitted('img-error')).toBeDefined() + expect(wrapper.emitted('img-error').length).toBe(1) + expect(wrapper.find('img').exists()).toBe(false) + expect(wrapper.text()).toContain('BV') + + wrapper.destroy() }) - it('should have expected structure when prop `src` set', async () => { + it('should have expected structure when prop `icon` set', async () => { const localVue = new CreateLocalVue() localVue.component('BIconPerson', BIconPerson) const wrapper = mount(BAvatar, { @@ -115,6 +170,7 @@ describe('avatar', () => { icon: 'person' } }) + expect(wrapper.isVueInstance()).toBe(true) expect(wrapper.is('span')).toBe(true) expect(wrapper.classes()).toContain('b-avatar') expect(wrapper.classes()).toContain('badge-secondary') @@ -125,31 +181,40 @@ describe('avatar', () => { const $icon = wrapper.find('.b-icon') expect($icon.exists()).toBe(true) expect($icon.classes()).toContain('bi-person') + wrapper.destroy() }) it('`size` prop should work as expected', async () => { const wrapper1 = mount(BAvatar) expect(wrapper1.attributes('style')).toEqual('width: 2.5em; height: 2.5em;') + wrapper1.destroy() const wrapper2 = mount(BAvatar, { propsData: { size: 'sm' } }) expect(wrapper2.attributes('style')).toEqual('width: 1.5em; height: 1.5em;') + wrapper2.destroy() const wrapper3 = mount(BAvatar, { propsData: { size: 'md' } }) expect(wrapper3.attributes('style')).toEqual('width: 2.5em; height: 2.5em;') + wrapper3.destroy() const wrapper4 = mount(BAvatar, { propsData: { size: 'lg' } }) expect(wrapper4.attributes('style')).toEqual('width: 3.5em; height: 3.5em;') + wrapper4.destroy() const wrapper5 = mount(BAvatar, { propsData: { size: 20 } }) expect(wrapper5.attributes('style')).toEqual('width: 20px; height: 20px;') + wrapper5.destroy() const wrapper6 = mount(BAvatar, { propsData: { size: '24.5' } }) expect(wrapper6.attributes('style')).toEqual('width: 24.5px; height: 24.5px;') + wrapper6.destroy() const wrapper7 = mount(BAvatar, { propsData: { size: '5em' } }) expect(wrapper7.attributes('style')).toEqual('width: 5em; height: 5em;') + wrapper7.destroy() const wrapper8 = mount(BAvatar, { propsData: { size: '36px' } }) expect(wrapper8.attributes('style')).toEqual('width: 36px; height: 36px;') + wrapper8.destroy() }) }) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index e1ec1db2022..e65d10ddafa 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -62,13 +62,18 @@ "events": [ { "event": "click", - "description": "Emitted when the avatar is clicked (when rendered as a button or link)", + "description": "Emitted when the avatar is clicked when rendered as a button or link. Not emitted if not a button or link", "args": [ { "arg": "evt", "description": "Native event object" } ] + }, + { + "event": "img-error", + "version": "2.11.0", + "description": "Emitted if an image `src` is provided and the image fails to load" } ] } From d05e0f222073f6fb1a14ab94c54aa1963b2ac84c Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 6 Apr 2020 14:20:08 -0300 Subject: [PATCH 14/17] Update package.json --- src/components/avatar/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index e65d10ddafa..e273e915515 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -66,14 +66,14 @@ "args": [ { "arg": "evt", - "description": "Native event object" + "description": "Native Event object" } ] }, { "event": "img-error", "version": "2.11.0", - "description": "Emitted if an image `src` is provided and the image fails to load" + "description": "Emitted if an image `src` is provided and the image fails to load." } ] } From b20f7d3a5e5d7872023dddf961a5fcd61b7d0e14 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 6 Apr 2020 14:25:16 -0300 Subject: [PATCH 15/17] Update package.json --- src/components/avatar/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index e273e915515..42b59b5e5e7 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -62,7 +62,7 @@ "events": [ { "event": "click", - "description": "Emitted when the avatar is clicked when rendered as a button or link. Not emitted if not a button or link", + "description": "Emitted when the avatar is clicked when rendered as a button or link. Not emitted otherwise", "args": [ { "arg": "evt", From 938ee9e7fa28fa3e1591fb4f844a10dbd3435ac9 Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 6 Apr 2020 14:27:38 -0300 Subject: [PATCH 16/17] Update avatar.js --- src/components/avatar/avatar.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/avatar/avatar.js b/src/components/avatar/avatar.js index 01e66068ab1..85895609440 100644 --- a/src/components/avatar/avatar.js +++ b/src/components/avatar/avatar.js @@ -159,9 +159,9 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({ } }, methods: { - onImgError() { + onImgError(evt) { this.localSrc = null - this.$emit('img-error') + this.$emit('img-error', evt) }, onClick(evt) { this.$emit('click', evt) From 64061f82a1b82bcf20a51dbcffeb789b5eb9857d Mon Sep 17 00:00:00 2001 From: Troy Morehouse Date: Mon, 6 Apr 2020 14:28:10 -0300 Subject: [PATCH 17/17] Update package.json --- src/components/avatar/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/avatar/package.json b/src/components/avatar/package.json index 42b59b5e5e7..f236072e1a8 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -73,7 +73,13 @@ { "event": "img-error", "version": "2.11.0", - "description": "Emitted if an image `src` is provided and the image fails to load." + "description": "Emitted if an image `src` is provided and the image fails to load", + "args": [ + { + "arg": "evt", + "description": "Native Event object" + } + ] } ] }