feat(skeleton): skeleton component (#1345)

* feat(skeleton): skeleton component

* fix(skeleton): skeleton css style
This commit is contained in:
xiaoy 2024-02-02 15:27:14 +08:00 committed by GitHub
parent 1380f4e931
commit 47c6f176c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1398 additions and 654 deletions

View File

@ -0,0 +1,9 @@
<template>
<div>
<tiny-skeleton></tiny-skeleton>
</div>
</template>
<script setup>
import { Skeleton as TinySkeleton } from '@opentiny/vue'
</script>

View File

@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test'
test('基本用法', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#basic')
const first = page.locator('.tiny-skeleton')
const children = page.locator('.tiny-skeleton-item')
const active = page.locator('.tiny-skeleton-item--active')
await expect(first).toHaveCount(1)
await expect(children).toHaveCount(4)
await expect(active).toHaveCount(4)
})

View File

@ -0,0 +1,15 @@
<template>
<div>
<tiny-skeleton></tiny-skeleton>
</div>
</template>
<script>
import { Skeleton } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton
}
}
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>
<tiny-skeleton avatar></tiny-skeleton>
</div>
</template>
<script setup>
import { Skeleton as TinySkeleton } from '@opentiny/vue'
</script>

View File

@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test'
test('段落显示左侧头像', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#complex-demo')
const first = page.locator('.tiny-skeleton')
const avatar = page.locator('.tiny-skeleton__avatar')
const medium = page.locator('.tiny-skeleton-item--medium')
await expect(first).toHaveCount(1)
await expect(avatar).toHaveCount(1)
await expect(medium).toHaveCount(1)
})

View File

@ -0,0 +1,15 @@
<template>
<div>
<tiny-skeleton avatar></tiny-skeleton>
</div>
</template>
<script>
import { Skeleton } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton
}
}
</script>

View File

@ -0,0 +1,15 @@
<template>
<div>
<tiny-skeleton>
<template #placeholder>
<tiny-skeleton-item variant="image" style="width: 200px"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item variant="circle" size="small"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script setup>
import { Skeleton as TinySkeleton, SkeletonItem as TinySkeletonItem } from '@opentiny/vue'
</script>

View File

@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test'
test('自定义排版', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#custom-layout')
const image = page.locator('.tiny-skeleton-item--image')
const circle = page.locator('.tiny-skeleton-item--circle')
await expect(image).toHaveCount(1)
await expect(circle).toHaveCount(1)
await expect(circle).toHaveClass(/tiny-skeleton-item--small/)
await expect(image).toHaveCSS('width', '200px')
})

View File

@ -0,0 +1,22 @@
<template>
<div>
<tiny-skeleton>
<template #placeholder>
<tiny-skeleton-item variant="image" style="width: 200px"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item variant="circle" size="small"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script>
import { Skeleton, SkeletonItem } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton,
TinySkeletonItem: SkeletonItem
}
}
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>
<tiny-skeleton :rows="5" :rows-width="['200px', '100px', '50px']"></tiny-skeleton>
</div>
</template>
<script setup>
import { Skeleton as TinySkeleton } from '@opentiny/vue'
</script>

View File

@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test'
test('自定义段落宽度', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#custom-paragraph-width')
const first = page.locator('.tiny-skeleton')
const item1 = page.locator('.tiny-skeleton-item').nth(1)
const item2 = page.locator('.tiny-skeleton-item').nth(2)
const item3 = page.locator('.tiny-skeleton-item').nth(3)
await expect(first).toHaveCount(1)
await expect(item1).toHaveCSS('width', '200px')
await expect(item2).toHaveCSS('width', '100px')
await expect(item3).toHaveCSS('width', '50px')
})

View File

@ -0,0 +1,15 @@
<template>
<div>
<tiny-skeleton :rows="5" :rows-width="['200px', '100px', '50px']"></tiny-skeleton>
</div>
</template>
<script>
import { Skeleton } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton
}
}
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>
<tiny-skeleton :rows="4"></tiny-skeleton>
</div>
</template>
<script setup>
import { Skeleton as TinySkeleton } from '@opentiny/vue'
</script>

View File

@ -0,0 +1,12 @@
import { expect, test } from '@playwright/test'
test('自定义段落行数', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#custom-rows')
const first = page.locator('.tiny-skeleton')
const item = page.locator('.tiny-skeleton-item')
await expect(first).toHaveCount(1)
await expect(item).toHaveCount(5)
})

View File

