Skip to content

Commit a2e465b

Browse files
authored
feat(b-avatar): and support for badges on avatars (#5124)
Co-authored-by: Jacob Müller
1 parent 1c8014a commit a2e465b

File tree

8 files changed

+242
-14
lines changed

8 files changed

+242
-14
lines changed

src/_custom-controls.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@
9494
}
9595
}
9696

97-
9897
// Disabled and read-only styling
9998
&[aria-disabled="true"],
10099
&[aria-readonly="true"] {

src/components/avatar/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,92 @@ The `to` prop can either be a string path, or a `Location` object. The `to` prop
315315
- For additional details on the `<router-link>` compatible props, please refer to the
316316
[Router support reference section](/docs/reference/router-links).
317317

318+
## Badge avatars
319+
320+
<span class="badge badge-info small">2.12.0+<span>
321+
322+
Easily add a badge to your avatar via the `badge` prop or `'badge'` slot, and the badge variant can
323+
be set via the `badge-variant` prop. The badge will scale with the size of the avatar.
324+
325+
```html
326+
<template>
327+
<div>
328+
<b-avatar badge></b-avatar>
329+
<b-avatar badge badge-variant="danger" src="https://placekitten.com/300/300"></b-avatar>
330+
<b-avatar badge badge-variant="warning" icon="people-fill"></b-avatar>
331+
<b-avatar badge badge-variant="success" src="https://placekitten.com/300/300"></b-avatar>
332+
<b-avatar badge badge-variant="dark" text="BV"></b-avatar>
333+
<b-avatar square badge badge-variant="dark" text="BV"></b-avatar>
334+
</div>
335+
</template>
336+
337+
<!-- b-avatar-badge.vue -->
338+
```
339+
340+
### Badge content
341+
342+
Add textual content to the badge by supplying a string to the `badge` prop, or use the named slot
343+
`'badge'`.
344+
345+
```html
346+
<template>
347+
<div style="font-size: 2rem">
348+
<b-avatar badge="BV"></b-avatar>
349+
<b-avatar badge="7" variant="primary" badge-variant="dark"></b-avatar>
350+
<b-avatar badge-variant="info" src="https://placekitten.com/300/300">
351+
<template v-slot:badge><b-icon icon="star-fill"></b-badge></template>
352+
</b-avatar>
353+
</div>
354+
</template>
355+
356+
<!-- b-avatar-badge-content.vue -->
357+
```
358+
359+
### Badge positioning
360+
361+
By default the badge appears on the bottom right of the avatar. You can use the `badge-top` and
362+
`badge-right` boolean props to switch the sides. Combine both props to move the badge to the top
363+
right of the avatar.
364+
365+
```html
366+
<template>
367+
<div>
368+
<b-avatar badge></b-avatar>
369+
<b-avatar badge badge-left></b-avatar>
370+
<b-avatar badge badge-top></b-avatar>
371+
<b-avatar badge badge-left badge-top></b-avatar>
372+
</div>
373+
</template>
374+
375+
<!-- b-avatar-badge-position.vue -->
376+
```
377+
378+
Use the `badge-offset` prop to control the offset of the badge. The `badge-offset` must be a valid
379+
CSS length string (i.e. `'2px'`, `'-2px'`, `'0.5em'`, etc.). Positive values will move the badge
380+
inward, while negative values will move the badge outward.
381+
382+
```html
383+
<template>
384+
<div>
385+
<b-avatar badge></b-avatar>
386+
<b-avatar badge badge-offset="-0.5em"></b-avatar>
387+
<b-avatar badge badge-offset="-2px"></b-avatar>
388+
<b-avatar badge badge-offset="2px"></b-avatar>
389+
<b-avatar badge badge-top></b-avatar>
390+
<b-avatar badge badge-top badge-offset="-0.5em"></b-avatar>
391+
<b-avatar badge badge-top badge-offset="-2px"></b-avatar>
392+
<b-avatar badge badge-top badge-offset="2px"></b-avatar>
393+
</div>
394+
</template>
395+
396+
<!-- b-avatar-badge-offset.vue -->
397+
```
398+
318399
## Accessibility
319400

320401
Use the `aria-label` prop to provide an accessible, screen reader friendly, label for your avatar.
402+
If you have a badge, it is recommended to add inforation to your aria-label regarding the badge
403+
purpose or content (i.g. `'3 messages'`, `'online'`, etc)).
321404

322405
While the `click` event is emitted regardless if the `button`, `href`, or `to` props are set, it is
323406
highly recommended to use the `button` prop when the click event should trigger an action (or use

src/components/avatar/_avatar.scss

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010
max-width: 100%;
1111
max-height: auto;
1212
text-align: center;
13-
text-transform: uppercase;
14-
white-space: nowrap;
15-
overflow: hidden;
13+
overflow: visible;
14+
position: relative;
1615
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
1716
box-shadow 0.15s ease-in-out;
1817

@@ -38,15 +37,30 @@
3837
pointer-events: none;
3938
}
4039

