diff --git a/components/index.js b/components/index.js index 1374a81f9..79d18c6c5 100644 --- a/components/index.js +++ b/components/index.js @@ -122,6 +122,8 @@ import { default as version } from './version' import { default as Drawer } from './drawer' +import { default as Skeleton } from './skeleton' + const components = [ Affix, Anchor, @@ -174,6 +176,7 @@ const components = [ Tooltip, Upload, Drawer, + Skeleton, ] const install = function (Vue) { @@ -251,6 +254,7 @@ export { Tooltip, Upload, Drawer, + Skeleton, } export default { diff --git a/components/skeleton/Avatar.jsx b/components/skeleton/Avatar.jsx new file mode 100644 index 000000000..c6ae2d518 --- /dev/null +++ b/components/skeleton/Avatar.jsx @@ -0,0 +1,37 @@ +import classNames from 'classnames' +import PropTypes from '../_util/vue-types' +import { initDefaultProps } from '../_util/props-util' + +const skeletonAvatarProps = { + prefixCls: PropTypes.string, + size: PropTypes.oneOf(['large', 'small', 'default']), + shape: PropTypes.oneOf(['circle', 'square']), +} + +export const SkeletonAvatarProps = PropTypes.shape(skeletonAvatarProps).loose + +const Avatar = { + props: initDefaultProps(skeletonAvatarProps, { + prefixCls: 'ant-skeleton-avatar', + size: 'large', + }), + render () { + const { prefixCls, size, shape } = this.$props + + const sizeCls = classNames({ + [`${prefixCls}-lg`]: size === 'large', + [`${prefixCls}-sm`]: size === 'small', + }) + + const shapeCls = classNames({ + [`${prefixCls}-circle`]: shape === 'circle', + [`${prefixCls}-square`]: shape === 'square', + }) + + return ( + + ) + }, +} + +export default Avatar diff --git a/components/skeleton/Paragraph.jsx b/components/skeleton/Paragraph.jsx new file mode 100644 index 000000000..cd3032a86 --- /dev/null +++ b/components/skeleton/Paragraph.jsx @@ -0,0 +1,53 @@ +import PropTypes from '../_util/vue-types' +import { initDefaultProps } from '../_util/props-util' + +const widthUnit = PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, +]) + +const skeletonParagraphProps = { + prefixCls: PropTypes.string, + width: PropTypes.oneOfType([ + widthUnit, + PropTypes.arrayOf(widthUnit), + ]), + rows: PropTypes.number, +} + +export const SkeletonParagraphProps = PropTypes.shape(skeletonParagraphProps) + +const Paragraph = { + props: initDefaultProps(skeletonParagraphProps, { + prefixCls: 'ant-skeleton-paragraph', + }), + methods: { + getWidth (index) { + const { width, rows = 2 } = this + if (Array.isArray(width)) { + return width[index] + } + // last paragraph + if (rows - 1 === index) { + return width + } + return undefined + }, + }, + render () { + const { prefixCls, rows } = this.$props + const rowList = [...Array(rows)].map((_, index) => { + const width = this.getWidth(index) + return
  • + }) + return ( + + ) + }, +} + +export default Paragraph diff --git a/components/skeleton/Title.jsx b/components/skeleton/Title.jsx new file mode 100644 index 000000000..5c0520a3e --- /dev/null +++ b/components/skeleton/Title.jsx @@ -0,0 +1,30 @@ +import PropTypes from '../_util/vue-types' +import { initDefaultProps } from '../_util/props-util' + +const skeletonTitleProps = { + prefixCls: PropTypes.string, + width: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + ]), +} + +export const SkeletonTitleProps = PropTypes.shape(skeletonTitleProps) + +const Title = { + props: initDefaultProps(skeletonTitleProps, { + prefixCls: 'ant-skeleton-title', + }), + render () { + const { prefixCls, width } = this.$props + const zWidth = typeof width === 'number' ? `${width}px` : width + return ( +

    + ) + }, +} + +export default Title diff --git a/components/skeleton/__tests__/__snapshots__/demo.test.js.snap b/components/skeleton/__tests__/__snapshots__/demo.test.js.snap new file mode 100644 index 000000000..92240169d --- /dev/null +++ b/components/skeleton/__tests__/__snapshots__/demo.test.js.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/skeleton/demo/active.md correctly 1`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`renders ./components/skeleton/demo/basic.md correctly 1`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`renders ./components/skeleton/demo/children.md correctly 1`] = ` +
    +
    +

    Ant Design Vue, a design language

    +

    We supply a series of design principles, practical patterns and high quality design resources (Sketch and Axure), to help people create their product prototypes beautifully and efficiently.

    +
    +
    +`; + +exports[`renders ./components/skeleton/demo/complex.md correctly 1`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`renders ./components/skeleton/demo/list.md correctly 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/components/skeleton/__tests__/__snapshots__/index.test.js.snap b/components/skeleton/__tests__/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..df16afe2e --- /dev/null +++ b/components/skeleton/__tests__/__snapshots__/index.test.js.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Skeleton avatar shape 1`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton avatar shape 2`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton avatar size 1`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton avatar size 2`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton avatar size 3`] = ` +
    +
    +
    +

    +
      +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton paragraph rows 1`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton paragraph width 1`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton paragraph width 2`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    +
    +
    +`; + +exports[`Skeleton title width 1`] = ` +
    +
    +

    +
      +
    • +
    • +
    • +
    +
    +
    +`; diff --git a/components/skeleton/__tests__/demo.test.js b/components/skeleton/__tests__/demo.test.js new file mode 100644 index 000000000..6456f6f69 --- /dev/null +++ b/components/skeleton/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest' + +demoTest('skeleton') diff --git a/components/skeleton/__tests__/index.test.js b/components/skeleton/__tests__/index.test.js new file mode 100644 index 000000000..bc10c398b --- /dev/null +++ b/components/skeleton/__tests__/index.test.js @@ -0,0 +1,82 @@ +import { mount } from '@vue/test-utils' +import { asyncExpect } from '@/tests/utils' +import Skeleton from '..' + +describe('Skeleton', () => { + const genSkeleton = props => { + const skeletonProps = { + propsData: { + loading: true, + ...props, + }, + slots: { + default: 'Bamboo', + }, + sync: false, + } + return mount(Skeleton, skeletonProps) + } + + describe('avatar', () => { + it('size', async () => { + const wrapperSmall = genSkeleton({ avatar: { size: 'small' }}) + await asyncExpect(() => { + expect(wrapperSmall.html()).toMatchSnapshot() + }) + + const wrapperDefault = genSkeleton({ avatar: { size: 'default' }}) + + await asyncExpect(() => { + expect(wrapperDefault.html()).toMatchSnapshot() + }) + + const wrapperLarge = genSkeleton({ avatar: { size: 'large' }}) + + await asyncExpect(() => { + expect(wrapperLarge.html()).toMatchSnapshot() + }) + }) + + it('shape', async () => { + const wrapperCircle = genSkeleton({ avatar: { shape: 'circle' }}) + await asyncExpect(() => { + expect(wrapperCircle.html()).toMatchSnapshot() + }) + + const wrapperSquare = genSkeleton({ avatar: { shape: 'square' }}) + await asyncExpect(() => { + expect(wrapperSquare.html()).toMatchSnapshot() + }) + }) + }) + + describe('title', () => { + it('width', async () => { + const wrapper = genSkeleton({ title: { width: '93%' }}) + await asyncExpect(() => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + }) + + describe('paragraph', () => { + it('rows', async () => { + const wrapper = genSkeleton({ paragraph: { rows: 5 }}) + await asyncExpect(() => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + it('width', async () => { + const wrapperPure = genSkeleton({ paragraph: { width: '93%' }}) + await asyncExpect(() => { + expect(wrapperPure.html()).toMatchSnapshot() + }) + + const wrapperList = genSkeleton({ paragraph: { width: ['28%', '93%'] }}) + await asyncExpect(() => { + expect(wrapperList.html()).toMatchSnapshot() + }) + }) + }) +}) diff --git a/components/skeleton/demo/active.md b/components/skeleton/demo/active.md new file mode 100644 index 000000000..6dce2df1f --- /dev/null +++ b/components/skeleton/demo/active.md @@ -0,0 +1,16 @@ + +#### 动画效果 +显示动画效果。 + + + +#### Active Animation +Display active animation. + + +```html + +``` + diff --git a/components/skeleton/demo/basic.md b/components/skeleton/demo/basic.md new file mode 100644 index 000000000..3fdbf37ad --- /dev/null +++ b/components/skeleton/demo/basic.md @@ -0,0 +1,15 @@ + +#### 基本 +最简单的占位效果。 + + + +#### Basic +Simplest Skeleton usage. + + +```html + +``` diff --git a/components/skeleton/demo/children.md b/components/skeleton/demo/children.md new file mode 100644 index 000000000..0da5b60ef --- /dev/null +++ b/components/skeleton/demo/children.md @@ -0,0 +1,51 @@ + +#### 包含子组件 +加载占位图包含子组件。 + + + +#### Contains sub component +Skeleton contains sub component. + + +```html + + + +``` + diff --git a/components/skeleton/demo/complex.md b/components/skeleton/demo/complex.md new file mode 100644 index 000000000..39c86ec85 --- /dev/null +++ b/components/skeleton/demo/complex.md @@ -0,0 +1,16 @@ + +#### 复杂的组合 +更复杂的组合。 + + + +#### Complex combination +Complex combination with avatar and multiple paragraphs. + + +```html + +``` + diff --git a/components/skeleton/demo/index.vue b/components/skeleton/demo/index.vue new file mode 100644 index 000000000..718372dc6 --- /dev/null +++ b/components/skeleton/demo/index.vue @@ -0,0 +1,62 @@ + diff --git a/components/skeleton/demo/list.md b/components/skeleton/demo/list.md new file mode 100644 index 000000000..e48f621b5 --- /dev/null +++ b/components/skeleton/demo/list.md @@ -0,0 +1,79 @@ + +#### 列表 +在列表组件中使用加载占位符。 + + + +#### List +Use skeleton in list component. + + +```html + + + +``` + + diff --git a/components/skeleton/index.en-US.md b/components/skeleton/index.en-US.md new file mode 100644 index 000000000..c63d6da24 --- /dev/null +++ b/components/skeleton/index.en-US.md @@ -0,0 +1,31 @@ +## API + +### Skeleton + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| active | Show animation effect | boolean | false | +| avatar | Show avatar placeholder | boolean \| [SkeletonAvatarProps](#SkeletonAvatarProps) | false | +| loading | Display the skeleton when `true` | boolean | - | +| paragraph | Show paragraph placeholder | boolean \| [SkeletonParagraphProps](#SkeletonParagraphProps) | true | +| title | Show title placeholder | boolean \| [SkeletonTitleProps](#SkeletonTitleProps) | true | + +### SkeletonAvatarProps + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| size | Set the size of avatar | Enum{ 'large', 'small', 'default' } | - | +| shape | Set the shape of avatar | Enum{ 'circle', 'square' } | - | + +### SkeletonTitleProps + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| width | Set the width of title | number \| string | - | + +### SkeletonParagraphProps + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| rows | Set the row count of paragraph | number | - | +| width | Set the width of paragraph. When width is an Array, it can set the width of each row. Otherwise only set the last row width | number \| string \| Array | - | diff --git a/components/skeleton/index.jsx b/components/skeleton/index.jsx new file mode 100644 index 000000000..8330a4244 --- /dev/null +++ b/components/skeleton/index.jsx @@ -0,0 +1,171 @@ +import classNames from 'classnames' +import PropTypes from '../_util/vue-types' +import { initDefaultProps, hasProp } from '../_util/props-util' +import Avatar, { SkeletonAvatarProps } from './Avatar' +import Title, { SkeletonTitleProps } from './Title' +import Paragraph, { SkeletonParagraphProps } from './Paragraph' + +export const SkeletonProps = { + active: PropTypes.bool, + loading: PropTypes.bool, + prefixCls: PropTypes.string, + children: PropTypes.any, + avatar: PropTypes.oneOfType([ + PropTypes.string, + SkeletonAvatarProps, + PropTypes.bool, + ]), + title: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + SkeletonTitleProps, + ]), + paragraph: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + SkeletonParagraphProps, + ]), +} + +function getComponentProps (prop) { + if (prop && typeof prop === 'object') { + return prop + } + return {} +} + +function getAvatarBasicProps (hasTitle, hasParagraph) { + if (hasTitle && !hasParagraph) { + return { shape: 'square' } + } + + return { shape: 'circle' } +} + +function getTitleBasicProps (hasAvatar, hasParagraph) { + if (!hasAvatar && hasParagraph) { + return { width: '38%' } + } + + if (hasAvatar && hasParagraph) { + return { width: '50%' } + } + + return {} +} + +function getParagraphBasicProps (hasAvatar, hasTitle) { + const basicProps = {} + + // Width + if (!hasAvatar || !hasTitle) { + basicProps.width = '61%' + } + + // Rows + if (!hasAvatar && hasTitle) { + basicProps.rows = 3 + } else { + basicProps.rows = 2 + } + + return basicProps +} + +const Skeleton = { + name: 'ASkeleton', + props: initDefaultProps(SkeletonProps, { + prefixCls: 'ant-skeleton', + avatar: false, + title: true, + paragraph: true, + }), + render () { + const { + loading, prefixCls, + avatar, title, paragraph, active, + } = this.$props + if (loading || !hasProp(this, 'loading')) { + const hasAvatar = !!avatar || avatar === '' + const hasTitle = !!title + const hasParagraph = !!paragraph + + // Avatar + let avatarNode + if (hasAvatar) { + const avatarProps = { + props: { + ...getAvatarBasicProps(hasTitle, hasParagraph), + ...getComponentProps(avatar), + }, + } + + avatarNode = ( +
    + +
    + ) + } + + let contentNode + if (hasTitle || hasParagraph) { + // Title + let $title + if (hasTitle) { + const titleProps = { + props: { + ...getTitleBasicProps(hasAvatar, hasParagraph), + ...getComponentProps(title), + }, + } + + $title = ( + + ) + } + + // Paragraph + let paragraphNode + if (hasParagraph) { + const paragraphProps = { + props: { + ...getParagraphBasicProps(hasAvatar, hasTitle), + ...getComponentProps(paragraph), + }, + } + + paragraphNode = ( + <Paragraph {...paragraphProps} /> + ) + } + + contentNode = ( + <div class={`${prefixCls}-content`}> + {$title} + {paragraphNode} + </div> + ) + } + + const cls = classNames( + prefixCls, { + [`${prefixCls}-with-avatar`]: hasAvatar, + [`${prefixCls}-active`]: active, + }, + ) + + return ( + <div class={cls}> + {avatarNode} + {contentNode} + </div> + ) + } + return this.$slots.default && this.$slots.default[0] + }, +} +/* istanbul ignore next */ +Skeleton.install = function (Vue) { + Vue.component(Skeleton.name, Skeleton) +} +export default Skeleton diff --git a/components/skeleton/index.zh-CN.md b/components/skeleton/index.zh-CN.md new file mode 100644 index 000000000..57bf22230 --- /dev/null +++ b/components/skeleton/index.zh-CN.md @@ -0,0 +1,31 @@ +## API + +### Skeleton + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| active | 是否展示动画效果 | boolean | false | +| avatar | 是否显示头像占位图 | boolean \| [SkeletonAvatarProps](#SkeletonAvatarProps) | false | +| loading | 为 `true` 时,显示占位图。反之则直接展示子组件 | boolean | - | +| paragraph | 是否显示段落占位图 | boolean \| [SkeletonParagraphProps](#SkeletonParagraphProps) | true | +| title | 是否显示标题占位图 | boolean \| [SkeletonTitleProps](#SkeletonTitleProps) | true | + +### SkeletonAvatarProps + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| size | 设置头像占位图的大小 | Enum{ 'large', 'small', 'default' } | - | +| shape | 指定头像的形状 | Enum{ 'circle', 'square' } | - | + +### SkeletonTitleProps + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| width | 设置标题占位图的宽度 | number \| string | - | + +### SkeletonParagraphProps + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| rows | 设置段落占位图的行数 | number | - | +| width | 设置段落占位图的宽度,若为数组时则为对应的每行宽度,反之则是最后一行的宽度 | number \| string \| Array<number \| string> | - | diff --git a/components/skeleton/style/index.js b/components/skeleton/style/index.js new file mode 100644 index 000000000..3a3ab0de5 --- /dev/null +++ b/components/skeleton/style/index.js @@ -0,0 +1,2 @@ +import '../../style/index.less'; +import './index.less'; diff --git a/components/skeleton/style/index.less b/components/skeleton/style/index.less new file mode 100644 index 000000000..1d5054c45 --- /dev/null +++ b/components/skeleton/style/index.less @@ -0,0 +1,123 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@skeleton-prefix-cls: ~"@{ant-prefix}-skeleton"; +@skeleton-avatar-prefix-cls: ~"@{skeleton-prefix-cls}-avatar"; +@skeleton-title-prefix-cls: ~"@{skeleton-prefix-cls}-title"; +@skeleton-paragraph-prefix-cls: ~"@{skeleton-prefix-cls}-paragraph"; + +@skeleton-to-color: shade(@skeleton-color, 5%); + +.@{skeleton-prefix-cls} { + display: table; + width: 100%; + + &-header { + display: table-cell; + vertical-align: top; + padding-right: 16px; + + // Avatar + .@{skeleton-avatar-prefix-cls} { + display: inline-block; + vertical-align: top; + background: @skeleton-color; + + .avatar-size(@avatar-size-base); + + &-lg { + .avatar-size(@avatar-size-lg); + } + + &-sm { + .avatar-size(@avatar-size-sm); + } + } + } + + &-content { + display: table-cell; + vertical-align: top; + width: 100%; + + // Title + .@{skeleton-title-prefix-cls} { + margin-top: 16px; + height: 16px; + width: 100%; + background: @skeleton-color; + + + .@{skeleton-paragraph-prefix-cls} { + margin-top: 24px; + } + } + + // paragraph + .@{skeleton-paragraph-prefix-cls} { + > li { + height: 16px; + background: @skeleton-color; + list-style: none; + width: 100%; + + &:last-child:not(:first-child):not(:nth-child(2)) { + width: 61%; + } + + + li { + margin-top: 16px; + } + } + } + } + + &-with-avatar &-content { + // Title + .@{skeleton-title-prefix-cls} { + margin-top: 12px; + + + .@{skeleton-paragraph-prefix-cls} { + margin-top: 28px; + } + } + } + + // With active animation + &.@{skeleton-prefix-cls}-active { + & .@{skeleton-prefix-cls}-content { + .@{skeleton-title-prefix-cls}, + .@{skeleton-paragraph-prefix-cls} > li { + .skeleton-color(); + } + } + + .@{skeleton-avatar-prefix-cls} { + .skeleton-color(); + } + } +} + +.avatar-size(@size) { + width: @size; + height: @size; + line-height: @size; + + &.@{skeleton-avatar-prefix-cls}-circle { + border-radius: 50%; + } +} + +.skeleton-color() { + background: linear-gradient(90deg, @skeleton-color 25%, @skeleton-to-color 37%, @skeleton-color 63%); + animation: ~"@{skeleton-prefix-cls}-loading" 1.4s ease infinite; + background-size: 400% 100%; +} + +@keyframes ~"@{skeleton-prefix-cls}-loading" { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0 50%; + } +} diff --git a/components/style.js b/components/style.js index 9d68364d9..4b4bbda16 100644 --- a/components/style.js +++ b/components/style.js @@ -50,3 +50,4 @@ import './list/style' import './carousel/style' import './tree-select/style' import './drawer/style' +import './skeleton/style' diff --git a/components/style/themes/default.less b/components/style/themes/default.less index 7de75b370..a5a6fc9e8 100644 --- a/components/style/themes/default.less +++ b/components/style/themes/default.less @@ -361,6 +361,11 @@ @tag-default-color: @text-color; @tag-font-size: @font-size-sm; +// Skeleton +// --- +@skeleton-color: #f2f2f2; + + // TimePicker // --- @time-picker-panel-column-width: 56px; diff --git a/site/components.js b/site/components.js index 3537ef9c6..54b59717e 100644 --- a/site/components.js +++ b/site/components.js @@ -55,6 +55,7 @@ import { Upload, // version, Drawer, + Skeleton, } from 'ant-design-vue' Vue.prototype.$message = message @@ -117,6 +118,7 @@ Vue.use(TimePicker) Vue.use(Timeline) Vue.use(Tooltip) Vue.use(Upload) +Vue.use(Skeleton) /* v1.1.2 registration methods */ // Vue.component(Affix.name, Affix) // a-affix