@ -0,0 +1,15 @@
<template>
<div>
<tiny-skeleton :rows="4"></tiny-skeleton>
</div>
</template>
<script>
import { Skeleton } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton
}
}
</script>

View File

@ -0,0 +1,34 @@
<template>
<div>
<p>大小</p>
<tiny-radio v-model="size" label="small">Small</tiny-radio>
<tiny-radio v-model="size" label="medium">Middle</tiny-radio>
<tiny-radio v-model="size" label="large">Large</tiny-radio>
<br /><br />
<p>动画</p>
<tiny-switch v-model="active"></tiny-switch>
<br /><br />
<tiny-skeleton :active="active">
<template #placeholder>
<tiny-skeleton-item></tiny-skeleton-item>
<br />
<tiny-skeleton-item variant="image" :size="size"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item variant="circle" :size="size"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script setup>
import {
Skeleton as TinySkeleton,
Radio as TinyRadio,
SkeletonItem as TinySkeletonItem,
Switch as TinySwitch
} from '@opentiny/vue'
import { ref } from 'vue'
const size = ref('medium')
const active = ref(true)
</script>

View File

@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test'
test('细粒度模式', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#fine-grained-mode')
const first = page.locator('.tiny-skeleton')
const radio1 = page.locator('.tiny-radio').nth(0)
const radio2 = page.locator('.tiny-radio').nth(1)
const radio3 = page.locator('.tiny-radio').nth(2)
const activeSwitch = page.locator('.pc-demo > div > .tiny-switch')
const image = page.locator('.tiny-skeleton-item--image')
const circle = page.locator('.tiny-skeleton-item--circle')
const square = page.locator('.tiny-skeleton-item--square')
await expect(first).toHaveCount(1)
// 测试动画效果
await expect(circle).toHaveClass(/tiny-skeleton-item--active/)
await expect(square).toHaveClass(/tiny-skeleton-item--active/)
await expect(image).toHaveClass(/tiny-skeleton-item--active/)
await activeSwitch.click()
await page.waitForTimeout(500)
await expect(image).not.toHaveClass(/tiny-skeleton-item--active/)
await expect(circle).not.toHaveClass(/tiny-skeleton-item--active/)
await expect(square).not.toHaveClass(/tiny-skeleton-item--active/)
// 测试大小
await radio2.click()
await page.waitForTimeout(500)
await expect(radio2).toHaveClass(/is-checked/)
await expect(circle).toHaveClass(/tiny-skeleton-item--medium/)
await expect(image).toHaveClass(/tiny-skeleton-item--medium/)
await expect(square).not.toHaveClass(/tiny-skeleton-item--medium/)
await radio1.click()
await page.waitForTimeout(500)
await expect(radio1).toHaveClass(/is-checked/)
await expect(circle).toHaveClass(/tiny-skeleton-item--small/)
await expect(image).toHaveClass(/tiny-skeleton-item--small/)
await expect(square).not.toHaveClass(/tiny-skeleton-item--small/)
await radio3.click()
await page.waitForTimeout(500)
await expect(radio3).toHaveClass(/is-checked/)
await expect(circle).toHaveClass(/tiny-skeleton-item--large/)
await expect(image).toHaveClass(/tiny-skeleton-item--large/)
await expect(square).not.toHaveClass(/tiny-skeleton-item--large/)
})

View File

@ -0,0 +1,41 @@
<template>
<div>
<p>大小</p>
<tiny-radio v-model="size" label="small">Small</tiny-radio>
<tiny-radio v-model="size" label="medium">Middle</tiny-radio>
<tiny-radio v-model="size" label="large">Large</tiny-radio>
<br /><br />
<p>动画</p>
<tiny-switch v-model="active"></tiny-switch>
<br /><br />
<tiny-skeleton :active="active">
<template #placeholder>
<tiny-skeleton-item></tiny-skeleton-item>
<br />
<tiny-skeleton-item variant="image" :size="size"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item variant="circle" :size="size"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script>
import { Skeleton, Radio, SkeletonItem, Switch } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton,
TinyRadio: Radio,
TinySkeletonItem: SkeletonItem,
TinySwitch: Switch
},
data() {
return {
size: 'medium',
active: true
}
}
}
</script>

View File

