feat: add avatar group component (#6128)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.17.x

#### What this PR does / why we need it:

添加 AvatarGroup 组件,用于堆叠多个 Avatar 组件。

<img width="243" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/2d202e95-e735-4635-b16e-cdcf1f94f69a">
<img width="352" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/da4a293d-eb3f-40b4-94b2-10dcf54d3305">

#### Which issue(s) this PR fixes:

Fixes #6079 

#### Does this PR introduce a user-facing change?

```release-note
添加 AvatarGroup 组件,用于堆叠多个头像。
```
pull/6148/head
Ryan Wang 2024-06-26 10:24:48 +08:00 committed by GitHub
parent e4cce918f7
commit 8302c21bb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 139 additions and 47 deletions

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { Contributor } from "@halo-dev/api-client"; import type { Contributor } from "@halo-dev/api-client";
import { VAvatar } from "@halo-dev/components"; import { VAvatar, VAvatarGroup } from "@halo-dev/components";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
withDefaults( withDefaults(
@ -26,14 +26,14 @@ function handleRouteToUserDetail(contributor: Contributor) {
</script> </script>
<template> <template>
<VAvatar <VAvatarGroup size="xs" circle>
v-for="(contributor, contributorIndex) in contributors" <VAvatar
:key="contributorIndex" v-for="contributor in contributors"
v-tooltip="contributor.displayName" :key="contributor.name"
size="xs" v-tooltip="contributor.displayName"
:src="contributor.avatar" :src="contributor.avatar"
:alt="contributor.displayName" :alt="contributor.displayName"
circle @click="handleRouteToUserDetail(contributor)"
@click="handleRouteToUserDetail(contributor)" ></VAvatar>
></VAvatar> </VAvatarGroup>
</template> </template>

View File

@ -1,3 +1,4 @@
/* eslint-disable storybook/no-uninstalled-addons */
import type { StorybookConfig } from "@storybook/vue3-vite"; import type { StorybookConfig } from "@storybook/vue3-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
@ -15,5 +16,12 @@ const config: StorybookConfig = {
docs: { docs: {
autodocs: "tag", autodocs: "tag",
}, },
async viteFinal(config) {
const { mergeConfig } = await import("vite");
return mergeConfig(config, {
assetsInclude: ["/sb-preview/runtime.js"],
});
},
}; };
export default config; export default config;

View File

@ -1,26 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, inject, onMounted, ref, watch } from "vue";
import { IconErrorWarning } from "../../icons/icons"; import { IconErrorWarning } from "../../icons/icons";
import type { Size } from "./interface"; import { AvatarGroupContextInjectionKey, type AvatarProps } from "./interface";
const props = withDefaults( const props = withDefaults(defineProps<AvatarProps>(), {
defineProps<{ size: "md",
src?: string; circle: false,
alt?: string; });
size?: Size;
width?: string; const groupProps = inject(AvatarGroupContextInjectionKey);
height?: string;
circle?: boolean; const size = computed(() => groupProps?.size || props.size);
}>(), const circle = computed(() => groupProps?.circle || props.circle);
{ const width = computed(() => groupProps?.width || props.width);
src: undefined, const height = computed(() => groupProps?.height || props.height);
alt: undefined,
size: "md", console.log(groupProps);
width: undefined,
height: undefined,
circle: false,
}
);
const isLoading = ref(false); const isLoading = ref(false);
const error = ref(false); const error = ref(false);
@ -65,20 +60,20 @@ onMounted(async () => {
}); });
const classes = computed(() => { const classes = computed(() => {
const result = [`avatar-${props.circle ? "circle" : "square"}`]; const result = [`avatar-${circle.value ? "circle" : "square"}`];
if (props.size) { if (size.value) {
result.push(`avatar-${props.size}`); result.push(`avatar-${size.value}`);
} }
return result; return result;
}); });
const styles = computed(() => { const styles = computed(() => {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
if (props.width) { if (width.value) {
result.width = props.width; result.width = width.value;
} }
if (props.height) { if (height.value) {
result.height = props.height; result.height = height.value;
} }
return result; return result;
}); });

View File

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import { VAvatar, VAvatarGroup } from ".";
const meta: Meta<typeof VAvatarGroup> = {
title: "AvatarGroup",
component: VAvatarGroup,
tags: ["autodocs"],
render: (args) => ({
components: { VAvatarGroup, VAvatar },
setup() {
return { args };
},
template: `<VAvatarGroup v-bind="args">
<VAvatar src="https://avatar.iran.liara.run/public?id=1" />
<VAvatar src="https://avatar.iran.liara.run/public?id=2" />
<VAvatar src="https://avatar.iran.liara.run/public?id=3" />
<VAvatar src="https://avatar.iran.liara.run/public?id=4" />
</VAvatarGroup>`,
}),
argTypes: {
size: {
control: { type: "select" },
options: ["lg", "md", "sm", "xs"],
defaultValue: "md",
},
},
};
export default meta;
type Story = StoryObj<typeof VAvatarGroup>;
export const Default: Story = {
args: {},
};
export const Circle: Story = {
args: {
circle: true,
},
};

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { provide } from "vue";
import {
AvatarGroupContextInjectionKey,
type AvatarGroupProps,
} from "./interface";
const props = withDefaults(defineProps<AvatarGroupProps>(), {
size: "md",
circle: false,
});
provide(AvatarGroupContextInjectionKey, props);
</script>
<template>
<div class="avatar-group-wrapper">
<slot />
</div>
</template>
<style lang="scss">
.avatar-group-wrapper {
@apply -space-x-2.5 inline-flex;
> * {
@apply hover:z-10 ring-2 ring-white transition-all;
}
}
</style>

View File

@ -1 +1,2 @@
export { default as VAvatar } from "./Avatar.vue"; export { default as VAvatar } from "./Avatar.vue";
export { default as VAvatarGroup } from "./AvatarGroup.vue";

View File

@ -1 +1,17 @@
import type { InjectionKey } from "vue";
export type Size = "lg" | "md" | "sm" | "xs"; export type Size = "lg" | "md" | "sm" | "xs";
export interface AvatarProps {
src?: string;
alt?: string;
size?: Size;
width?: string;
height?: string;
circle?: boolean;
}
export type AvatarGroupProps = Omit<AvatarProps, "src" | "alt">;
export const AvatarGroupContextInjectionKey: InjectionKey<AvatarGroupProps> =
Symbol("avatar-group-context");

View File

@ -7,6 +7,7 @@ import {
IconTimerLine, IconTimerLine,
Toast, Toast,
VAvatar, VAvatar,
VAvatarGroup,
VDropdownDivider, VDropdownDivider,
VDropdownItem, VDropdownItem,
VEntity, VEntity,
@ -179,15 +180,15 @@ function handleUnpublish() {
<template #end> <template #end>
<VEntityField> <VEntityField>
<template #description> <template #description>
<VAvatar <VAvatarGroup size="xs" circle>
v-for="{ name, avatar, displayName } in post.contributors" <VAvatar
:key="name" v-for="{ name, avatar, displayName } in post.contributors"
v-tooltip="displayName" :key="name"
size="xs" v-tooltip="displayName"
:src="avatar" :src="avatar"
:alt="displayName" :alt="displayName"
circle ></VAvatar>
></VAvatar> </VAvatarGroup>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField :description="publishStatus"> <VEntityField :description="publishStatus">