41-
> span {
40+
.b-avatar-custom,
41+
.b-avatar-text {
4242
border-radius: inherit;
43+
width: 100%;
44+
height: 100%;
45+
overflow: hidden;
46+
display: flex;
47+
justify-content: center;
4348
}
4449

45-
.b-icon {
50+
.b-avatar-text {
51+
text-transform: uppercase;
52+
white-space: nowrap;
53+
align-items: center;
54+
}
55+
56+
.b-avatar-custom {
57+
vertical-align: middle;
58+
}
59+
60+
> .b-icon {
4661
width: 60%;
4762
height: auto;
4863
max-width: 100%;
49-
max-height: auto;
5064
}
5165

5266
img {
@@ -56,4 +70,17 @@
5670
max-height: auto;
5771
border-radius: inherit;
5872
}
73+
74+
.b-avatar-badge {
75+
// Positioning will be handled via inline styles
76+
position: absolute;
77+
min-height: 1.5em;
78+
min-width: 1.5em;
79+
padding: 0.25em;
80+
line-height: 1;
81+
border-radius: 10em;
82+
font-size: 70%;
83+
font-weight: 700;
84+
z-index: 5;
85+
}
5986
}

src/components/avatar/avatar.js

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const CLASS_NAME = 'b-avatar'
1616
const RX_NUMBER = /^[0-9]*\.?[0-9]+$/
1717

1818
const FONT_SIZE_SCALE = 0.4
19+
const BADGE_FONT_SIZE_SCALE = FONT_SIZE_SCALE * 0.7
1920

2021
const DEFAULT_SIZES = {
2122
sm: '1.5em',
@@ -112,6 +113,26 @@ const props = {
112113
type: String,
113114
default: 'button'
114115
},
116+
badge: {
117+
type: [Boolean, String],
118+
default: false
119+
},
120+
badgeVariant: {
121+
type: String,
122+
default: () => getComponentConfig(NAME, 'badgeVariant')
123+
},
124+
badgeTop: {
125+
type: Boolean,
126+
default: false
127+
},
128+
badgeLeft: {
129+
type: Boolean,
130+
default: false
131+
},
132+
badgeOffset: {
133+
type: String,
134+
default: '0px'
135+
},
115136
...linkProps,
116137
ariaLabel: {
117138
type: String
@@ -149,6 +170,17 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
149170
fontSize() {
150171
const size = this.computedSize
151172
return size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null
173+
},
174+
badgeStyle() {
175+
const { computedSize: size, badgeTop, badgeLeft, badgeOffset } = this
176+
const offset = badgeOffset || '0px'
177+
return {
178+
fontSize: size ? `calc(${size} * ${BADGE_FONT_SIZE_SCALE} )` : null,
179+
top: badgeTop ? offset : null,
180+
bottom: badgeTop ? null : offset,
181+
left: badgeLeft ? offset : null,
182+
right: badgeLeft ? null : offset
183+
}
152184
}
153185
},
154186
watch: {
@@ -178,7 +210,10 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
178210
fontSize,
179211
computedSize: size,
180212
button: isButton,
181-
buttonType: type
213+
buttonType: type,
214+
badge,
215+
badgeVariant,
216+
badgeStyle
182217
} = this
183218
const isBLink = !isButton && (this.href || this.to)
184219
const tag = isButton ? BButton : isBLink ? BLink : 'span'
@@ -189,7 +224,7 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
189224
let $content = null
190225
if (this.hasNormalizedSlot('default')) {
191226
// Default slot overrides props
192-
$content = this.normalizeSlot('default')
227+
$content = h('span', { staticClass: 'b-avatar-custom' }, [this.normalizeSlot('default')])
193228
} else if (src) {
194229
$content = h('img', { attrs: { src, alt }, on: { error: this.onImgError } })
195230
} else if (icon) {
@@ -198,12 +233,27 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
198233
attrs: { 'aria-hidden': 'true', alt }
199234
})
200235
} else if (text) {
201-
$content = h('span', { style: { fontSize } }, text)
236+
$content = h('span', { staticClass: 'b-avatar-text', style: { fontSize } }, [h('span', text)])
202237
} else {
203238
// Fallback default avatar content
204239
$content = h(BIconPersonFill, { attrs: { 'aria-hidden': 'true', alt } })
205240
}
206241