@ -0,0 +1,35 @@
<template>
<div>
<tiny-button @click="handler">显示/隐藏</tiny-button>
<br /><br />
<tiny-skeleton :loading="loading">
<template #default>
<tiny-user-head type="icon" round></tiny-user-head>
<p class="paragraph">内容比较短的一段文字</p>
<tiny-button>一个按钮</tiny-button>
</template>
<template #placeholder>
<tiny-skeleton-item variant="circle" style="width: 72px; height: 72px"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item style="width: 180px"></tiny-skeleton-item>
<tiny-skeleton-item style="width: 92px; height: 28px"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script setup>
import {
Skeleton as TinySkeleton,
Button as TinyButton,
SkeletonItem as TinySkeletonItem,
UserHead as TinyUserHead
} from '@opentiny/vue'
import { ref } from 'vue'
const loading = ref(true)
const handler = () => {
loading.value = !loading.value
}
</script>

View File

@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test'
test('加载完成', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('skeleton#loading-completed')
const square = page.locator('.tiny-skeleton-item--square')
const circle = page.locator('.tiny-skeleton-item--circle')
const button = page.getByRole('button', { name: '显示/隐藏' })
await expect(circle).toHaveCount(1)
await expect(square).toHaveCount(2)
await expect(circle).toHaveCSS('width', '72px')
await expect(circle).toHaveCSS('height', '72px')
await button.click()
await page.waitForTimeout(500)
await expect(square).toBeHidden()
await expect(circle).toBeHidden()
const p = page.locator('.paragraph')
await expect(p).toHaveText(/内容比较短的一段文字/)
const btn = page.getByRole('button', { name: '一个按钮' })
await expect(btn).toBeVisible()
const head = page.locator('.tiny-user-head')
await expect(head).toBeVisible()
})

View File

@ -0,0 +1,42 @@
<template>
<div>
<tiny-button @click="handler">显示/隐藏</tiny-button>
<br /><br />
<tiny-skeleton :loading="loading">
<template #default>
<tiny-user-head type="icon" round></tiny-user-head>
<p class="paragraph">内容比较短的一段文字</p>
<tiny-button>一个按钮</tiny-button>
</template>
<template #placeholder>
<tiny-skeleton-item variant="circle" style="width: 72px; height: 72px"></tiny-skeleton-item>
<br /><br />
<tiny-skeleton-item style="width: 180px"></tiny-skeleton-item>
<tiny-skeleton-item style="width: 92px; height: 28px"></tiny-skeleton-item>
</template>
</tiny-skeleton>
</div>
</template>
<script>
import { Skeleton, Button, SkeletonItem, UserHead } from '@opentiny/vue'
export default {
components: {
TinySkeleton: Skeleton,
TinyButton: Button,
TinySkeletonItem: SkeletonItem,
TinyUserHead: UserHead
},
data() {
return {
loading: true
}
},
methods: {
handler() {
this.loading = !this.loading
}
}
}
</script>

View File

@ -0,0 +1,7 @@
---
title: Skeleton 骨架屏
---
# Skeleton 骨架屏
用于在内容加载过程中展示一组占位图形。

View File

@ -0,0 +1,7 @@
---
title: Skeleton 骨架屏
---
# Skeleton 骨架屏
Used to display a set of placeholder graphics while content is loading.

View File

