diff --git a/components/_util/hooks/useBreakpoint.ts b/components/_util/hooks/useBreakpoint.ts new file mode 100644 index 000000000..61a38867e --- /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 7fc1cbf53..ba27e1e0a 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 5fcacf47e..85446b0aa 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, @@ -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, + 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>; 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 = {alt}; + children = ( + {alt} + ); } else if (icon) { children = icon; } else { @@ -159,7 +209,15 @@ const Avatar = defineComponent({ } } return ( - + {children} ); diff --git a/components/avatar/Group.tsx b/components/avatar/Group.tsx new file mode 100644 index 000000000..eda441ed6 --- /dev/null +++ b/components/avatar/Group.tsx @@ -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, + 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>; + +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( + + {`+${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 9ceeadad8..91d028d39 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 000000000..a17048216 --- /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 5229cee3e..ba103d620 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 000000000..8116ae25a --- /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 0596972c1..e039ef893 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/config-provider/index.tsx b/components/config-provider/index.tsx index 835e38d3b..55c31214a 100644 --- a/components/config-provider/index.tsx +++ b/components/config-provider/index.tsx @@ -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', diff --git a/components/style/themes/default.less b/components/style/themes/default.less index 1c9c2b259..666c69872 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 // ---