242+
let $badge = h()
243+
const hasBadgeSlot = this.hasNormalizedSlot('badge')
244+
if (badge || badge === '' || hasBadgeSlot) {
245+
const badgeText = badge === true ? '' : badge
246+
$badge = h(
247+
'span',
248+
{
249+
staticClass: 'b-avatar-badge',
250+
class: { [`badge-${badgeVariant}`]: !!badgeVariant },
251+
style: badgeStyle
252+
},
253+
[hasBadgeSlot ? this.normalizeSlot('badge') : badgeText]
254+
)
255+
}
256+
207257
const componentData = {
208258
staticClass: CLASS_NAME,
209259
class: {
@@ -217,11 +267,11 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
217267
disabled
218268
},
219269
style: { width: size, height: size },
220-
attrs: { 'aria-label': ariaLabel },
270+
attrs: { 'aria-label': ariaLabel || null },
221271
props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {},
222272
on: isBLink || isButton ? { click: this.onClick } : {}
223273
}
224274

225-
return h(tag, componentData, [$content])
275+
return h(tag, componentData, [$content, $badge])
226276
}
227277
})

src/components/avatar/avatar.spec.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,41 @@ describe('avatar', () => {
217217
expect(wrapper8.attributes('style')).toEqual('width: 36px; height: 36px;')
218218
wrapper8.destroy()
219219
})
220+
221+
it('should have expected structure when prop badge is set', async () => {
222+
const wrapper = mount(BAvatar, {
223+
propsData: {
224+
badge: true
225+
}
226+
})
227+
expect(wrapper.isVueInstance()).toBe(true)
228+
expect(wrapper.is('span')).toBe(true)
229+
expect(wrapper.classes()).toContain('b-avatar')
230+
expect(wrapper.classes()).toContain('badge-secondary')
231+
expect(wrapper.classes()).not.toContain('disabled')
232+
expect(wrapper.attributes('href')).not.toBeDefined()
233+
expect(wrapper.attributes('type')).not.toBeDefined()
234+
235+
const $badge = wrapper.find('.b-avatar-badge')
236+
expect($badge.exists()).toBe(true)
237+
expect($badge.classes()).toContain('badge-primary')
238+
expect($badge.text()).toEqual('')
239+
240+
wrapper.setProps({
241+
badge: 'FOO'
242+
})
243+
await waitNT(wrapper.vm)
244+
expect($badge.classes()).toContain('badge-primary')
245+
expect($badge.text()).toEqual('FOO')
246+
247+
wrapper.setProps({
248+
badgeVariant: 'info'
249+
})
250+
await waitNT(wrapper.vm)
251+
expect($badge.classes()).not.toContain('badge-primary')
252+
expect($badge.classes()).toContain('badge-info')
253+
expect($badge.text()).toEqual('FOO')
254+
255+
wrapper.destroy()
256+
})
220257
})

src/components/avatar/package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,43 @@
5151
{
5252
"prop": "rounded",
5353
"description": "Specifies the type of rounding to apply to the avatar. The `square` prop takes precedence. Refer to the documentation for details"
54+
},
55+
{
56+
"prop": "badge",
57+
"version": "2.12.0",
58+
"description": "When `true` shows an empty badge on the avatar, alternatively set to a string for content in the badge"
59+
},
60+
{
61+
"prop": "badgeVariant",
62+
"version": "2.12.0",
63+
"settings": true,
64+
"description": "Applies one of the Bootstrap theme color variants to the badge"
65+
},
66+
{
67+
"prop": "badgeTop",
68+
"version": "2.12.0",
69+
"description": "When `true` places the badge at the top instead of the bottom"
70+
},
71+
{
72+
"prop": "badgeLeft",
73+
"version": "2.12.0",
74+
"description": "When `true` places the badge at the left instead of the right"
75+
},
76+
{
77+
"prop": "badgeOffset",
78+
"version": "2.12.0",
79+
"description": "CSS length to offset the badge. Positive values move the badge inwards, while negative values move the badge outwards"
5480
}
5581
],
5682
"slots": [
5783
{
5884
"name": "default",
5985
"description": "Content to place in the avatar. Overrides props `text`, `src`, and `icon-name`"
86+
},
87+
{
88+
"name": "badge",
89+
"version": "2.12.0",
90+
"description": "Content to place in the avatars optional badge. Overrides the `badge` prop"
6091
}
6192
],
6293
"events": [

src/components/time/_time.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@
3636
}
3737

3838
.b-time-ampm {
39-
margin-left: .5rem;
39+
margin-left: 0.5rem;
4040
}
4141
}

src/utils/config-defaults.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export default deepFreeze({
5252
variant: 'info'
5353
},
5454
BAvatar: {
55-
variant: 'secondary'
55+
variant: 'secondary',
56+
badgeVariant: 'primary'
5657
},
5758
BBadge: {
5859
variant: 'secondary'

0 commit comments

Comments
 (0)