feat(marquee): new component

This commit is contained in:
07akioni 2024-10-07 22:19:38 +08:00
parent 9a23d7eed4
commit acced75df6
30 changed files with 469 additions and 2 deletions

View File

@ -10,6 +10,7 @@
### Features ### Features
- 🌟 Adds `n-marquee` component.
- `n-image` adds `error` slot, closes [#5649](https://github.com/tusen-ai/naive-ui/issues/5649) - `n-image` adds `error` slot, closes [#5649](https://github.com/tusen-ai/naive-ui/issues/5649)
- `n-date-picker` adds `date-format` prop. - `n-date-picker` adds `date-format` prop.
- `n-progress`'s `color` prop supports gradient config. - `n-progress`'s `color` prop supports gradient config.

View File

@ -10,6 +10,7 @@
### Features ### Features
- 🌟 新增 `n-marquee` 组件
- `n-image` 新增 `error` 插槽,关闭 [#5649](https://github.com/tusen-ai/naive-ui/issues/5649) - `n-image` 新增 `error` 插槽,关闭 [#5649](https://github.com/tusen-ai/naive-ui/issues/5649)
- `n-date-picker` 新增 `date-format` 属性 - `n-date-picker` 新增 `date-format` 属性
- `n-progress``color` 属性支持渐变色配置 - `n-progress``color` 属性支持渐变色配置

View File

@ -590,6 +590,10 @@ export const enComponentRoutes = [
path: 'highlight', path: 'highlight',
component: () => component: () =>
import('../../src/highlight/demos/enUS/index.demo-entry.md') import('../../src/highlight/demos/enUS/index.demo-entry.md')
},
{
path: 'marquee',
component: () => import('../../src/marquee/demos/enUS/index.demo-entry.md')
} }
] ]
@ -999,6 +1003,10 @@ export const zhComponentRoutes = [
path: 'highlight', path: 'highlight',
component: () => component: () =>
import('../../src/highlight/demos/zhCN/index.demo-entry.md') import('../../src/highlight/demos/zhCN/index.demo-entry.md')
},
{
path: 'marquee',
component: () => import('../../src/marquee/demos/zhCN/index.demo-entry.md')
} }
] ]

View File

