Skip to content

Commit

Permalink
feat(b-avatar): and support for badges on avatars (#5124)
Browse files Browse the repository at this point in the history
Co-authored-by: Jacob Müller
  • Loading branch information
tmorehouse committed Apr 13, 2020
1 parent 1c8014a commit a2e465b
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 14 deletions.
1 change: 0 additions & 1 deletion src/_custom-controls.scss
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@
}
}


// Disabled and read-only styling
&[aria-disabled="true"],
&[aria-readonly="true"] {
Expand Down
83 changes: 83 additions & 0 deletions src/components/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,92 @@ The `to` prop can either be a string path, or a `Location` object. The `to` prop
- For additional details on the `<router-link>` compatible props, please refer to the
[Router support reference section](/docs/reference/router-links).

## Badge avatars

<span class="badge badge-info small">2.12.0+<span>

Easily add a badge to your avatar via the `badge` prop or `'badge'` slot, and the badge variant can
be set via the `badge-variant` prop. The badge will scale with the size of the avatar.

```html
<template>
<div>
<b-avatar badge></b-avatar>
<b-avatar badge badge-variant="danger" src="https://placekitten.com/300/300"></b-avatar>
<b-avatar badge badge-variant="warning" icon="people-fill"></b-avatar>
<b-avatar badge badge-variant="success" src="https://placekitten.com/300/300"></b-avatar>
<b-avatar badge badge-variant="dark" text="BV"></b-avatar>
<b-avatar square badge badge-variant="dark" text="BV"></b-avatar>
</div>
</template>

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

### Badge content

Add textual content to the badge by supplying a string to the `badge` prop, or use the named slot
`'badge'`.

```html
<template>
<div style="font-size: 2rem">
<b-avatar badge="BV"></b-avatar>
<b-avatar badge="7" variant="primary" badge-variant="dark"></b-avatar>
<b-avatar badge-variant="info" src="https://placekitten.com/300/300">
<template v-slot:badge><b-icon icon="star-fill"></b-badge></template>
</b-avatar>
</div>
</template>

<!-- b-avatar-badge-content.vue -->
```

### Badge positioning

By default the badge appears on the bottom right of the avatar. You can use the `badge-top` and
`badge-right` boolean props to switch the sides. Combine both props to move the badge to the top
right of the avatar.

```html
<template>
<div>
<b-avatar badge></b-avatar>
<b-avatar badge badge-left></b-avatar>
<b-avatar badge badge-top></b-avatar>
<b-avatar badge badge-left badge-top></b-avatar>
</div>
</template>

<!-- b-avatar-badge-position.vue -->
```

Use the `badge-offset` prop to control the offset of the badge. The `badge-offset` must be a valid
CSS length string (i.e. `'2px'`, `'-2px'`, `'0.5em'`, etc.). Positive values will move the badge
inward, while negative values will move the badge outward.

```html
<template>
<div>
<b-avatar badge></b-avatar>
<b-avatar badge badge-offset="-0.5em"></b-avatar>
<b-avatar badge badge-offset="-2px"></b-avatar>
<b-avatar badge badge-offset="2px"></b-avatar>
<b-avatar badge badge-top></b-avatar>
<b-avatar badge badge-top badge-offset="-0.5em"></b-avatar>
<b-avatar badge badge-top badge-offset="-2px"></b-avatar>
<b-avatar badge badge-top badge-offset="2px"></b-avatar>
</div>
</template>

<!-- b-avatar-badge-offset.vue -->
```

## Accessibility

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

While the `click` event is emitted regardless if the `button`, `href`, or `to` props are set, it is
highly recommended to use the `button` prop when the click event should trigger an action (or use
Expand Down
39 changes: 33 additions & 6 deletions src/components/avatar/_avatar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
max-width: 100%;
max-height: auto;
text-align: center;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
overflow: visible;
position: relative;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;

Expand All @@ -38,15 +37,30 @@
pointer-events: none;
}

> span {
.b-avatar-custom,
.b-avatar-text {
border-radius: inherit;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
}

.b-icon {
.b-avatar-text {
text-transform: uppercase;
white-space: nowrap;
align-items: center;
}

.b-avatar-custom {
vertical-align: middle;
}

> .b-icon {
width: 60%;
height: auto;
max-width: 100%;
max-height: auto;
}

img {
Expand All @@ -56,4 +70,17 @@
max-height: auto;
border-radius: inherit;
}

.b-avatar-badge {
// Positioning will be handled via inline styles
position: absolute;
min-height: 1.5em;
min-width: 1.5em;
padding: 0.25em;
line-height: 1;
border-radius: 10em;
font-size: 70%;
font-weight: 700;
z-index: 5;
}
}
60 changes: 55 additions & 5 deletions src/components/avatar/avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const CLASS_NAME = 'b-avatar'
const RX_NUMBER = /^[0-9]*\.?[0-9]+$/

const FONT_SIZE_SCALE = 0.4
const BADGE_FONT_SIZE_SCALE = FONT_SIZE_SCALE * 0.7

const DEFAULT_SIZES = {
sm: '1.5em',
Expand Down Expand Up @@ -112,6 +113,26 @@ const props = {
type: String,
default: 'button'
},
badge: {
type: [Boolean, String],
default: false
},
badgeVariant: {
type: String,
default: () => getComponentConfig(NAME, 'badgeVariant')
},
badgeTop: {
type: Boolean,
default: false
},
badgeLeft: {
type: Boolean,
default: false
},
badgeOffset: {
type: String,
default: '0px'
},
...linkProps,
ariaLabel: {
type: String
Expand Down Expand Up @@ -149,6 +170,17 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
fontSize() {
const size = this.computedSize
return size ? `calc(${size} * ${FONT_SIZE_SCALE})` : null
},
badgeStyle() {
const { computedSize: size, badgeTop, badgeLeft, badgeOffset } = this
const offset = badgeOffset || '0px'
return {
fontSize: size ? `calc(${size} * ${BADGE_FONT_SIZE_SCALE} )` : null,
top: badgeTop ? offset : null,
bottom: badgeTop ? null : offset,
left: badgeLeft ? offset : null,
right: badgeLeft ? null : offset
}
}
},
watch: {
Expand Down Expand Up @@ -178,7 +210,10 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
fontSize,
computedSize: size,
button: isButton,
buttonType: type
buttonType: type,
badge,
badgeVariant,
badgeStyle
} = this
const isBLink = !isButton && (this.href || this.to)
const tag = isButton ? BButton : isBLink ? BLink : 'span'
Expand All @@ -189,7 +224,7 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
let $content = null
if (this.hasNormalizedSlot('default')) {
// Default slot overrides props
$content = this.normalizeSlot('default')
$content = h('span', { staticClass: 'b-avatar-custom' }, [this.normalizeSlot('default')])
} else if (src) {
$content = h('img', { attrs: { src, alt }, on: { error: this.onImgError } })
} else if (icon) {
Expand All @@ -198,12 +233,27 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
attrs: { 'aria-hidden': 'true', alt }
})
} else if (text) {
$content = h('span', { style: { fontSize } }, text)
$content = h('span', { staticClass: 'b-avatar-text', style: { fontSize } }, [h('span', text)])
} else {
// Fallback default avatar content
$content = h(BIconPersonFill, { attrs: { 'aria-hidden': 'true', alt } })
}

let $badge = h()
const hasBadgeSlot = this.hasNormalizedSlot('badge')
if (badge || badge === '' || hasBadgeSlot) {
const badgeText = badge === true ? '' : badge
$badge = h(
'span',
{
staticClass: 'b-avatar-badge',
class: { [`badge-${badgeVariant}`]: !!badgeVariant },
style: badgeStyle
},
[hasBadgeSlot ? this.normalizeSlot('badge') : badgeText]
)
}

const componentData = {
staticClass: CLASS_NAME,
class: {
Expand All @@ -217,11 +267,11 @@ export const BAvatar = /*#__PURE__*/ Vue.extend({
disabled
},
style: { width: size, height: size },
attrs: { 'aria-label': ariaLabel },
attrs: { 'aria-label': ariaLabel || null },
props: isButton ? { variant, disabled, type } : isBLink ? pluckProps(linkProps, this) : {},
on: isBLink || isButton ? { click: this.onClick } : {}
}

return h(tag, componentData, [$content])
return h(tag, componentData, [$content, $badge])
}
})
37 changes: 37 additions & 0 deletions src/components/avatar/avatar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,41 @@ describe('avatar', () => {
expect(wrapper8.attributes('style')).toEqual('width: 36px; height: 36px;')
wrapper8.destroy()
})

it('should have expected structure when prop badge is set', async () => {
const wrapper = mount(BAvatar, {
propsData: {
badge: true
}
})
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()

const $badge = wrapper.find('.b-avatar-badge')
expect($badge.exists()).toBe(true)
expect($badge.classes()).toContain('badge-primary')
expect($badge.text()).toEqual('')

wrapper.setProps({
badge: 'FOO'
})
await waitNT(wrapper.vm)
expect($badge.classes()).toContain('badge-primary')
expect($badge.text()).toEqual('FOO')

wrapper.setProps({
badgeVariant: 'info'
})
await waitNT(wrapper.vm)
expect($badge.classes()).not.toContain('badge-primary')
expect($badge.classes()).toContain('badge-info')
expect($badge.text()).toEqual('FOO')

wrapper.destroy()
})
})
31 changes: 31 additions & 0 deletions src/components/avatar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,43 @@
{
"prop": "rounded",
"description": "Specifies the type of rounding to apply to the avatar. The `square` prop takes precedence. Refer to the documentation for details"
},
{
"prop": "badge",
"version": "2.12.0",
"description": "When `true` shows an empty badge on the avatar, alternatively set to a string for content in the badge"
},
{
"prop": "badgeVariant",
"version": "2.12.0",
"settings": true,
"description": "Applies one of the Bootstrap theme color variants to the badge"
},
{
"prop": "badgeTop",
"version": "2.12.0",
"description": "When `true` places the badge at the top instead of the bottom"
},
{
"prop": "badgeLeft",
"version": "2.12.0",
"description": "When `true` places the badge at the left instead of the right"
},
{
"prop": "badgeOffset",
"version": "2.12.0",
"description": "CSS length to offset the badge. Positive values move the badge inwards, while negative values move the badge outwards"
}
],
"slots": [
{
"name": "default",
"description": "Content to place in the avatar. Overrides props `text`, `src`, and `icon-name`"
},
{
"name": "badge",
"version": "2.12.0",
"description": "Content to place in the avatars optional badge. Overrides the `badge` prop"
}
],
"events": [
Expand Down
2 changes: 1 addition & 1 deletion src/components/time/_time.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
}

.b-time-ampm {
margin-left: .5rem;
margin-left: 0.5rem;
}
}
3 changes: 2 additions & 1 deletion src/utils/config-defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ export default deepFreeze({
variant: 'info'
},
BAvatar: {
variant: 'secondary'
variant: 'secondary',
badgeVariant: 'primary'
},
BBadge: {
variant: 'secondary'
Expand Down

0 comments on commit a2e465b

Please sign in to comment.