feat(components): [avatar-group] new component (#23211)

* feat(components): [avatar-group] new component

* feat: update

* feat: update

* feat: update

* docs: add an example

* refactor: remove unnecessary computed

* refactor: extract props to avatar-group-props

* feat: update

* fix: handle the rendering issue of hiddenAvatars

* style: use inline-flex instead of flex

* test: update test case

* docs: update the default values of size and shape

* refactor: apply rabbit comment

* refactor: apply rabbit comment

* chore: update description

* style: use getCssVar

* style: css variables take effect on collapse-avatars
This commit is contained in:
Rainbow
2026-01-05 09:18:22 +08:00
committed by GitHub
parent 71e5216f17
commit 1e15f9d661
17 changed files with 374 additions and 19 deletions

View File

@@ -47,28 +47,56 @@ avatar/fit
:::
## API
## Avatar Group ^(2.13.1)
### Attributes
Displayed as a avatar group.
:::demo Use tag `<el-avatar-group>` to group your avatars.
avatar/group
:::
## Avatar API
### Avatar Attributes
| Name | Description | Type | Default |
| ------- | --------------------------------------------------------- | ----------------------------------------------------------------- | ------- |
| icon | representation type to icon, more info on icon component. | ^[string] / ^[Component] | — |
| size | avatar size. | ^[number] / ^[enum]`'large' \| 'default' \| 'small'` | default |
| shape | avatar shape. | ^[enum]`'circle' \| 'square'` | circle |
| size | avatar size. | ^[number] / ^[enum]`'large' \| 'default' \| 'small'` | |
| shape | avatar shape. | ^[enum]`'circle' \| 'square'` | |
| src | the source of the image for an image avatar. | `string` | — |
| src-set | native attribute `srcset` of image avatar. | `string` | — |
| alt | native attribute `alt` of image avatar. | `string` | — |
| fit | set how the image fit its container for an image avatar. | ^[enum]`'fill' \| 'contain' \| 'cover' \| 'none' \| 'scale-down'` | cover |
### Events
### Avatar Events
| Name | Description | Type |
| ----- | ------------------------------ | ------------------------------- |
| error | trigger when image load error. | ^[Function]`(e: Event) => void` |
### Slots
### Avatar Slots
| Name | Description |
| ------- | ------------------------- |
| default | customize avatar content. |
## AvatarGroup API ^(2.13.1)
### AvatarGroup Attributes
| Name | Description | Type | Default |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| size | control the size of avatars in this avatar-group | ^[number] / ^[enum]`'large' \| 'default' \| 'small'` | — |
| shape | control the shape of avatars in this avatar-group | ^[enum]`'circle' \| 'square'` | — |
| collapse-avatars | whether to collapse avatars | ^[boolean] | false |
| collapse-avatars-tooltip | whether show all collapsed avatars when mouse hover text of the collapse-avatar. To use this, `collapse-avatars` must be true | ^[boolean] | false |
| max-collapse-avatars | the max avatars number to be shown. To use this, `collapse-avatars` must be true | ^[number] | 1 |
| effect | tooltip theme, built-in theme: `dark` / `light` | ^[enum]`'dark' \| 'light'` / ^[string] | light |
| placement | placement of tooltip | ^[enum]`'top' \| 'top-start' \| 'top-end' \| 'bottom' \| 'bottom-start' \| 'bottom-end' \| 'left' \| 'left-start' \| 'left-end' \| 'right' \| 'right-start' \| 'right-end'` | top |
| popper-class | custom class name for tooltip | ^[string] | '' |
| popper-style | custom style for tooltip | ^[string] / ^[object] | — |
| collapse-class | custom class name for the collapse-avatar | ^[string] | '' |
| collapse-style | custom style for the collapse-avatar | ^[string] / ^[object] | — |

View File

@@ -0,0 +1,51 @@
<template>
<div class="m-4">
<p>default</p>
<el-avatar-group>
<el-avatar v-for="number in 5" :key="number" :src="circleUrl" />
</el-avatar-group>
</div>
<div class="m-4">
<p>use collapse-avatars</p>
<el-avatar-group collapse-avatars>
<el-avatar v-for="number in 5" :key="number" :src="circleUrl" />
</el-avatar-group>
</div>
<div class="m-4">
<p>use collapse-class and collapse-style</p>
<el-avatar-group
collapse-avatars
:collapse-style="{ 'background-color': '#d9ecff' }"
collapse-class="my-collapse-avatar"
>
<el-avatar v-for="number in 5" :key="number" :src="circleUrl" />
</el-avatar-group>
</div>
<div class="m-4">
<p>use max-collapse-avatars</p>
<el-avatar-group collapse-avatars :max-collapse-avatars="3">
<el-avatar v-for="number in 5" :key="number" :src="circleUrl" />
</el-avatar-group>
</div>
<div class="m-4">
<p>use collapse-avatars-tooltip</p>
<el-avatar-group
collapse-avatars
:max-collapse-avatars="3"
collapse-avatars-tooltip
>
<el-avatar v-for="number in 5" :key="number" :src="circleUrl" />
</el-avatar-group>
</div>
</template>
<script lang="ts" setup>
const circleUrl =
'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
</script>
<style>
.my-collapse-avatar {
color: #409eff;
}
</style>

View File

@@ -0,0 +1,3 @@
import '@element-plus/components/base/style/css'
import '@element-plus/theme-chalk/el-avatar-group.css'
import '@element-plus/components/tooltip/style/css'

View File

@@ -0,0 +1,3 @@
import '@element-plus/components/base/style'
import '@element-plus/theme-chalk/src/avatar-group.scss'
import '@element-plus/components/tooltip/style'

View File

@@ -9,6 +9,7 @@ import {
} from '@element-plus/test-utils/mock'
import { stableLoad } from '@element-plus/test-utils/stable-load'
import Avatar from '../src/avatar.vue'
import AvatarGroup from '../src/avatar-group'
describe('Avatar.vue', () => {
mockImageEvent()
@@ -96,3 +97,61 @@ describe('Avatar.vue', () => {
expect(wrapper.find('img').exists()).toBe(true)
})
})
describe('Avatar Group', () => {
test('render test', () => {
const wrapper = mount(
<AvatarGroup size="small" shape="square">
<Avatar />
<Avatar size="large" shape="circle"></Avatar>
<Avatar />
<Avatar size="large" shape="circle"></Avatar>
<Avatar />
</AvatarGroup>
)
expect(wrapper.findAll('.el-avatar').length).toBe(5)
expect(wrapper.findAll('.el-avatar--small').length).toBe(3)
expect(wrapper.findAll('.el-avatar--large').length).toBe(2)
expect(wrapper.findAll('.el-avatar--circle').length).toBe(2)
expect(wrapper.findAll('.el-avatar--square').length).toBe(3)
})
test('collapse-class & collapse-style', () => {
const collapseStyle = 'background-color: red;'
const wrapper = mount(
<AvatarGroup
collapseAvatars
collapseClass="collapse-avatar"
collapseStyle={collapseStyle}
>
<Avatar />
<Avatar />
</AvatarGroup>
)
const collapseAvatar = wrapper.findAll('.el-avatar')[1]
expect(collapseAvatar.text()).toBe('+ 1')
expect(collapseAvatar.classes()).toContain('collapse-avatar')
expect(collapseAvatar.attributes('style')).toContain(
'background-color: red;'
)
})
test('collapse-avatars-tooltip', async () => {
const wrapper = mount(
<AvatarGroup collapseAvatars collapseAvatarsTooltip>
<Avatar />
<Avatar />
</AvatarGroup>
)
const collapseAvatar = wrapper.findAll('.el-avatar')[1]
await collapseAvatar.trigger('mouseenter')
await nextTick()
const tooltip = wrapper.findComponent({ name: 'ElTooltip' })
expect(tooltip.exists()).toBe(true)
expect(tooltip.html()).toContain('el-avatar')
})
})

View File

@@ -1,10 +1,19 @@
import { withInstall } from '@element-plus/utils'
import { withInstall, withNoopInstall } from '@element-plus/utils'
import Avatar from './src/avatar.vue'
import AvatarGroup from './src/avatar-group'
import type { SFCWithInstall } from '@element-plus/utils'
export const ElAvatar: SFCWithInstall<typeof Avatar> = withInstall(Avatar)
export const ElAvatar: SFCWithInstall<typeof Avatar> & {
AvatarGroup: typeof AvatarGroup
} = withInstall(Avatar, {
AvatarGroup,
})
export const ElAvatarGroup: SFCWithInstall<typeof AvatarGroup> =
withNoopInstall(AvatarGroup)
export default ElAvatar
export * from './src/avatar'
export type { AvatarInstance } from './src/instance'
export * from './src/constants'
export * from './src/avatar-group-props'
export type { AvatarInstance, AvatarGroupInstance } from './src/instance'

View File

@@ -0,0 +1,70 @@
import { placements } from '@popperjs/core'
import { useTooltipContentProps } from '@element-plus/components/tooltip'
import { buildProps, definePropType } from '@element-plus/utils'
import { avatarProps } from './avatar'
import type { ExtractPropTypes, ExtractPublicPropTypes, StyleValue } from 'vue'
import type { Placement, PopperEffect } from '@element-plus/components/popper'
export const avatarGroupProps = buildProps({
/**
* @description control the size of avatars in this avatar-group
*/
size: avatarProps.size,
/**
* @description control the shape of avatars in this avatar-group
*/
shape: avatarProps.shape,
/**
* @description whether to collapse avatars
*/
collapseAvatars: Boolean,
/**
* @description whether show all collapsed avatars when mouse hover text of the collapse-avatar. To use this, `collapse-avatars` must be true
*/
collapseAvatarsTooltip: Boolean,
/**
* @description the max avatars number to be shown. To use this, `collapse-avatars` must be true
*/
maxCollapseAvatars: {
type: Number,
default: 1,
},
/**
* @description tooltip theme, built-in theme: `dark` / `light`
*/
effect: {
type: definePropType<PopperEffect>(String),
default: 'light',
},
/**
* @description placement of tooltip
*/
placement: {
type: definePropType<Placement>(String),
values: placements,
default: 'top',
},
/**
* @description custom class name for tooltip
*/
popperClass: useTooltipContentProps.popperClass,
/**
* @description custom style for tooltip
*/
popperStyle: useTooltipContentProps.popperStyle,
/**
* @description custom class name for the collapse-avatar
*/
collapseClass: String,
/**
* @description custom style for the collapse-avatar
*/
collapseStyle: {
type: definePropType<StyleValue>([String, Array, Object]),
},
} as const)
export type AvatarGroupProps = ExtractPropTypes<typeof avatarGroupProps>
export type AvatarGroupPropsPublic = ExtractPublicPropTypes<
typeof avatarGroupProps
>

View File

@@ -0,0 +1,77 @@
import {
cloneVNode,
defineComponent,
isVNode,
provide,
reactive,
toRef,
} from 'vue'
import { flattedChildren } from '@element-plus/utils'
import ElTooltip from '@element-plus/components/tooltip'
import { useNamespace } from '@element-plus/hooks'
import ElAvatar from './avatar.vue'
import { avatarGroupContextKey } from './constants'
import { avatarGroupProps } from './avatar-group-props'
export default defineComponent({
name: 'ElAvatarGroup',
props: avatarGroupProps,
setup(props, { slots }) {
const ns = useNamespace('avatar-group')
provide(
avatarGroupContextKey,
reactive({
size: toRef(props, 'size'),
shape: toRef(props, 'shape'),
})
)
return () => {
const avatars = flattedChildren(slots.default?.() ?? [])
let visibleAvatars = avatars
const showCollapseAvatar =
props.collapseAvatars && avatars.length > props.maxCollapseAvatars
if (showCollapseAvatar) {
visibleAvatars = avatars.slice(0, props.maxCollapseAvatars)
const hiddenAvatars = avatars.slice(props.maxCollapseAvatars)
visibleAvatars.push(
<ElTooltip
popperClass={props.popperClass}
popperStyle={props.popperStyle}
placement={props.placement}
effect={props.effect}
disabled={!props.collapseAvatarsTooltip}
>
{{
default: () => (
<ElAvatar
size={props.size}
shape={props.shape}
class={props.collapseClass}
style={props.collapseStyle}
>
+ {hiddenAvatars.length}
</ElAvatar>
),
content: () => (
<div class={ns.e('collapse-avatars')}>
{hiddenAvatars.map((node, idx) =>
isVNode(node)
? cloneVNode(node, { key: node.key ?? idx })
: node
)}
</div>
),
}}
</ElTooltip>
)
}
return <div class={ns.b()}>{visibleAvatars}</div>
}
},
})

View File

@@ -19,7 +19,6 @@ export const avatarProps = buildProps({
size: {
type: [Number, String],
values: componentSizes,
default: '',
validator: (val: unknown): val is number => isNumber(val),
},
/**
@@ -28,7 +27,6 @@ export const avatarProps = buildProps({
shape: {
type: String,
values: ['circle', 'square'],
default: 'circle',
},
/**
* @description representation type to icon, more info on icon component.

View File

@@ -16,11 +16,12 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { ElIcon } from '@element-plus/components/icon'
import { useNamespace } from '@element-plus/hooks'
import { addUnit, isNumber, isString } from '@element-plus/utils'
import { avatarEmits, avatarProps } from './avatar'
import { avatarGroupContextKey } from './constants'
import type { CSSProperties } from 'vue'
@@ -31,24 +32,30 @@ defineOptions({
const props = defineProps(avatarProps)
const emit = defineEmits(avatarEmits)
const avatarGroupContext = inject(avatarGroupContextKey, undefined)
const ns = useNamespace('avatar')
const hasLoadError = ref(false)
const size = computed(() => props.size ?? avatarGroupContext?.size)
const shape = computed(
() => props.shape ?? avatarGroupContext?.shape ?? 'circle'
)
const avatarClass = computed(() => {
const { size, icon, shape } = props
const { icon } = props
const classList = [ns.b()]
if (isString(size)) classList.push(ns.m(size))
if (isString(size.value)) classList.push(ns.m(size.value))
if (icon) classList.push(ns.m('icon'))
if (shape) classList.push(ns.m(shape))
if (shape.value) classList.push(ns.m(shape.value))
return classList
})
const sizeStyle = computed(() => {
const { size } = props
return isNumber(size)
return isNumber(size.value)
? (ns.cssVarBlock({
size: addUnit(size)!,
size: addUnit(size.value)!,
}) as CSSProperties)
: undefined
})

View File

@@ -0,0 +1,11 @@
import type { InjectionKey } from 'vue'
import type { AvatarProps } from './avatar'
export interface AvatarGroupContext {
size?: AvatarProps['size']
shape?: AvatarProps['shape']
}
export const avatarGroupContextKey: InjectionKey<AvatarGroupContext> = Symbol(
'avatarGroupContextKey'
)

View File

@@ -1,3 +1,5 @@
import type Avatar from './avatar.vue'
import type AvatarGroup from './avatar-group'
export type AvatarInstance = InstanceType<typeof Avatar> & unknown
export type AvatarGroupInstance = InstanceType<typeof AvatarGroup> & unknown

View File

@@ -1,7 +1,7 @@
import { ElAffix } from '@element-plus/components/affix'
import { ElAlert } from '@element-plus/components/alert'
import { ElAutocomplete } from '@element-plus/components/autocomplete'
import { ElAvatar } from '@element-plus/components/avatar'
import { ElAvatar, ElAvatarGroup } from '@element-plus/components/avatar'
import { ElBacktop } from '@element-plus/components/backtop'
import { ElBadge } from '@element-plus/components/badge'
import {
@@ -119,6 +119,7 @@ export default [
ElAutocomplete,
ElAutoResizer,
ElAvatar,
ElAvatarGroup,
ElBacktop,
ElBadge,
ElBreadcrumb,

View File

@@ -0,0 +1,25 @@
@use 'mixins/mixins' as *;
@use 'mixins/var' as *;
@use 'common/var' as *;
@include b(avatar-group) {
@include set-component-css-var('avatar-group', $avatar-group);
display: inline-flex;
.#{$namespace}-avatar {
border: 1px solid getCssVar('border-color-extra-light');
}
.#{$namespace}-avatar:not(:first-child) {
margin-left: getCssVar('avatar-group', 'item-gap');
}
@include e(collapse-avatars) {
@include set-component-css-var('avatar-group', $avatar-group);
.#{$namespace}-avatar:not(:first-child) {
margin-left: getCssVar('avatar-group', 'collapse-item-gap');
}
}
}

View File

@@ -1523,6 +1523,15 @@ $avatar-size: map.merge(
$avatar-size
);
$avatar-group: () !default;
$avatar-group: map.merge(
(
'item-gap': -8px,
'collapse-item-gap': 4px,
),
$avatar-group
);
// Empty
// css3 var in packages/theme-chalk/src/empty.scss
$empty: () !default;

View File

@@ -5,6 +5,7 @@
@use './aside.scss';
@use './autocomplete.scss';
@use './avatar.scss';
@use './avatar-group.scss';
@use './backtop.scss';
@use './badge.scss';
@use './breadcrumb-item.scss';

1
typings/global.d.ts vendored
View File

@@ -8,6 +8,7 @@ declare module 'vue' {
ElAutoResizer: typeof import('element-plus')['ElAutoResizer']
ElAutocomplete: typeof import('element-plus')['ElAutocomplete']
ElAvatar: typeof import('element-plus')['ElAvatar']
ElAvatarGroup: typeof import('element-plus')['ElAvatarGroup']
ElAnchor: typeof import('element-plus')['ElAnchor']
ElAnchorLink: typeof import('element-plus')['ElAnchorLink']
ElBacktop: typeof import('element-plus')['ElBacktop']