feat(v3/avatar): add avatar group (#4062)

* feat(avatar): add avatar group

* refactor: update

* refactor: update

Co-authored-by: tangjinzhou <415800467@qq.com>
This commit is contained in:
John 2021-05-12 19:56:23 +08:00 committed by GitHub
parent 49ad768432
commit 0984951845
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 384 additions and 12 deletions

View File

@ -0,0 +1,21 @@
import { onMounted, onUnmounted, Ref, ref } from 'vue';
import ResponsiveObserve, { ScreenMap } from '../../_util/responsiveObserve';
function useBreakpoint(): Ref<ScreenMap> {
const screens = ref<ScreenMap>({});
let token = null;
onMounted(() => {
token = ResponsiveObserve.subscribe(supportScreens => {
screens.value = supportScreens;
});
});
onUnmounted(() => {
ResponsiveObserve.unsubscribe(token);
});
return screens;
}
export default useBreakpoint;

View File

@ -1,6 +1,7 @@
export type Breakpoint = 'xxl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs';
export type BreakpointMap = Partial<Record<Breakpoint, string>>;
export type ScreenMap = Partial<Record<Breakpoint, boolean>>;
export type ScreenSizeMap = Partial<Record<Breakpoint, number>>;
export const responsiveArray: Breakpoint[] = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs'];

View File

@ -1,5 +1,6 @@
import { tuple, VueNode } from '../_util/type';
import {
computed,
CSSProperties,
defineComponent,
ExtractPropTypes,
@ -14,20 +15,25 @@ import {
import { defaultConfigProvider } from '../config-provider';
import { getPropsSlot } from '../_util/props-util';
import PropTypes from '../_util/vue-types';
import useBreakpoint from '../_util/hooks/useBreakpoint';
import { Breakpoint, responsiveArray, ScreenSizeMap } from '../_util/responsiveObserve';
const avatarProps = {
export type AvatarSize = 'large' | 'small' | 'default' | number | ScreenSizeMap;
export const avatarProps = {
prefixCls: PropTypes.string,
shape: PropTypes.oneOf(tuple('circle', 'square')),
size: {
type: [Number, String] as PropType<'large' | 'small' | 'default' | number>,
default: 'default',
type: [Number, String, Object] as PropType<AvatarSize>,
default: (): AvatarSize => 'default',
},
src: PropTypes.string,
/** Srcset of image avatar */
srcset: PropTypes.string,
icon: PropTypes.VNodeChild,
alt: PropTypes.string,
gap: Number,
gap: PropTypes.number,
draggable: PropTypes.bool,
loadError: {
type: Function as PropType<() => boolean>,
},
@ -38,7 +44,7 @@ export type AvatarProps = Partial<ExtractPropTypes<typeof avatarProps>>;
const Avatar = defineComponent({
name: 'AAvatar',
props: avatarProps,
setup(props, { slots }) {
setup(props, { slots, attrs }) {
const isImgExist = ref(true);
const isMounted = ref(false);
const scale = ref(1);
@ -48,6 +54,34 @@ const Avatar = defineComponent({
const configProvider = inject('configProvider', defaultConfigProvider);
const groupSize = inject(
'SizeProvider',
computed(() => 'default'),
);
const screens = useBreakpoint();
const responsiveSize = computed(() => {
if (typeof props.size !== 'object') {
return undefined;
}
const currentBreakpoint: Breakpoint = responsiveArray.find(screen => screens.value[screen])!;
const currentSize = props.size[currentBreakpoint];
return currentSize;
});
const responsiveSizeStyle = (hasIcon: boolean) => {
if (responsiveSize.value) {
return {
width: `${responsiveSize.value}px`,
height: `${responsiveSize.value}px`,
lineHeight: `${responsiveSize.value}px`,
fontSize: `${hasIcon ? responsiveSize.value / 2 : 18}px`,
};
}
return {};
};
const setScale = () => {
if (!avatarChildrenRef.value || !avatarNodeRef.value) {
return;
@ -96,11 +130,19 @@ const Avatar = defineComponent({
});
return () => {
const { prefixCls: customizePrefixCls, shape, size, src, alt, srcset } = props;
const {
prefixCls: customizePrefixCls,
shape,
size: customSize,
src,
alt,
srcset,
draggable,
} = props;
const icon = getPropsSlot(slots, props, 'icon');
const getPrefixCls = configProvider.getPrefixCls;
const prefixCls = getPrefixCls('avatar', customizePrefixCls);
const size = customSize === 'default' ? groupSize.value : customSize;
const classString = {
[prefixCls]: true,
[`${prefixCls}-lg`]: size === 'large',
@ -122,7 +164,15 @@ const Avatar = defineComponent({
let children: VueNode = slots.default?.();
if (src && isImgExist.value) {
children = <img src={src} srcset={srcset} onError={handleImgLoadError} alt={alt} />;
children = (
<img
draggable={draggable}
src={src}
srcset={srcset}
onError={handleImgLoadError}
alt={alt}
/>
);
} else if (icon) {
children = icon;
} else {
@ -159,7 +209,15 @@ const Avatar = defineComponent({
}
}
return (
<span ref={avatarNodeRef} class={classString} style={sizeStyle}>
<span
ref={avatarNodeRef}
class={classString}
style={{
...sizeStyle,
...responsiveSizeStyle(!!icon),
...(attrs.style as CSSProperties),
}}
>
{children}
</span>
);

104
components/avatar/Group.tsx Normal file
View File

@ -0,0 +1,104 @@
import toArray from 'lodash/toArray';
import { cloneElement } from '../_util/vnode';
import { defaultConfigProvider } from '../config-provider';
import Avatar, { avatarProps } from './Avatar';
import Popover from '../popover';
import {
computed,
defineComponent,
inject,
provide,
PropType,
ExtractPropTypes,
CSSProperties,
} from 'vue';
import PropTypes from '../_util/vue-types';
import { getPropsSlot } from '../_util/props-util';
import { tuple } from '../_util/type';
const groupProps = {
prefixCls: PropTypes.string,
maxCount: PropTypes.number,
maxStyle: {
type: Object as PropType<CSSProperties>,
default: () => ({} as CSSProperties),
},
maxPopoverPlacement: PropTypes.oneOf(tuple('top', 'bottom')).def('top'),
/*
* Size of avatar, options: `large`, `small`, `default`
* or a custom number size
* */
size: avatarProps.size,
};
export type AvatarGroupProps = Partial<ExtractPropTypes<typeof groupProps>>;
const Group = defineComponent({
name: 'AAvatarGroup',
props: groupProps,
inheritAttrs: false,
setup(props, { slots, attrs }) {
const configProvider = inject('configProvider', defaultConfigProvider);
provide(
'SizeProvider',
computed(() => props.size || configProvider.componentSize),
);
return () => {
const {
prefixCls: customizePrefixCls,
maxPopoverPlacement = 'top',
maxCount,
maxStyle,
} = props;
const { getPrefixCls } = configProvider;
const prefixCls = getPrefixCls('avatar-group', customizePrefixCls);
const className = attrs.class as string;
const cls = {
[prefixCls]: true,
[className]: className !== undefined,
};
const children = getPropsSlot(slots, props);
const childrenWithProps = toArray(children).map((child, index) =>
cloneElement(child, {
key: `avatar-key-${index}`,
}),
);
const numOfChildren = childrenWithProps.length;
if (maxCount && maxCount < numOfChildren) {
const childrenShow = childrenWithProps.slice(0, maxCount);
const childrenHidden = childrenWithProps.slice(maxCount, numOfChildren);
childrenShow.push(
<Popover
key="avatar-popover-key"
content={childrenHidden}
trigger="hover"
placement={maxPopoverPlacement}
overlayClassName={`${prefixCls}-popover`}
>
<Avatar style={maxStyle}>{`+${numOfChildren - maxCount}`}</Avatar>
</Popover>,
);
return (
<div class={cls} style={attrs.style}>
{childrenShow}
</div>
);
}
return (
<div class={cls} style={attrs.style}>
{childrenWithProps}
</div>
);
};
},
});
export default Group;

View File

@ -1,9 +1,14 @@
import { mount } from '@vue/test-utils';
import { asyncExpect } from '@/tests/utils';
import Avatar from '..';
import useBreakpoint from '../../_util/hooks/useBreakpoint';
jest.mock('../../_util/hooks/useBreakpoint');
describe('Avatar Render', () => {
let originOffsetWidth;
const sizes = { xs: 24, sm: 32, md: 40, lg: 64, xl: 80, xxl: 100 };
beforeAll(() => {
// Mock offsetHeight
originOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth').get;
@ -124,4 +129,83 @@ describe('Avatar Render', () => {
expect(wrapper.findAll('.ant-avatar-image').length).toBe(1);
}, 0);
});
it('should calculate scale of avatar children correctly', async () => {
let wrapper = mount({
render() {
return <Avatar>Avatar</Avatar>;
},
});
await asyncExpect(() => {
expect(wrapper.find('.ant-avatar-string')).toMatchSnapshot();
}, 0);
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
get() {
if (this.className === 'ant-avatar-string') {
return 100;
}
return 40;
},
});
wrapper = mount({
render() {
return <Avatar>xx</Avatar>;
},
});
await asyncExpect(() => {
expect(wrapper.find('.ant-avatar-string')).toMatchSnapshot();
}, 0);
});
it('should calculate scale of avatar children correctly with gap', async () => {
const wrapper = mount({
render() {
return <Avatar gap={2}>Avatar</Avatar>;
},
});
await asyncExpect(() => {
expect(wrapper.html()).toMatchSnapshot();
}, 0);
});
Object.entries(sizes).forEach(([key, value]) => {
it(`adjusts component size to ${value} when window size is ${key}`, async () => {
useBreakpoint.mockReturnValue({ value: { [key]: true } });
const wrapper = mount({
render() {
return <Avatar size={sizes} />;
},
});
await asyncExpect(() => {
expect(wrapper.html()).toMatchSnapshot();
}, 0);
});
});
it('fallback', async () => {
const div = global.document.createElement('div');
global.document.body.appendChild(div);
const wrapper = mount(
{
render() {
return (
<Avatar shape="circle" src="http://error.url">
A
</Avatar>
);
},
},
{ attachTo: div },
);
await asyncExpect(async () => {
await wrapper.find('img').trigger('error');
expect(wrapper.html()).toMatchSnapshot();
wrapper.unmount();
global.document.body.removeChild(div);
}, 0);
});
});

View File

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Avatar Render adjusts component size to 24 when window size is xs 1`] = `<span class="ant-avatar" style="width: 24px; height: 24px; line-height: 24px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render adjusts component size to 32 when window size is sm 1`] = `<span class="ant-avatar" style="width: 32px; height: 32px; line-height: 32px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render adjusts component size to 40 when window size is md 1`] = `<span class="ant-avatar" style="width: 40px; height: 40px; line-height: 40px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render adjusts component size to 64 when window size is lg 1`] = `<span class="ant-avatar" style="width: 64px; height: 64px; line-height: 64px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render adjusts component size to 80 when window size is xl 1`] = `<span class="ant-avatar" style="width: 80px; height: 80px; line-height: 80px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render adjusts component size to 100 when window size is xxl 1`] = `<span class="ant-avatar" style="width: 100px; height: 100px; line-height: 100px; font-size: 18px;"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);"><!----></span></span>`;
exports[`Avatar Render fallback 1`] = `<span class="ant-avatar ant-avatar-circle"><span class="ant-avatar-string" style="transform: scale(0.32) translateX(-50%);">A</span></span>`;
exports[`Avatar Render should calculate scale of avatar children correctly 1`] = `
DOMWrapper {
"wrapperElement": <span
class="ant-avatar-string"
style="transform: scale(0.72) translateX(-50%);"
>
Avatar
</span>,
}
`;
exports[`Avatar Render should calculate scale of avatar children correctly 2`] = `
DOMWrapper {
"wrapperElement": <span
class="ant-avatar-string"
style="transform: scale(0.32) translateX(-50%);"
>
xx
</span>,
}
`;
exports[`Avatar Render should calculate scale of avatar children correctly with gap 1`] = `<span class="ant-avatar"><span class="ant-avatar-string" style="transform: scale(0.36) translateX(-50%);">Avatar</span></span>`;

View File

@ -1,6 +1,20 @@
import { App } from 'vue';
import Avatar from './Avatar';
import { withInstall } from '../_util/type';
import Group from './Group';
export { AvatarProps } from './Avatar';
export { AvatarProps, AvatarSize } from './Avatar';
export { AvatarGroupProps } from './Group';
export default withInstall(Avatar);
Avatar.Group = Group;
/* istanbul ignore next */
Avatar.install = function(app: App) {
app.component(Avatar.name, Avatar);
app.component(Group.name, Group);
return app;
};
export default Avatar as typeof Avatar &
Plugin & {
readonly Group: typeof Group;
};

View File

@ -0,0 +1,17 @@
.@{avatar-prefix-cls}-group {
display: inline-flex;
.@{avatar-prefix-cls} {
border: 1px solid @avatar-group-border-color;
&:not(:first-child) {
margin-left: @avatar-group-overlapping;
}
}
&-popover {
.@{ant-prefix}-avatar + .@{ant-prefix}-avatar {
margin-left: @avatar-group-space;
}
}
}

View File

@ -57,3 +57,5 @@
font-size: @font-size;
}
}
@import './group';

View File

@ -14,6 +14,31 @@ export interface CSPConfig {
export { RenderEmptyHandler };
export interface ConfigConsumerProps {
getTargetContainer?: () => HTMLElement;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
rootPrefixCls?: string;
getPrefixCls: (suffixCls?: string, customizePrefixCls?: string) => string;
renderEmpty: RenderEmptyHandler;
transformCellText?: (tableProps: TransformCellTextProps) => any;
csp?: CSPConfig;
autoInsertSpaceInButton?: boolean;
input?: {
autoComplete?: string;
};
locale?: Locale;
pageHeader?: {
ghost: boolean;
};
componentSize?: SizeType;
direction?: 'ltr' | 'rtl';
space?: {
size?: SizeType | number;
};
virtual?: boolean;
dropdownMatchSelectWidth?: boolean;
}
export const configConsumerProps = [
'getTargetContainer',
'getPopupContainer',

View File

@ -644,6 +644,9 @@
@avatar-bg: #ccc;
@avatar-color: #fff;
@avatar-border-radius: @border-radius-base;
@avatar-group-overlapping: -8px;
@avatar-group-space: 3px;
@avatar-group-border-color: #fff;
// Switch
// ---