mirror of
https://gitee.com/ant-design-vue/ant-design-vue.git
synced 2024-12-04 21:18:14 +08:00
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:
parent
49ad768432
commit
0984951845
21
components/_util/hooks/useBreakpoint.ts
Normal file
21
components/_util/hooks/useBreakpoint.ts
Normal 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;
|
@ -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'];
|
||||
|
||||
|
@ -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
104
components/avatar/Group.tsx
Normal 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;
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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>`;
|
@ -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;
|
||||
};
|
||||
|
17
components/avatar/style/group.less
Normal file
17
components/avatar/style/group.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -57,3 +57,5 @@
|
||||
font-size: @font-size;
|
||||
}
|
||||
}
|
||||
|
||||
@import './group';
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
// ---
|
||||
|
Loading…
Reference in New Issue
Block a user