@ -663,6 +663,13 @@ export function createComponentMenuOptions({ lang, theme }) {
enSuffix: true, enSuffix: true,
path: '/drawer' path: '/drawer'
}, },
{
en: 'Marquee',
zh: '跑马灯',
enSuffix: true,
path: '/marquee',
isNew: true
},
{ {
en: 'Message', en: 'Message',
zh: '信息', zh: '信息',

View File

@ -10,6 +10,7 @@ bordered.vue
closable.vue closable.vue
icon.vue icon.vue
no-icon.vue no-icon.vue
marquee.vue
``` ```
## API ## API

View File

@ -0,0 +1,15 @@
<markdown>
# Marquee
You can use `n-marquee` to achieve marquee effect.
</markdown>
<template>
<n-alert type="error" title="Warning">
<n-marquee>
<div style="margin-right: 64px">
Test environment is offline again.
</div>
</n-marquee>
</n-alert>
</template>

View File

@ -12,6 +12,7 @@ bordered.vue
closable.vue closable.vue
icon.vue icon.vue
no-icon.vue no-icon.vue
marquee.vue
rtl-debug.vue rtl-debug.vue
empty-debug.vue empty-debug.vue
``` ```

View File

@ -0,0 +1,15 @@
<markdown>
# 跑马灯
你可以配合 `n-marquee` 实现轮播的效果
</markdown>
<template>
<n-alert type="error" title="呵呵">
<n-marquee>
<div style="margin-right: 64px">
测试环境又挂了
</div>
</n-marquee>
</n-alert>
</template>

View File

@ -53,6 +53,7 @@ export * from './list'
export * from './loading-bar' export * from './loading-bar'
export * from './log' export * from './log'
export * from './infinite-scroll' export * from './infinite-scroll'
export * from './marquee'
export * from './menu' export * from './menu'
export * from './mention' export * from './mention'
export * from './message' export * from './message'

View File

@ -100,6 +100,7 @@ import type { RowTheme } from '../../legacy-grid/styles'
import type { SplitTheme } from '../../split/styles' import type { SplitTheme } from '../../split/styles'
import type { FlexTheme } from '../../flex/styles' import type { FlexTheme } from '../../flex/styles'
import type { FloatButtonGroupTheme } from '../../float-button-group/styles' import type { FloatButtonGroupTheme } from '../../float-button-group/styles'
import type { MarqueeTheme } from '../../marquee/styles'
import type { Katex } from './katex' import type { Katex } from './katex'
import type { GlobalTheme, GlobalThemeOverrides } from './interface' import type { GlobalTheme, GlobalThemeOverrides } from './interface'
@ -190,6 +191,7 @@ export interface GlobalThemeWithoutCommon {
Watermark?: WatermarkTheme Watermark?: WatermarkTheme
Split?: SplitTheme Split?: SplitTheme
Row?: RowTheme Row?: RowTheme
Marquee?: MarqueeTheme
// internal // internal
InternalSelectMenu?: InternalSelectMenuTheme InternalSelectMenu?: InternalSelectMenuTheme
InternalSelection?: InternalSelectionTheme InternalSelection?: InternalSelectionTheme

View File

@ -0,0 +1,16 @@
<markdown>
# Auto fill
Use `auto-fill` prop to fill all the blank space that left.
</markdown>
<template>
<n-marquee auto-fill>
<n-image
width="80"
height="80"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
style="margin-right: 24px"
/>
</n-marquee>
</template>

View File

@ -0,0 +1,15 @@
<markdown>
# Basic
Put text into marquee:
</markdown>
<template>
<n-marquee>
<div style="margin-right: 64px">
In 2020 Noel returned to the legendary Rockfield Studios in Wales for the
first time since the band recorded the album, looking back at his memories
and reflecting on the albums legacy.
</div>
</n-marquee>
</template>

View File

@ -0,0 +1,16 @@
<markdown>
# Image
You can put any content inside marquee.
</markdown>
<template>
<n-marquee>
<n-image
width="80"
height="80"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
style="margin-right: 24px"
/>
</n-marquee>
</template>

View File

@ -0,0 +1,28 @@
# Marquee
A trivia: There's a deprecated HTML Element called `marquee`.
Available since `NEXT_VERSION`.
## Demos
```demo
basic.vue
image.vue
auto-fill.vue
```
## API
### Marquee Props
| Name | Type | Default | Description | Version |
| --- | --- | --- | --- | --- |
| auto-fill | `boolean` | `false` | Whether to fill the blank of the container using its content repeatly. | NEXT_VERSION |
| speed | `number` | `48` | The speed calculated as pixels/second. | NEXT_VERSION |
### Marquee Slots
| Name | Parameters | Description | Version |
| ------- | ---------- | ----------- | ------------ |
| default | `()` | Content. | NEXT_VERSION |

View File

@ -0,0 +1,16 @@
<markdown>
# 自动填充
使用 `auto-fill` 属性让内容铺满空白空间
</markdown>
<template>
<n-marquee auto-fill>
<n-image
width="80"
height="80"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
style="margin-right: 24px"
/>
</n-marquee>
</template>

View File

@ -0,0 +1,11 @@
<markdown>
# 基础用法
在跑马灯中输入文字
</markdown>
<template>
<n-marquee>
谁用运气换呼吸 谁用灵魂换稻米 谁用运气换呼吸 谁用灵魂换稻米&nbsp;
</n-marquee>
</template>

View File

@ -0,0 +1,16 @@
<markdown>
# 图片
你可以将任何内容放入跑马灯中
</markdown>
<template>
<n-marquee>
<n-image
width="80"
height="80"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
style="margin-right: 24px"
/>
</n-marquee>
</template>

View File

@ -0,0 +1,28 @@
# 跑马灯 Marquee
我有一个高中同学,当时他的口头禅是:“滚滚滚。”
`NEXT_VERSION` 开始提供。
## 演示
```demo
basic.vue
image.vue
auto-fill.vue
```
## API
### Marquee Props
| 名称 | 类型 | 默认值 | 说明 | 版本 |
| --- | --- | --- | --- | --- |
| auto-fill | `boolean` | `false` | 是否重复的用内容铺满容器的空白 | NEXT_VERSION |
| speed | `number` | `48` | 移动的速度,单位是像素每秒 | NEXT_VERSION |
### Marquee Slots
| 名称 | 参数 | 说明 | 版本 |
| ------- | ---- | ---- | ------------ |
| default | `()` | 内容 | NEXT_VERSION |

2
src/marquee/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { default as NMarqueue } from './src/Marquee'
export type * from './src/public-types'

148
src/marquee/src/Marquee.tsx Normal file
View File

@ -0,0 +1,148 @@
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { VResizeObserver } from 'vueuc'
import { repeat } from 'seemly'
import { useConfig, useTheme } from '../../_mixins'
import { marqueeLight } from '../styles'
import style from './styles/index.cssr'
import { marqueeProps } from './props'
export default defineComponent({
name: 'Marquee',
props: marqueeProps,
setup(props) {
const { mergedClsPrefixRef } = useConfig(props)
useTheme(
'Marquee',
'-marquee',
style,
marqueeLight,
props,
mergedClsPrefixRef
)
const containerElRef = ref<HTMLDivElement | null>(null)
const contentWidthRef = ref(-1)
const containerWidthRef = ref(-1)
const playStateRef = ref<'paused' | 'running'>('running')
const repeatCountInOneGroupRef = computed(() => {
if (!props.autoFill)
return 1
const { value: contentWidth } = contentWidthRef
const { value: containerWidth } = containerWidthRef
if (contentWidth === -1 || containerWidth === -1)
return 1
return Math.ceil(containerWidthRef.value / contentWidth)
})
const durationRef = computed(() => {
const { value: contentWidth } = contentWidthRef
if (contentWidth === -1)
return 0
return (contentWidth * repeatCountInOneGroupRef.value) / props.speed
})
const animationCssVarsRef = computed(() => {
return {
'--n-play': playStateRef.value,
'--n-direction': 'normal',
'--n-duration': `${durationRef.value}s`,
'--n-delay': '0s',
'--n-iteration-count': 'infinite',
'--n-min-width': 'auto'
}
})
function resetScrollState() {
playStateRef.value = 'paused'
nextTick().then(() => {
void containerElRef.value?.offsetTop
playStateRef.value = 'running'
})
}
function handleContainerResize(entry: ResizeObserverEntry) {
containerWidthRef.value = entry.contentRect.width
}
function handleContentResize(entry: ResizeObserverEntry) {
contentWidthRef.value = entry.contentRect.width
}
function handleAnimationIteration() {
resetScrollState()
}
return {
mergedClsPrefix: mergedClsPrefixRef,
animationCssVars: animationCssVarsRef,
containerElRef,
repeatCountInOneGroup: repeatCountInOneGroupRef,
handleContainerResize,
handleContentResize,
handleAnimationIteration
}
},
render() {
const {
$slots,
mergedClsPrefix,
animationCssVars,
repeatCountInOneGroup,
handleAnimationIteration
} = this
const originalNode = (
<VResizeObserver onResize={this.handleContentResize}>
<div
class={`${mergedClsPrefix}-marquee__item ${mergedClsPrefix}-marquee__original-item`}
>
{$slots}
</div>
</VResizeObserver>
)
const mirrorNode = (
<div class={`${mergedClsPrefix}-marquee__item`}>{$slots}</div>
)
if (this.autoFill) {
return (
<VResizeObserver onResize={this.handleContainerResize}>
<div
class={`${mergedClsPrefix}-marquee ${mergedClsPrefix}-marquee--auto-fill`}
ref="containerElRef"
style={animationCssVars}
>
<div
class={`${mergedClsPrefix}-marquee__group`}
onAnimationiteration={handleAnimationIteration}
>
{originalNode}
{repeat(repeatCountInOneGroup - 1, mirrorNode)}
</div>
<div class={`${mergedClsPrefix}-marquee__group`}>
{repeat(repeatCountInOneGroup, mirrorNode)}
</div>
</div>
</VResizeObserver>
)
}
else {
return (
<div
class={[`${mergedClsPrefix}-marquee`]}
ref="containerElRef"
style={animationCssVars}
>
<div
class={`${mergedClsPrefix}-marquee__group`}
onAnimationiteration={handleAnimationIteration}
>
{originalNode}
</div>
<div class={`${mergedClsPrefix}-marquee__group`}>{mirrorNode}</div>
</div>
)
}
}
})

15
src/marquee/src/props.ts Normal file
View File

@ -0,0 +1,15 @@
import type { ThemeProps } from '../../_mixins'
import { useTheme } from '../../_mixins'
import type { ExtractPublicPropTypes } from '../../_utils'
import type { MarqueeTheme } from '../styles'
export const marqueeProps = {
...(useTheme.props as ThemeProps<MarqueeTheme>),
autoFill: Boolean,
speed: {
type: Number,
default: 48
}
}
export type MarqueeProps = ExtractPublicPropTypes<typeof marqueeProps>

View File

@ -0,0 +1 @@
export type { MarqueeProps } from './props'

View File

@ -0,0 +1,40 @@
import { c, cB, cE, cNotM } from '../../../_utils/cssr'
// vars:
// --n-play
// --n-direction
// --n-duration
// --n-delay
// --n-iteration-count
// --n-min-width
export default c([
cB('marquee', `
overflow: hidden;
display: flex;
`, [
cE('group', `
flex: 0 0 auto;
min-width: var(--n-min-width);
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
animation: n-marquee var(--n-duration) linear var(--n-delay) var(--n-iteration-count);
animation-play-state: var(--n-play);
animation-delay: var(--n-delay);
animation-direction: var(--n-direction);
`),
cNotM('auto-fill', [
cE('group', `min-width: 100%;`),
cE('item', `min-width: 100%;`)
])
]),
c('@keyframes n-marquee', {
from: {
transform: 'translateX(0)'
},
to: {
transform: 'translateX(-100%)'
}
})
])

View File

@ -0,0 +1,11 @@
import { commonDark } from '../../_styles/common'
import type { MarqueeTheme } from './light'
import { self } from './light'
const marqueeDark: MarqueeTheme = {
name: 'Marquee',
common: commonDark,
self
}
export default marqueeDark

View File

@ -0,0 +1,3 @@
export { default as marqueeDark } from './dark'
export { default as marqueeLight } from './light'
export type { MarqueeTheme, MarqueeThemeVars } from './light'

View File

@ -0,0 +1,17 @@
import { commonLight } from '../../_styles/common'
import type { Theme } from '../../_mixins'
export function self() {
return {}
}
export type MarqueeThemeVars = ReturnType<typeof self>
const marqueeLight: Theme<'Marquee', MarqueeThemeVars> = {
name: 'Marquee',
common: commonLight,
self
}
export default marqueeLight
export type MarqueeTheme = typeof marqueeLight

View File

@ -0,0 +1,8 @@
import { mount } from '@vue/test-utils'
import { NMarqueue } from '../index'
describe('n-marquee', () => {
it('should work with import on demand', () => {
mount(NMarqueue)
})
})

View File

@ -0,0 +1,20 @@
/**
* @jest-environment node
*/
import { createSSRApp, h } from 'vue'
import { renderToString } from '@vue/server-renderer'
import { setup } from '@css-render/vue3-ssr'
import { NEmpty } from '../..'
describe('sSR', () => {
it('works', async () => {
const app = createSSRApp(() => <NEmpty />)
setup(app)
try {
await renderToString(app)
}
catch (e) {
expect(e).not.toBeTruthy()
}
})
})

View File

@ -85,6 +85,7 @@ import { watermarkDark } from '../watermark/styles'
import { splitDark } from '../split/styles' import { splitDark } from '../split/styles'
import { flexDark } from '../styles' import { flexDark } from '../styles'
import { floatButtonGroupDark } from '../float-button-group/styles' import { floatButtonGroupDark } from '../float-button-group/styles'
import { marqueeDark } from '../marquee/styles'
import type { BuiltInGlobalTheme } from './interface' import type { BuiltInGlobalTheme } from './interface'
export const darkTheme: BuiltInGlobalTheme = { export const darkTheme: BuiltInGlobalTheme = {
@ -175,5 +176,6 @@ export const darkTheme: BuiltInGlobalTheme = {
Watermark: watermarkDark, Watermark: watermarkDark,
Split: splitDark, Split: splitDark,
FloatButton: floatButtonDark, FloatButton: floatButtonDark,
FloatButtonGroup: floatButtonGroupDark FloatButtonGroup: floatButtonGroupDark,
Marquee: marqueeDark
} }

View File

@ -87,6 +87,7 @@ import { watermarkLight } from '../watermark/styles'
import { splitLight } from '../split/styles' import { splitLight } from '../split/styles'
import { flexLight } from '../flex/styles' import { flexLight } from '../flex/styles'
import { floatButtonGroupLight } from '../float-button-group/styles' import { floatButtonGroupLight } from '../float-button-group/styles'
import { marqueeLight } from '../marquee/styles'
import type { BuiltInGlobalTheme } from './interface' import type { BuiltInGlobalTheme } from './interface'
export const lightTheme: BuiltInGlobalTheme = { export const lightTheme: BuiltInGlobalTheme = {
@ -177,5 +178,6 @@ export const lightTheme: BuiltInGlobalTheme = {
Watermark: watermarkLight, Watermark: watermarkLight,
Split: splitLight, Split: splitLight,
FloatButton: floatButtonLight, FloatButton: floatButtonLight,
FloatButtonGroup: floatButtonGroupLight FloatButtonGroup: floatButtonGroupLight,
Marquee: marqueeLight
} }