Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 29 additions & 22 deletions src/components/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
72 changes: 56 additions & 16 deletions src/components/avatar/avatar.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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])
}
})
69 changes: 67 additions & 2 deletions src/components/avatar/avatar.spec.js
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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')
Expand All @@ -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 () => {
Expand All @@ -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')
Expand All @@ -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 () => {
Expand All @@ -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')
Expand All @@ -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 () => {
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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, {
Expand All @@ -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')
Expand All @@ -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()
})
})
Loading