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"
}
]
}