Skip to content
Permalink
Browse files
feat(b-avatar): if img src fails to load, then show icon, text or f…
…allback icon (#5079)

* feat(b-avatar): if img `src` fails to load, show icon or text or fallback icon

* Update avatar.spec.js

* lint

* Update package.json

* Update avatar.js

* Update avatar.spec.js

* Update package.json

* Update avatar.spec.js

* Update avatar.js

* Update README.md

* Update README.md

* Update README.md

* restore changes

* Update package.json

* Update package.json

* Update avatar.js

* Update package.json

Co-authored-by: Jacob Müller <jacob.mueller.elz@gmail.com>
  • Loading branch information
tmorehouse and jacobmllr95 committed Apr 6, 2020
1 parent 33c6cef commit ed6704d0971ade485393b7f711f05d93ca42ebc3
Showing 4 changed files with 165 additions and 42 deletions.
@@ -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
<template>
<div class="mb-2">
<b-avatar text="BV"></b-avatar>
<b-avatar text="a"></b-avatar>
<b-avatar text="Foo"></b-avatar>
<b-avatar text="BV" size="4rem"></b-avatar>
</div>
</template>

<!-- b-avatar-text.vue -->
```
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.
- <span class="badge badge-secondary">2.11.0+</span> 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
<template>
<div class="mb-2">
<b-avatar text="BV"></b-avatar>
<b-avatar text="a"></b-avatar>
<b-avatar text="Foo"></b-avatar>
<b-avatar text="BV" size="4rem"></b-avatar>
</div>
</template>

<!-- b-avatar-text.vue -->
```

### 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 `<b-badge>` and `<b-button>` components, and as such, rel
`badge-*` and `btn-*` variant classes, as well as the `rounded-*`
[utility classes](/docs/reference/utility-classes).

`<b-avatar>` also requires BootstrapVue's custom CSS for proper styling.
`<b-avatar>` also requires BootstrapVue's custom CSS for proper styling.
@@ -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])
}
})
@@ -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()
})
})

0 comments on commit ed6704d

Please sign in to comment.