Skip to content

Commit ed6704d

Browse files
feat(b-avatar): if img src fails to load, then show icon, text or fallback 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>
1 parent 33c6cef commit ed6704d

File tree

4 files changed

+165
-42
lines changed

4 files changed

+165
-42
lines changed

src/components/avatar/README.md

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,27 +52,11 @@ components.
5252

5353
## Avatar types
5454

55-
The avatar content can be either a short text string, an image, or an icon. Avatar content defaults
55+
The avatar content can be either a an image, an icon, or short text string. Avatar content defaults
5656
to the [`'person-fill'` icon](/docs/icons) when no other content is specified.
5757

58-
### Text content
59-
60-
You can specify a short string as the content of an avatar via the `text` prop. The string should be
61-
short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will
62-
be scaled relative to the [`size` prop setting](#sizing).
63-
64-
```html
65-
<template>
66-
<div class="mb-2">
67-
<b-avatar text="BV"></b-avatar>
68-
<b-avatar text="a"></b-avatar>
69-
<b-avatar text="Foo"></b-avatar>
70-
<b-avatar text="BV" size="4rem"></b-avatar>
71-
</div>
72-
</template>
73-
74-
<!-- b-avatar-text.vue -->
75-
```
58+
You can also supply custom content via the default slot, although you may need to apply additional
59+
styling on the content.
7660

7761
### Image content
7862

@@ -96,7 +80,11 @@ and will be sized to show the avatar's [variant background](#variants) around th
9680

9781
- When using a module bundler and project relative image URLs, please refer to the
9882
[Component img src resolving](/docs/reference/images) reference section for additional details.
99-
- The `src` prop takes precedence over the `text` prop.
83+
- The `src` prop takes precedence over the `icon` and `text` props.
84+
- <span class="badge badge-secondary">2.11.0+</span> If the image fails to load, the avatar will
85+
fallback to the value of the `icon` or `text` props. If neither the `icon` or `text` props are
86+
provided, then the default avatar icon will be shown. Also, when the image fails to load, the
87+
`img-error` event will be emitted.
10088

10189
### Icon content
10290

@@ -121,10 +109,29 @@ prop should be set to a valid icon name. Icons will scale respective to the [`si
121109
- When providing a BootstrapVue icon name, you _must_ ensure that you have registered the
122110
corresponding icon component (either locally to your component/page, or globally), if not using
123111
the full [`BootstrapVueIcons` plugin](/docs/icons).
124-
- The `icon` prop takes precedence over the `text` and `src` props.
112+
- The `icon` prop takes precedence over the `text` prop.
125113
- If the `text`, `src`, or `icon` props are not provided _and_ the [default slot](#custom-content)
126114
has no content, then the `person-fill` icon will be used.
127115

116+
### Text content
117+
118+
You can specify a short string as the content of an avatar via the `text` prop. The string should be
119+
short (1 to 3 characters), and will be transformed via CSS to be all uppercase. The font size will
120+
be scaled relative to the [`size` prop setting](#sizing).
121+
122+
```html
123+
<template>
124+
<div class="mb-2">
125+
<b-avatar text="BV"></b-avatar>
126+
<b-avatar text="a"></b-avatar>
127+
<b-avatar text="Foo"></b-avatar>
128+
<b-avatar text="BV" size="4rem"></b-avatar>
129+
</div>
130+
</template>
131+
132+
<!-- b-avatar-text.vue -->
133+
```
134+
128135
### Custom content
129136

130137
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
323330
`badge-*` and `btn-*` variant classes, as well as the `rounded-*`
324331
[utility classes](/docs/reference/utility-classes).
325332

326-
`<b-avatar>` also requires BootstrapVue's custom CSS for proper styling.
333+
`<b-avatar>` also requires BootstrapVue's custom CSS for proper styling.

src/components/avatar/avatar.js

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { mergeData } from 'vue-functional-data-merge'
21
import Vue from '../../utils/vue'
32
import pluckProps from '../../utils/pluck-props'
43
import { getComponentConfig } from '../../utils/config'
@@ -8,6 +7,7 @@ import { BButton } from '../button/button'
87
import { BLink } from '../link/link'
98
import { BIcon } from '../../icons/icon'
109
import { BIconPersonFill } from '../../icons/icons'
10+
import normalizeSlotMixin from '../../mixins/normalize-slot'
1111

1212
// --- Constants ---
1313
const NAME = 'BAvatar'
@@ -135,30 +135,69 @@ const computeSize = value => {
135135
// @vue/component
136136
export const BAvatar = /*#__PURE__*/ Vue.extend({
137137
name: NAME,
138-
functional: true,
138+
mixins: [normalizeSlotMixin],
139139
props,
140-
render(h, { props, data, children }) {
141-
const { variant, disabled, square, icon, src, text, button: isButton, buttonType: type } = props
142-
const isBLink = !isButton && (props.href || props.to)
140+
data() {
141+
return {
142+
localSrc: this.src || null
143+
}
144+
},
145+
computed: {
146+
computedSize() {
147+
return computeSize(this.size)
148+
},
149+
fontSize() {
150+
const size = this.computedSize
151+
return size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null
152+
}
153+
},
154+
watch: {
155+
src(newSrc, oldSrc) {
156+
if (newSrc !== oldSrc) {
157+
this.localSrc = newSrc || null
158+
}
159+
}
160+
},
161+
methods: {
162+
onImgError(evt) {
163+
this.localSrc = null
164+
this.$emit('img-error', evt)
165+
},
166+
onClick(evt) {
167+
this.$emit('click', evt)
168+
}
169+
},
170+
render(h) {
171+
const {
172+
variant,
173+
disabled,
174+
square,
175+
icon,
176+
localSrc: src,
177+
text,
178+
fontSize,
179+
computedSize: size,
180+
button: isButton,
181+
buttonType: type
182+
} = this
183+
const isBLink = !isButton && (this.href || this.to)
143184
const tag = isButton ? BButton : isBLink ? BLink : 'span'
144-
const rounded = square ? false : props.rounded === '' ? true : props.rounded || 'circle'
145-
const size = computeSize(props.size)
146-
const alt = props.alt || null
147-
const ariaLabel = props.ariaLabel || null
185+
const rounded = square ? false : this.rounded === '' ? true : this.rounded || 'circle'
186+
const alt = this.alt || null
187+
const ariaLabel = this.ariaLabel || null
148188

149189
let $content = null
150-
if (children) {
190+
if (this.hasNormalizedSlot('default')) {
151191
// Default slot overrides props
152-
$content = children
192+
$content = this.normalizeSlot('default')
193+
} else if (src) {
194+
$content = h('img', { attrs: { src, alt }, on: { error: this.onImgError } })
153195
} else if (icon) {
154196
$content = h(BIcon, {
155197
props: { icon },
156198
attrs: { 'aria-hidden': 'true', alt }
157199
})
158-
} else if (src) {
159-
$content = h('img', { attrs: { src, alt } })
160200
} else if (text) {
161-
const fontSize = size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null
162201
$content = h('span', { style: { fontSize } }, text)
163202
} else {
164203
// Fallback default avatar content
@@ -179,9 +218,10 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
179218
},
180219
style: { width: size, height: size },
181220
attrs: { 'aria-label': ariaLabel },
182-
props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, props) : {}
221+
props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {},
222+
on: isBLink || isButton ? { click: this.onClick } : {}
183223
}
184224

185-
return h(tag, mergeData(data, componentData), [$content])
225+
return h(tag, componentData, [$content])
186226
}
187227
})

src/components/avatar/avatar.spec.js

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils'
22
import { BIconPerson } from '../../icons/icons'
33
import { BAvatar } from './avatar'
4+
import { waitNT } from '../../../tests/utils'
45

56
describe('avatar', () => {
67
it('should have expected default structure', async () => {
78
const wrapper = mount(BAvatar)
9+
expect(wrapper.isVueInstance()).toBe(true)
810
expect(wrapper.is('span')).toBe(true)
911
expect(wrapper.classes()).toContain('b-avatar')
1012
expect(wrapper.classes()).toContain('badge-secondary')
1113
expect(wrapper.classes()).not.toContain('disabled')
1214
expect(wrapper.attributes('href')).not.toBeDefined()
1315
expect(wrapper.attributes('type')).not.toBeDefined()
16+
wrapper.destroy()
1417
})
1518

1619
it('should have expected structure when prop `button` set', async () => {
@@ -19,6 +22,7 @@ describe('avatar', () => {
1922
button: true
2023
}
2124
})
25+
expect(wrapper.isVueInstance()).toBe(true)
2226
expect(wrapper.is('button')).toBe(true)
2327
expect(wrapper.classes()).toContain('b-avatar')
2428
expect(wrapper.classes()).toContain('btn-secondary')
@@ -29,6 +33,17 @@ describe('avatar', () => {
2933
expect(wrapper.text()).toEqual('')
3034
expect(wrapper.find('.b-icon').exists()).toBe(true)
3135
expect(wrapper.find('img').exists()).toBe(false)
36+
37+
expect(wrapper.emitted('click')).toBeUndefined()
38+
39+
wrapper.trigger('click')
40+
await waitNT(wrapper.vm)
41+
42+
expect(wrapper.emitted('click')).not.toBeUndefined()
43+
expect(wrapper.emitted('click').length).toBe(1)
44+
expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
45+
46+
wrapper.destroy()
3247
})
3348

3449
it('should have expected structure when prop `href` set', async () => {
@@ -37,6 +52,7 @@ describe('avatar', () => {
3752
href: '#foo'
3853
}
3954
})
55+
expect(wrapper.isVueInstance()).toBe(true)
4056
expect(wrapper.is('a')).toBe(true)
4157
expect(wrapper.classes()).toContain('b-avatar')
4258
expect(wrapper.classes()).toContain('badge-secondary')
@@ -48,6 +64,17 @@ describe('avatar', () => {
4864
expect(wrapper.text()).toEqual('')
4965
expect(wrapper.find('.b-icon').exists()).toBe(true)
5066
expect(wrapper.find('img').exists()).toBe(false)
67+
68+
expect(wrapper.emitted('click')).toBeUndefined()
69+
70+
wrapper.trigger('click')
71+
await waitNT(wrapper.vm)
72+
73+
expect(wrapper.emitted('click')).not.toBeUndefined()
74+
expect(wrapper.emitted('click').length).toBe(1)
75+
expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
76+
77+
wrapper.destroy()
5178
})
5279

5380
it('should have expected structure when prop `text` set', async () => {
@@ -56,6 +83,7 @@ describe('avatar', () => {
5683
text: 'BV'
5784
}
5885
})
86+
expect(wrapper.isVueInstance()).toBe(true)
5987
expect(wrapper.is('span')).toBe(true)
6088
expect(wrapper.classes()).toContain('b-avatar')
6189
expect(wrapper.classes()).toContain('badge-secondary')
@@ -65,6 +93,7 @@ describe('avatar', () => {
6593
expect(wrapper.text()).toContain('BV')
6694
expect(wrapper.find('.b-icon').exists()).toBe(false)
6795
expect(wrapper.find('img').exists()).toBe(false)
96+
wrapper.destroy()
6897
})
6998

7099
it('should have expected structure when default slot used', async () => {
@@ -76,6 +105,7 @@ describe('avatar', () => {
76105
default: 'BAR'
77106
}
78107
})
108+
expect(wrapper.isVueInstance()).toBe(true)
79109
expect(wrapper.is('span')).toBe(true)
80110
expect(wrapper.classes()).toContain('b-avatar')
81111
expect(wrapper.classes()).toContain('badge-secondary')
@@ -86,14 +116,17 @@ describe('avatar', () => {
86116
expect(wrapper.text()).not.toContain('FOO')
87117
expect(wrapper.find('.b-icon').exists()).toBe(false)
88118
expect(wrapper.find('img').exists()).toBe(false)
119+
wrapper.destroy()
89120
})
90121

91122
it('should have expected structure when prop `src` set', async () => {
92123
const wrapper = mount(BAvatar, {
93124
propsData: {
94-
src: '/foo/bar'
125+
src: '/foo/bar',
126+
text: 'BV'
95127
}
96128
})
129+
expect(wrapper.isVueInstance()).toBe(true)
97130
expect(wrapper.is('span')).toBe(true)
98131
expect(wrapper.classes()).toContain('b-avatar')
99132
expect(wrapper.classes()).toContain('badge-secondary')
@@ -104,9 +137,31 @@ describe('avatar', () => {
104137
expect(wrapper.find('.b-icon').exists()).toBe(false)
105138
expect(wrapper.find('img').exists()).toBe(true)
106139
expect(wrapper.find('img').attributes('src')).toEqual('/foo/bar')
140+
expect(wrapper.text()).not.toContain('BV')
141+
142+
wrapper.setProps({
143+
src: '/foo/baz'
144+
})
145+
await waitNT(wrapper.vm)
146+
147+
expect(wrapper.find('img').exists()).toBe(true)
148+
expect(wrapper.find('img').attributes('src')).toEqual('/foo/baz')
149+
expect(wrapper.text()).not.toContain('BV')
150+
expect(wrapper.emitted('img-error')).not.toBeDefined()
151+
expect(wrapper.text()).not.toContain('BV')
152+
153+
// Fake an image error
154+
wrapper.find('img').trigger('error')
155+
await waitNT(wrapper.vm)
156+
expect(wrapper.emitted('img-error')).toBeDefined()
157+
expect(wrapper.emitted('img-error').length).toBe(1)
158+
expect(wrapper.find('img').exists()).toBe(false)
159+
expect(wrapper.text()).toContain('BV')
160+
161+
wrapper.destroy()
107162
})
108163

109-
it('should have expected structure when prop `src` set', async () => {
164+
it('should have expected structure when prop `icon` set', async () => {
110165
const localVue = new CreateLocalVue()
111166
localVue.component('BIconPerson', BIconPerson)
112167
const wrapper = mount(BAvatar, {
@@ -115,6 +170,7 @@ describe('avatar', () => {
115170
icon: 'person'
116171
}
117172
})
173+
expect(wrapper.isVueInstance()).toBe(true)
118174
expect(wrapper.is('span')).toBe(true)
119175
expect(wrapper.classes()).toContain('b-avatar')
120176
expect(wrapper.classes()).toContain('badge-secondary')
@@ -125,31 +181,40 @@ describe('avatar', () => {
125181
const $icon = wrapper.find('.b-icon')
126182
expect($icon.exists()).toBe(true)
127183
expect($icon.classes()).toContain('bi-person')
184+
wrapper.destroy()
128185
})
129186

130187
it('`size` prop should work as expected', async () => {
131188
const wrapper1 = mount(BAvatar)
132189
expect(wrapper1.attributes('style')).toEqual('width: 2.5em; height: 2.5em;')
190+
wrapper1.destroy()
133191

134192
const wrapper2 = mount(BAvatar, { propsData: { size: 'sm' } })
135193
expect(wrapper2.attributes('style')).toEqual('width: 1.5em; height: 1.5em;')
194+
wrapper2.destroy()
136195

137196
const wrapper3 = mount(BAvatar, { propsData: { size: 'md' } })
138197
expect(wrapper3.attributes('style')).toEqual('width: 2.5em; height: 2.5em;')
198+
wrapper3.destroy()
139199

140200
const wrapper4 = mount(BAvatar, { propsData: { size: 'lg' } })
141201
expect(wrapper4.attributes('style')).toEqual('width: 3.5em; height: 3.5em;')
202+
wrapper4.destroy()
142203

143204
const wrapper5 = mount(BAvatar, { propsData: { size: 20 } })
144205
expect(wrapper5.attributes('style')).toEqual('width: 20px; height: 20px;')
206+
wrapper5.destroy()
145207

146208
const wrapper6 = mount(BAvatar, { propsData: { size: '24.5' } })
147209
expect(wrapper6.attributes('style')).toEqual('width: 24.5px; height: 24.5px;')
210+
wrapper6.destroy()
148211

149212
const wrapper7 = mount(BAvatar, { propsData: { size: '5em' } })
150213
expect(wrapper7.attributes('style')).toEqual('width: 5em; height: 5em;')
214+
wrapper7.destroy()
151215

152216
const wrapper8 = mount(BAvatar, { propsData: { size: '36px' } })
153217
expect(wrapper8.attributes('style')).toEqual('width: 36px; height: 36px;')
218+
wrapper8.destroy()
154219
})
155220
})

0 commit comments

Comments
 (0)