From de0a679377726eb995190fa7f2980bc45a55193f Mon Sep 17 00:00:00 2001 From: John Date: Tue, 11 May 2021 16:08:48 +0800 Subject: [PATCH 1/3] feat(avatar): add avatar group --- components/_util/hooks/useBreakpoint.ts | 21 ++++ components/_util/responsiveObserve.ts | 1 + components/avatar/Avatar.tsx | 66 +++++++++-- components/avatar/Group.tsx | 112 ++++++++++++++++++ components/avatar/__tests__/Avatar.test.js | 84 +++++++++++++ .../__snapshots__/Avatar.test.js.snap | 43 +++++++ components/avatar/index.ts | 20 +++- components/avatar/style/group.less | 17 +++ components/avatar/style/index.less | 2 + components/style/themes/default.less | 3 + 10 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 components/_util/hooks/useBreakpoint.ts create mode 100644 components/avatar/Group.tsx create mode 100644 components/avatar/__tests__/__snapshots__/Avatar.test.js.snap create mode 100644 components/avatar/style/group.less diff --git a/components/_util/hooks/useBreakpoint.ts b/components/_util/hooks/useBreakpoint.ts new file mode 100644 index 0000000000..61a38867eb --- /dev/null +++ b/components/_util/hooks/useBreakpoint.ts @@ -0,0 +1,21 @@ +import { onMounted, onUnmounted, Ref, ref } from 'vue'; +import ResponsiveObserve, { ScreenMap } from '../../_util/responsiveObserve'; + +function useBreakpoint(): Ref { + const screens = ref({}); + let token = null; + + onMounted(() => { + token = ResponsiveObserve.subscribe(supportScreens => { + screens.value = supportScreens; + }); + }); + + onUnmounted(() => { + ResponsiveObserve.unsubscribe(token); + }); + + return screens; +} + +export default useBreakpoint; diff --git a/components/_util/responsiveObserve.ts b/components/_util/responsiveObserve.ts index 7fc1cbf536..ba27e1e0ac 100644 --- a/components/_util/responsiveObserve.ts +++ b/components/_util/responsiveObserve.ts @@ -1,6 +1,7 @@ export type Breakpoint = 'xxl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type BreakpointMap = Partial>; export type ScreenMap = Partial>; +export type ScreenSizeMap = Partial>; export const responsiveArray: Breakpoint[] = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs']; diff --git a/components/avatar/Avatar.tsx b/components/avatar/Avatar.tsx index 5fcacf47e7..6c599ca207 100644 --- a/components/avatar/Avatar.tsx +++ b/components/avatar/Avatar.tsx @@ -1,5 +1,6 @@ import { tuple, VueNode } from '../_util/type'; import { + computed, CSSProperties, defineComponent, ExtractPropTypes, @@ -9,25 +10,31 @@ import { onUpdated, PropType, ref, + unref, watch, } from 'vue'; 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, + 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 +45,7 @@ export type AvatarProps = Partial>; 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 +55,27 @@ const Avatar = defineComponent({ const configProvider = inject('configProvider', defaultConfigProvider); + const groupSize = inject('SizeProvider', 'default'); + + const screens = useBreakpoint(); + const responsiveSizeStyle = computed(() => { + if (typeof props.size !== 'object') { + return {}; + } + const currentBreakpoint: Breakpoint = responsiveArray.find(screen => screens.value[screen])!; + const currentSize = props.size[currentBreakpoint]; + + const hasIcon = !!getPropsSlot(slots, props, 'icon'); + return currentSize + ? { + width: `${currentSize}px`, + height: `${currentSize}px`, + lineHeight: `${currentSize}px`, + fontSize: `${hasIcon ? currentSize / 2 : 18}px`, + } + : {}; + }); + const setScale = () => { if (!avatarChildrenRef.value || !avatarNodeRef.value) { return; @@ -96,11 +124,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' ? unref(groupSize) : customSize; const classString = { [prefixCls]: true, [`${prefixCls}-lg`]: size === 'large', @@ -122,7 +158,15 @@ const Avatar = defineComponent({ let children: VueNode = slots.default?.(); if (src && isImgExist.value) { - children = {alt}; + children = ( + {alt} + ); } else if (icon) { children = icon; } else { @@ -159,7 +203,11 @@ const Avatar = defineComponent({ } } return ( - + {children} ); diff --git a/components/avatar/Group.tsx b/components/avatar/Group.tsx new file mode 100644 index 0000000000..c0070a57a1 --- /dev/null +++ b/components/avatar/Group.tsx @@ -0,0 +1,112 @@ +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'; + +const groupProps = { + children: PropTypes.VNodeChild, + style: { + type: Object as PropType, + default: () => ({} as CSSProperties), + }, + prefixCls: String, + maxCount: Number, + maxStyle: { + type: Object as PropType, + default: () => ({} as CSSProperties), + }, + maxPopoverPlacement: { + type: String as PropType<'top' | 'bottom'>, + default: 'top', + }, + /* + * Size of avatar, options: `large`, `small`, `default` + * or a custom number size + * */ + size: avatarProps.size, +}; + +export type AvatarGroupProps = Partial>; + +const Group = defineComponent({ + name: 'AAvatarGroup', + props: groupProps, + inheritAttrs: false, + setup(props, { slots, attrs }) { + const configProvider = inject('configProvider', defaultConfigProvider); + + provide( + 'SizeProvider', + computed(() => props.size), + ); + + return () => { + const getPrefixCls = configProvider.getPrefixCls; + + const { + prefixCls: customizePrefixCls, + maxPopoverPlacement = 'top', + maxCount, + maxStyle, + } = props; + const className = attrs.class as string; + + const prefixCls = getPrefixCls('avatar-group', customizePrefixCls); + + 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( + + {`+${numOfChildren - maxCount}`} + , + ); + return ( +
+ {childrenShow} +
+ ); + } + + return ( +
+ {childrenWithProps} +
+ ); + }; + }, +}); + +export default Group; diff --git a/components/avatar/__tests__/Avatar.test.js b/components/avatar/__tests__/Avatar.test.js index 9ceeadad87..91d028d390 100644 --- a/components/avatar/__tests__/Avatar.test.js +++ b/components/avatar/__tests__/Avatar.test.js @@ -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; + }, + }); + + 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 xx; + }, + }); + 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; + }, + }); + 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 ; + }, + }); + + 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 ( + + A + + ); + }, + }, + { attachTo: div }, + ); + await asyncExpect(async () => { + await wrapper.find('img').trigger('error'); + expect(wrapper.html()).toMatchSnapshot(); + wrapper.unmount(); + global.document.body.removeChild(div); + }, 0); + }); }); diff --git a/components/avatar/__tests__/__snapshots__/Avatar.test.js.snap b/components/avatar/__tests__/__snapshots__/Avatar.test.js.snap new file mode 100644 index 0000000000..a17048216f --- /dev/null +++ b/components/avatar/__tests__/__snapshots__/Avatar.test.js.snap @@ -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`] = ``; + +exports[`Avatar Render adjusts component size to 32 when window size is sm 1`] = ``; + +exports[`Avatar Render adjusts component size to 40 when window size is md 1`] = ``; + +exports[`Avatar Render adjusts component size to 64 when window size is lg 1`] = ``; + +exports[`Avatar Render adjusts component size to 80 when window size is xl 1`] = ``; + +exports[`Avatar Render adjusts component size to 100 when window size is xxl 1`] = ``; + +exports[`Avatar Render fallback 1`] = `A`; + +exports[`Avatar Render should calculate scale of avatar children correctly 1`] = ` +DOMWrapper { + "wrapperElement": + + Avatar + + , +} +`; + +exports[`Avatar Render should calculate scale of avatar children correctly 2`] = ` +DOMWrapper { + "wrapperElement": + + xx + + , +} +`; + +exports[`Avatar Render should calculate scale of avatar children correctly with gap 1`] = `Avatar`; diff --git a/components/avatar/index.ts b/components/avatar/index.ts index 5229cee3e8..ba103d6206 100644 --- a/components/avatar/index.ts +++ b/components/avatar/index.ts @@ -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; + }; diff --git a/components/avatar/style/group.less b/components/avatar/style/group.less new file mode 100644 index 0000000000..8116ae25a6 --- /dev/null +++ b/components/avatar/style/group.less @@ -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; + } + } +} diff --git a/components/avatar/style/index.less b/components/avatar/style/index.less index 0596972c12..e039ef8934 100644 --- a/components/avatar/style/index.less +++ b/components/avatar/style/index.less @@ -57,3 +57,5 @@ font-size: @font-size; } } + +@import './group'; diff --git a/components/style/themes/default.less b/components/style/themes/default.less index 1c9c2b2590..666c69872b 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -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 // --- From 99027b190183fdae470d053d61a28c21092316c1 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 11 May 2021 16:21:06 +0800 Subject: [PATCH 2/3] refactor: update --- components/avatar/Group.tsx | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/components/avatar/Group.tsx b/components/avatar/Group.tsx index c0070a57a1..635e3116eb 100644 --- a/components/avatar/Group.tsx +++ b/components/avatar/Group.tsx @@ -14,23 +14,16 @@ import { } from 'vue'; import PropTypes from '../_util/vue-types'; import { getPropsSlot } from '../_util/props-util'; +import { tuple } from '../_util/type'; const groupProps = { - children: PropTypes.VNodeChild, - style: { - type: Object as PropType, - default: () => ({} as CSSProperties), - }, - prefixCls: String, - maxCount: Number, + prefixCls: PropTypes.string, + maxCount: PropTypes.number, maxStyle: { type: Object as PropType, default: () => ({} as CSSProperties), }, - maxPopoverPlacement: { - type: String as PropType<'top' | 'bottom'>, - default: 'top', - }, + maxPopoverPlacement: PropTypes.oneOf(tuple('top', 'bottom')).def('top'), /* * Size of avatar, options: `large`, `small`, `default` * or a custom number size @@ -53,17 +46,16 @@ const Group = defineComponent({ ); return () => { - const getPrefixCls = configProvider.getPrefixCls; - const { prefixCls: customizePrefixCls, maxPopoverPlacement = 'top', maxCount, maxStyle, } = props; - const className = attrs.class as string; + const { getPrefixCls } = configProvider; const prefixCls = getPrefixCls('avatar-group', customizePrefixCls); + const className = attrs.class as string; const cls = { [prefixCls]: true, @@ -94,14 +86,14 @@ const Group = defineComponent({ , ); return ( -
+
{childrenShow}
); } return ( -
+
{childrenWithProps}
); From 6e3c8f431faf785af8e0ac10635a69edc9015305 Mon Sep 17 00:00:00 2001 From: John Date: Wed, 12 May 2021 17:18:00 +0800 Subject: [PATCH 3/3] refactor: update --- components/avatar/Avatar.tsx | 40 +++++++++++++++++----------- components/avatar/Group.tsx | 2 +- components/config-provider/index.tsx | 1 + 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/components/avatar/Avatar.tsx b/components/avatar/Avatar.tsx index 6c599ca207..85446b0aa2 100644 --- a/components/avatar/Avatar.tsx +++ b/components/avatar/Avatar.tsx @@ -10,7 +10,6 @@ import { onUpdated, PropType, ref, - unref, watch, } from 'vue'; import { defaultConfigProvider } from '../config-provider'; @@ -55,27 +54,34 @@ const Avatar = defineComponent({ const configProvider = inject('configProvider', defaultConfigProvider); - const groupSize = inject('SizeProvider', 'default'); + const groupSize = inject( + 'SizeProvider', + computed(() => 'default'), + ); const screens = useBreakpoint(); - const responsiveSizeStyle = computed(() => { + const responsiveSize = computed(() => { if (typeof props.size !== 'object') { - return {}; + return undefined; } const currentBreakpoint: Breakpoint = responsiveArray.find(screen => screens.value[screen])!; const currentSize = props.size[currentBreakpoint]; - const hasIcon = !!getPropsSlot(slots, props, 'icon'); - return currentSize - ? { - width: `${currentSize}px`, - height: `${currentSize}px`, - lineHeight: `${currentSize}px`, - fontSize: `${hasIcon ? currentSize / 2 : 18}px`, - } - : {}; + 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; @@ -136,7 +142,7 @@ const Avatar = defineComponent({ const icon = getPropsSlot(slots, props, 'icon'); const getPrefixCls = configProvider.getPrefixCls; const prefixCls = getPrefixCls('avatar', customizePrefixCls); - const size = customSize === 'default' ? unref(groupSize) : customSize; + const size = customSize === 'default' ? groupSize.value : customSize; const classString = { [prefixCls]: true, [`${prefixCls}-lg`]: size === 'large', @@ -206,7 +212,11 @@ const Avatar = defineComponent({ {children} diff --git a/components/avatar/Group.tsx b/components/avatar/Group.tsx index 635e3116eb..eda441ed68 100644 --- a/components/avatar/Group.tsx +++ b/components/avatar/Group.tsx @@ -42,7 +42,7 @@ const Group = defineComponent({ provide( 'SizeProvider', - computed(() => props.size), + computed(() => props.size || configProvider.componentSize), ); return () => { diff --git a/components/config-provider/index.tsx b/components/config-provider/index.tsx index d6d564013c..f3fbd5a169 100644 --- a/components/config-provider/index.tsx +++ b/components/config-provider/index.tsx @@ -30,6 +30,7 @@ export interface ConfigConsumerProps { pageHeader?: { ghost: boolean; }; + componentSize?: SizeType; direction?: 'ltr' | 'rtl'; space?: { size?: SizeType | number;