@ -0,0 +1,207 @@
export default {
column: '2',
owner: '',
demos: [
{
'demoId': 'base',
'name': { 'zh-CN': '基本用法', 'en-US': 'Basic Usage' },
'desc': {
'zh-CN': '<p>基础的骨架效果。</p>\n',
'en-US': '<p>Basic skeleton effect.</p>\n'
},
'codeFiles': ['base.vue']
},
{
'demoId': 'complex-demo',
'name': { 'zh-CN': '复杂的组合', 'en-US': 'Complex Demo' },
'desc': {
'zh-CN': '<p>更复杂的组合,通过 <code>avatar</code> 属性控制骨架段落左侧出现头像占位。</p>\n',
'en-US':
'<p>More complex combinations, use the <code>avatar</code> attribute to control the appearance of the avatar placeholder on the left side of the skeleton paragraph.</p>\n'
},
'codeFiles': ['complex-demo.vue']
},
{
'demoId': 'custom-rows',
'name': { 'zh-CN': '自定义段落行数', 'en-US': 'Custom rows' },
'desc': {
'zh-CN':
'<p>段落默认渲染 4 行,通过 <code>rows</code> 属性控制段落行数,显示的数量会比传入的数量多 1首行会被渲染一个长度 40% 的段首,末行会被渲染一个长度 60% 的段尾。</p>\n',
'en-US':
'<p>By default, paragraphs are rendered in 4 lines. The number of paragraph lines is controlled through the <code>rows</code> attribute. The number displayed will be 1 more than the number passed in. The first line will render the paragraph header at 40% length, and the last line will render the paragraph trailer at 60% length.</p>\n'
},
'codeFiles': ['custom-rows.vue']
},
{
'demoId': 'custom-layout',
'name': { 'zh-CN': '自定义排版', 'en-US': 'Custom layout' },
'desc': {
'zh-CN':
'<p>当默认排版不满足需求时,可自定义排版结构,通过 <code>class</code> 和 <code>style</code> 可自定义宽高等样式。</p>\n',
'en-US':
'<p>When the default layout does not meet the needs, the layout structure can be customized, and styles such as width and height can be customized through <code>class</code> and <code>style</code>.</p>\n'
},
'codeFiles': ['custom-layout.vue']
},
{
'demoId': 'loading-completed',
'name': { 'zh-CN': '加载完成', 'en-US': 'Loading completed' },
'desc': {
'zh-CN':
'<p>通过 <code>loading</code> 属性的值来表示是否加载完成。 可以通过具名插槽 <code>default</code> 来构建 <code>loading</code> 结束之后需要展示的真实 DOM 元素结构。</p>\n',
'en-US':
'<p>Whether the loading is completed is indicated by the value of the <code>loading</code> attribute. You can use the named slot <code>default</code> to build the real DOM element structure that needs to be displayed after <code>loading</code> ends.</p>\n'
},
'codeFiles': ['loading-completed.vue']
},
{
'demoId': 'custom-paragraph-width',
'name': { 'zh-CN': '自定义段落宽度', 'en-US': 'Custom paragraph width' },
'desc': {
'zh-CN':
'<p><code>rows-width</code> 属性可以自定义段落宽度,数组中的每一项可以为 <code>number</code> 或 <code>string</code>,当为 <code>number</code> 时,组件会自动增加 <code>px</code> 单位。</p>\n',
'en-US':
'<p>The <code>rows-width</code> attribute can customize the paragraph width. Each item in the array can be <code>number</code> or <code>string</code>. When it is <code>number< /code>, the component will automatically increase the <code>px</code> unit</p>\n'
},
'codeFiles': ['custom-paragraph-width.vue']
},
{
'demoId': 'fine-grained-mode',
'name': { 'zh-CN': '细粒度模式', 'en-US': 'Fine-grained mode' },
'desc': {
'zh-CN':
'<p>细粒度模式,<code>variant</code> 属性可以控制 <code>skeleton-item</code> 的形态可选值image / circle / square。<code>size</code> 属性可以控制 <code>skeleton-item</code> 的大小可选值medium / small / large。</p>\n',
'en-US':
'<p>Fine-grained mode, the <code>variant</code> attribute can control the shape of <code>skeleton-item</code>, optional values: image / circle / square. The <code>size</code> attribute can control the size of <code>skeleton-item</code>. Optional values: medium / small / large.</p>\n'
},
'codeFiles': ['fine-grained-mode.vue']
}
],
apis: [
{
'name': 'skeleton',
'type': 'component',
'props': [
{
'name': 'loading',
'type': 'boolean',
'defaultValue': 'true',
'desc': {
'zh-CN': '是否显示骨架屏,传 false 时会展示加载完成后的内容',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'custom-layout'
},
{
'name': 'active',
'type': 'boolean',
'defaultValue': 'true',
'desc': {
'zh-CN': '是否开启动画',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'fine-grained-mode'
},
{
'name': 'avatar',
'type': 'boolean',
'defaultValue': 'false',
'desc': {
'zh-CN': '是否显示头像',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'complex-demo'
},
{
'name': 'rows',
'type': 'number',
'defaultValue': '3',
'desc': {
'zh-CN': '默认排版,可配置段落显示行数',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'custom-rows'
},
{
'name': 'rows-width',
'type': 'number[] | string[]',
'defaultValue': '[]',
'desc': {
'zh-CN': '自定义段落每一行的宽度',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'custom-paragraph-width'
}
],
'slots': [
{
'name': 'default',
'type': '',
'defaultValue': '',
'desc': {
'zh-CN': '加载完成后显示的内容',
'en-US': 'Option default slot'
},
'demoId': 'custom-layout'
},
{
'name': 'placeholder',
'type': '',
'defaultValue': '',
'desc': {
'zh-CN': '自定义骨架屏结构',
'en-US': 'Option default slot'
},
'demoId': 'custom-layout'
}
]
},
{
'name': 'skeleton-item',
'type': 'component',
'props': [
{
'name': 'variant',
'type': 'IVariant',
'typeAnchorName': 'IVariant',
'defaultValue': 'square',
'desc': {
'zh-CN': '骨架屏形态',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'fine-grained-mode'
},
{
'name': 'size',
'type': 'ISize',
'typeAnchorName': 'ISize',
'defaultValue': 'medium',
'desc': {
'zh-CN': '针对 image 和 circle 形态,内置三种大小',
'en-US':
'Customized interface. A Promise object is returned. This parameter is mandatory when the framework service is not used.'
},
'demoId': 'fine-grained-mode'
}
]
}
],
types: [
{
name: 'IVariant',
type: 'type',
code: `type IVariant = 'image' | 'circle' | 'square'`
},
{
name: 'ISize',
type: 'type',
code: `type ISize = 'large' | 'medium' | 'small'`
}
]
}

View File

@ -208,7 +208,8 @@ export const cmpMenus = [
{ 'nameCn': '进度条', 'name': 'Progress', 'key': 'progress' },
{ 'nameCn': '树形控件', 'name': 'Tree', 'key': 'tree' },
{ 'nameCn': '穿梭框', 'name': 'Transfer', 'key': 'transfer' },
{ 'nameCn': '无限滚动', 'name': 'InfiniteScroll', 'key': 'infinite-scroll' }
{ 'nameCn': '无限滚动', 'name': 'InfiniteScroll', 'key': 'infinite-scroll' },
{ 'nameCn': '骨架屏', 'name': 'Skeleton', 'key': 'skeleton' }
]
},
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
import type { ISkeletonItemProps, ISharedRenderlessParamHooks, ISkeletonItemState, ISkeletonItemApi } from '@/types'
export const api = ['state']
export const renderless = (props: ISkeletonItemProps, { reactive, inject }: ISharedRenderlessParamHooks) => {
const state: ISkeletonItemState = reactive({
isActive: inject('active', false)
})
const api: ISkeletonItemApi = {
state
}
return api
}

View File

@ -0,0 +1,13 @@
import { isNumber, isNull } from '../common/type'
export const toPxStyle = (value: string | number): undefined | string => {
if (isNull(value)) {
return undefined
}
if (isNumber(value)) {
return `${value}px`
}
return String(value)
}

View File

@ -0,0 +1,14 @@
import type { ISkeletonProps, ISkeletonApi, ISharedRenderlessParamHooks } from '@/types'
import { toPxStyle } from './index'
export const api = ['toPxStyle']
export const renderless = (props: ISkeletonProps, { toRefs, provide }: ISharedRenderlessParamHooks): ISkeletonApi => {
const { active } = toRefs(props)
provide('active', active)
const api = {
toPxStyle
}
return api
}

View File

@ -154,6 +154,8 @@ export * from './select-mobile.type'
export * from './select-view.type'
export * from './selected-box.type'
export * from './shared.type'
export * from './skeleton.type'
export * from './skeleton-item.type'
export * from './slide-bar.type'
export * from './slider.type'
export * from './slider-button.type'

View File

@ -0,0 +1,14 @@
import type { ExtractPropTypes } from 'vue'
import type { skeletonItemProps, $constants } from '@/skeleton-item/src'
export type ISkeletonItemProps = ExtractPropTypes<typeof skeletonItemProps>
export type ISkeletonItemConstants = typeof $constants
export interface ISkeletonItemState {
isActive: boolean
}
export interface ISkeletonItemApi {
state: ISkeletonItemState
}

View File

@ -0,0 +1,10 @@
import type { ExtractPropTypes } from 'vue'
import type { skeletonProps, $constants } from '@/skeleton/src'
export type ISkeletonProps = ExtractPropTypes<typeof skeletonProps>
export type ISkeletonConstants = typeof $constants
export interface ISkeletonApi {
toPxStyle: (value: string | number) => string | undefined
}

View File

@ -0,0 +1,80 @@
@import '../custom.less';
@import './vars.less';
@skeleton-item-prefix-cls: ~'@{css-prefix}skeleton-item';
.@{skeleton-item-prefix-cls} {
.component-css-vars-skeleton-item();
&--active {
&.@{skeleton-item-prefix-cls} {
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0) 60%) #f2f2f3;
background-size: 200% 100%;
background-position-x: 180%;
animation: 2s skeleton-loading ease-in-out infinite;
}
}
background-color: var(--ti-skeleton-item-bg-color);
border-radius: var(--ti-skeleton-item-border-radius);
&--square {
width: 100%;
height: var(--ti-skeleton-item-square-height);
}
&--circle {
border-radius: var(--ti-skeleton-item-circle-border-radius);
&.@{skeleton-item-prefix-cls} {
&--small {
width: var(--ti-skeleton-item-circle-small-size);
height: var(--ti-skeleton-item-circle-small-size);
}
&--medium {
width: var(--ti-skeleton-item-circle-medium-size);
height: var(--ti-skeleton-item-circle-medium-size);
}
&--large {
width: var(--ti-skeleton-item-circle-large-size);
height: var(--ti-skeleton-item-circle-large-size);
}
}
}
&--image {
display: flex;
justify-content: center;
align-items: center;
svg {
width: 40%;
height: 40%;
fill: var(--ti-skeleton-item-image-icon-color);
}
&.@{skeleton-item-prefix-cls} {
&--small {
width: var(--ti-skeleton-item-image-small-size-width);
height: var(--ti-skeleton-item-image-small-size-height);
}
&--medium {
width: var(--ti-skeleton-item-image-medium-size-width);
height: var(--ti-skeleton-item-image-medium-size-height);
}
&--large {
width: var(--ti-skeleton-item-image-large-size-width);
height: var(--ti-skeleton-item-image-large-size-height);
}
}
}
}
@keyframes skeleton-loading {
to {
background-position-x: -20%;
}
}

View File

@ -0,0 +1,16 @@
.component-css-vars-skeleton-item() {
--ti-skeleton-item-bg-color: var(--ti-base-color-bg-5, #f5f5f6);
--ti-skeleton-item-border-radius: var(--ti-common-border-radius-1, 4px);
--ti-skeleton-item-image-icon-color: var(--ti-base-color-common-2, #adb0b8);
--ti-skeleton-item-square-height: var(--ti-common-size-4x, 16px);
--ti-skeleton-item-circle-border-radius: var(--ti-common-border-radius-3, 50%);
--ti-skeleton-item-circle-large-size: var(--ti-common-size-15x, 60px);
--ti-skeleton-item-circle-medium-size: var(--ti-common-size-10x, 40px);
--ti-skeleton-item-circle-small-size: var(--ti-common-size-5x, 20px);
--ti-skeleton-item-image-small-size-height: var(--ti-common-size-15x, 60px);
--ti-skeleton-item-image-small-size-width: var(--ti-common-size-15x, 60px);
--ti-skeleton-item-image-medium-size-height: var(--ti-common-size-25x, 100px);
--ti-skeleton-item-image-medium-size-width: var(--ti-common-size-25x, 100px);
--ti-skeleton-item-image-large-size-height: var(--ti-common-size-50x, 200px);
--ti-skeleton-item-image-large-size-width: var(--ti-common-size-50x, 200px);
}

View File

@ -0,0 +1,42 @@
@import '../custom.less';
@import './vars.less';
@skeleton-item-prefix-cls: ~'@{css-prefix}skeleton';
.@{skeleton-item-prefix-cls} {
.component-css-vars-skeleton();
width: 100%;
&__article {
display: flex;
width: 100%;
}
&__avatar {
position: relative;
overflow: hidden;
flex-shrink: 0;
width: var(--ti-skeleton-avatar-size);
height: var(--ti-skeleton-avatar-size);
margin-right: var(--ti-skeleton-avatar-margin-right);
}
&__section {
width: 100%;
}
&-item__title {
width: var(--ti-skeleton-title-width);
margin-bottom: var(--ti-skeleton-title-margin-bottom);
}
&-item--square {
margin-bottom: var(--ti-skeleton-row-margin-bottom);
&:last-child {
width: var(--ti-skeleton-last-row-width);
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,9 @@
.component-css-vars-skeleton() {
--ti-skeleton-avatar-size: var(--ti-common-size-10x, 40px);
--ti-skeleton-avatar-background-color: var(--ti-common-color-bg-disabled, #f5f5f6);
--ti-skeleton-avatar-margin-right: var(--ti-common-space-4x, 16px);
--ti-skeleton-title-margin-bottom: var(--ti-common-space-4x, 16px);
--ti-skeleton-title-width: 40%;
--ti-skeleton-row-margin-bottom: var(--ti-common-space-3x, 12px);
--ti-skeleton-last-row-width: 60%;
}

View File

@ -204,6 +204,8 @@
"@opentiny/vue-select-mobile": "workspace:~",
"@opentiny/vue-select-view": "workspace:~",
"@opentiny/vue-selected-box": "workspace:~",
"@opentiny/vue-skeleton": "workspace:~",
"@opentiny/vue-skeleton-item": "workspace:~",
"@opentiny/vue-slide-bar": "workspace:~",
"@opentiny/vue-slider": "workspace:~",
"@opentiny/vue-slider-button": "workspace:~",

View File

@ -0,0 +1,20 @@
import { mountPcMode } from '@opentiny-internal/vue-test-utils'
import { test, describe, expect } from 'vitest'
import SkeletonItem from '@opentiny/vue-skeleton-item'
import { nextTick } from 'vue'
describe('PC Mode', () => {
const mount = mountPcMode
test('variant', async () => {
const wrapper = mount(() => <SkeletonItem variant="circle"></SkeletonItem>)
await nextTick()
expect(wrapper.classes()).toContain('tiny-skeleton-item--circle')
})
test('size', async () => {
const wrapper = mount(() => <SkeletonItem variant="image" size="small"></SkeletonItem>)
await nextTick()
expect(wrapper.classes()).toContain('tiny-skeleton-item--small')
})
})

View File

@ -0,0 +1,24 @@
import SkeletonItem from './src/index'
import '@opentiny/vue-theme/skeleton-item/index.less'
import { version } from './package.json'
SkeletonItem.model = {
prop: 'modelValue',
event: 'update:modelValue'
}
/* istanbul ignore next */
SkeletonItem.install = function (Vue) {
Vue.component(SkeletonItem.name, SkeletonItem)
}
SkeletonItem.version = version
/* istanbul ignore next */
if (process.env.BUILD_TARGET === 'runtime') {
if (typeof window !== 'undefined' && window.Vue) {
SkeletonItem.install(window.Vue)
}
}
export default SkeletonItem

View File

@ -0,0 +1,19 @@
{
"name": "@opentiny/vue-skeleton-item",
"version": "3.13.0",
"description": "",
"main": "lib/index.js",
"module": "index.ts",
"sideEffects": false,
"type": "module",
"dependencies": {
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/vue-theme": "workspace:~",
"@opentiny/vue-common": "workspace:~"
},
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "^0.31.0"
},
"license": "MIT"
}

View File

@ -0,0 +1,34 @@
import { $props, $setup, $prefix, defineComponent } from '@opentiny/vue-common'
import type { PropType } from '@opentiny/vue-common'
import template from 'virtual-template?pc'
export type VariantType = 'image' | 'circle' | 'square'
export type SizeType = 'large' | 'medium' | 'small'
const $constants = {}
export const skeletonItemProps = {
...$props,
_constants: {
type: Object,
default: () => $constants
},
modelValue: String,
variant: {
type: String as PropType<VariantType>,
default: 'square'
},
size: {
type: String as PropType<SizeType>,
default: 'medium'
}
}
export default defineComponent({
name: $prefix + 'SkeletonItem',
props: skeletonItemProps,
setup(props, context) {
return $setup({ props, context, template })
}
})

View File

@ -0,0 +1,29 @@
<template>
<div
class="tiny-skeleton-item"
:class="[
variant ? 'tiny-skeleton-item--' + variant : '',
size && variant !== 'square' ? 'tiny-skeleton-item--' + size : '',
state.isActive ? 'tiny-skeleton-item--active' : ''
]"
>
<icon-rich-text-image v-if="variant === 'image'"></icon-rich-text-image>
</div>
</template>
<script>
import { renderless, api } from '@opentiny/vue-renderless/skeleton-item/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
import { iconRichTextImage } from '@opentiny/vue-icon'
export default defineComponent({
emits: ['update:modelValue'],
props: [...props, 'modelValue', 'variant', 'size'],
components: {
IconRichTextImage: iconRichTextImage()
},
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>

View File

@ -0,0 +1,65 @@
import { mountPcMode } from '@opentiny-internal/vue-test-utils'
import { test, describe, expect } from 'vitest'
import Skeleton from '@opentiny/vue-skeleton'
import { nextTick } from 'vue'
describe('PC Mode', () => {
const mount = mountPcMode
test('base usage', () => {
const wrapper = mount(() => <Skeleton></Skeleton>)
expect(wrapper.exists()).toBe(true)
})
test('active', async () => {
const wrapper = mount(() => <Skeleton></Skeleton>)
await nextTick()
expect(wrapper.find('.tiny-skeleton-item').classes()).toContain('tiny-skeleton-item--active')
})
test('loading & rows', async () => {
const wrapper = mount(() => <Skeleton loading rows={3}></Skeleton>)
await nextTick()
expect(wrapper.find('.tiny-skeleton').exists()).toBe(true)
expect(wrapper.findAll('.tiny-skeleton-item').length).toBe(4)
})
test('slot', async () => {
const wrapper = mount(() => (
<Skeleton
v-slots={{
placeholder: () => <div class="tiny-placeholder"></div>
}}></Skeleton>
))
await nextTick()
expect(wrapper.find('.tiny-placeholder').exists()).toBe(true)
})
test('avatar', async () => {
const wrapper = mount(() => <Skeleton avatar></Skeleton>)
await nextTick()
expect(wrapper.find('.tiny-skeleton__avatar').exists()).toBe(true)
})
test('rows-width', async () => {
const widths = ['200px', '100px', '50px']
const wrapper = mount(<Skeleton></Skeleton>, {
props: {
'rows-width': widths
}
})
await nextTick()
const skeletonItems = wrapper.findAll('.tiny-skeleton-item')
expect(skeletonItems).toHaveLength(4)
skeletonItems.shift()
skeletonItems.forEach((item, index) => {
const computedStyles = getComputedStyle(item.element)
const width = computedStyles.width
expect(width).toBe(widths[index])
})
})
})

View File

@ -0,0 +1,24 @@
import Skeleton from './src/index'
import '@opentiny/vue-theme/skeleton/index.less'
import { version } from './package.json'
Skeleton.model = {
prop: 'modelValue',
event: 'update:modelValue'
}
/* istanbul ignore next */
Skeleton.install = function (Vue) {
Vue.component(Skeleton.name, Skeleton)
}
Skeleton.version = version
/* istanbul ignore next */
if (process.env.BUILD_TARGET === 'runtime') {
if (typeof window !== 'undefined' && window.Vue) {
Skeleton.install(window.Vue)
}
}
export default Skeleton

View File

@ -0,0 +1,20 @@
{
"name": "@opentiny/vue-skeleton",
"version": "3.13.0",
"description": "",
"main": "lib/index.js",
"module": "index.ts",
"sideEffects": false,
"type": "module",
"dependencies": {
"@opentiny/vue-common": "workspace:~",
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/vue-theme": "workspace:~",
"@opentiny/vue-skeleton-item": "workspace:~"
},
"license": "MIT",
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "0.31.0"
}
}

View File

@ -0,0 +1,42 @@
import { $props, $setup, $prefix, defineComponent } from '@opentiny/vue-common'
import type { PropType } from '@opentiny/vue-common'
import template from 'virtual-template?pc'
const $constants = {}
export const skeletonProps = {
...$props,
_constants: {
type: Object,
default: () => $constants
},
modelValue: String,
loading: {
type: Boolean,
default: true
},
rows: {
type: Number,
default: 3
},
avatar: {
type: Boolean,
default: false
},
rowsWidth: {
type: Array as PropType<(string | number)[]>,
default: () => []
},
active: {
type: Boolean,
default: true
}
}
export default defineComponent({
name: $prefix + 'Skeleton',
props: skeletonProps,
setup(props, context) {
return $setup({ props, context, template })
}
})

View File

@ -0,0 +1,39 @@
<template>
<div class="tiny-skeleton">
<template v-if="loading">
<slot name="placeholder">
<div class="tiny-skeleton__article">
<tiny-skeleton-item variant="circle" class="tiny-skeleton__avatar" v-if="avatar"> </tiny-skeleton-item>
<div class="tiny-skeleton__section">
<tiny-skeleton-item class="tiny-skeleton-item__title"></tiny-skeleton-item>
<tiny-skeleton-item
v-for="(item, index) in rows"
:key="item"
:style="{ width: toPxStyle(rowsWidth[index]) }"
></tiny-skeleton-item>
</div>
</div>
</slot>
</template>
<template v-else>
<slot></slot>
</template>
</div>
</template>
<script>
import { renderless, api } from '@opentiny/vue-renderless/skeleton/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
import SkeletonItem from '@opentiny/vue-skeleton-item'
export default defineComponent({
emits: ['update:modelValue'],
props: [...props, 'modelValue', 'loading', 'rows', 'avatar', 'rowsWidth', 'active'],
components: {
TinySkeletonItem: SkeletonItem
},
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>