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..85895609440 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(evt) { + this.localSrc = null + this.$emit('img-error', evt) + }, + 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..f236072e1a8 100644 --- a/src/components/avatar/package.json +++ b/src/components/avatar/package.json @@ -62,11 +62,22 @@ "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 otherwise", "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", + "args": [ + { + "arg": "evt", + "description": "Native Event object" } ] }