diff --git a/packages/ccui/ui/shared/hooks/use-namespace.ts b/packages/ccui/ui/shared/hooks/use-namespace.ts index 33391d46..2e20e447 100644 --- a/packages/ccui/ui/shared/hooks/use-namespace.ts +++ b/packages/ccui/ui/shared/hooks/use-namespace.ts @@ -3,6 +3,7 @@ export interface UseNamespace { e: (el: string) => string m: (mo: string) => string em: (el: string, mo: string) => string + is: (name: string) => string } function createBem( @@ -35,10 +36,12 @@ export function useNamespace(block: string, needDot = false): UseNamespace { modifier ? createBem(namespace, '', modifier) : '' const em = (element: string, modifier: string) => element && modifier ? createBem(namespace, element, modifier) : '' + const is = (name: string) => `is-${name}` return { b, e, m, em, + is, } } diff --git a/packages/ccui/ui/timeline/index.ts b/packages/ccui/ui/timeline/index.ts new file mode 100644 index 00000000..3b2ae153 --- /dev/null +++ b/packages/ccui/ui/timeline/index.ts @@ -0,0 +1,19 @@ +import type { App } from 'vue' +import Timeline from './src/timeline' +import TimelineItem from './src/timeline-item' + +Timeline.install = function (app: App) { + app.component(Timeline.name!, Timeline) + app.component(TimelineItem.name!, TimelineItem) +} + +export { Timeline, TimelineItem } + +export default { + title: 'Timeline 时间线', + category: '数据展示', + status: '100%', + install(app: App) { + Timeline.install(app) + }, +} diff --git a/packages/ccui/ui/timeline/src/timeline-item.tsx b/packages/ccui/ui/timeline/src/timeline-item.tsx new file mode 100644 index 00000000..de4028f6 --- /dev/null +++ b/packages/ccui/ui/timeline/src/timeline-item.tsx @@ -0,0 +1,103 @@ +import type { TimelineItemProps } from './timeline-types' +import { computed, defineComponent, h } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { timelineItemProps } from './timeline-types' + +export default defineComponent({ + name: 'CTimelineItem', + props: timelineItemProps, + emits: [], + setup(props: TimelineItemProps, { slots }) { + const ns = useNamespace('timeline-item') + + // 计算节点的样式类名 + const nodeClasses = computed(() => { + return [ + ns.e('node'), + ns.em('node', props.size), + props.type && ns.em('node', props.type), + props.hollow && ns.is('hollow'), + ].filter(Boolean) + }) + + // 计算时间戳的样式类名 + const timestampClasses = computed(() => { + return [ + ns.e('timestamp'), + ns.is(props.placement), + ] + }) + + // 渲染图标 + const renderIcon = () => { + if (props.icon) { + if (typeof props.icon === 'string') { + return + } + else { + // 如果是组件,使用 h 函数渲染 + return h(props.icon, { class: ns.e('icon') }) + } + } + return null + } + + // 渲染节点 + const renderNode = () => { + if (slots.dot) { + return ( +
+ {slots.dot()} +
+ ) + } + + return ( +
+ {renderIcon()} +
+ ) + } + + // 渲染时间戳 + const renderTimestamp = () => { + if (props.hideTimestamp) + return null + + return ( +
+ {props.timestamp} +
+ ) + } + + return () => { + return ( +
  • + {/* 连接线 */} +
    + + {/* 节点 */} + {renderNode()} + + {/* 内容区域 */} +
    + {/* 顶部时间戳 */} + {props.placement === 'top' && renderTimestamp()} + + {/* 内容 */} +
    + {slots.default && slots.default()} +
    + + {/* 底部时间戳 */} + {props.placement === 'bottom' && renderTimestamp()} +
    +
  • + ) + } + }, +}) diff --git a/packages/ccui/ui/timeline/src/timeline-types.ts b/packages/ccui/ui/timeline/src/timeline-types.ts new file mode 100644 index 00000000..b170587c --- /dev/null +++ b/packages/ccui/ui/timeline/src/timeline-types.ts @@ -0,0 +1,81 @@ +import type { Component, ExtractPropTypes, PropType } from 'vue' + +// Timeline 主组件的 props +export const timelineProps = { + // 暂时没有特殊的 props,主要通过插槽传递 TimelineItem +} as const + +export type TimelineProps = ExtractPropTypes + +export type TimelineItemType = 'primary' | 'success' | 'warning' | 'danger' | 'info' | '' + +// TimelineItem 组件的 props +export const timelineItemProps = { + /** + * 时间戳内容 + */ + timestamp: { + type: String, + default: '', + }, + /** + * 是否隐藏时间戳 + */ + hideTimestamp: { + type: Boolean, + default: false, + }, + /** + * 是否垂直居中 + */ + center: { + type: Boolean, + default: false, + }, + /** + * 时间戳位置 + */ + placement: { + type: String as PropType<'top' | 'bottom'>, + default: 'bottom', + validator: (value: string) => ['top', 'bottom'].includes(value), + }, + /** + * 节点类型 + */ + type: { + type: String as PropType, + default: '', + validator: (value: string) => ['primary', 'success', 'warning', 'danger', 'info', ''].includes(value), + }, + /** + * 节点颜色 + */ + color: { + type: String, + default: '', + }, + /** + * 节点尺寸 + */ + size: { + type: String as PropType<'normal' | 'large'>, + default: 'normal', + validator: (value: string) => ['normal', 'large'].includes(value), + }, + /** + * 自定义图标 + */ + icon: { + type: [String, Object] as PropType, + }, + /** + * 是否空心点 + */ + hollow: { + type: Boolean, + default: false, + }, +} as const + +export type TimelineItemProps = ExtractPropTypes diff --git a/packages/ccui/ui/timeline/src/timeline.scss b/packages/ccui/ui/timeline/src/timeline.scss new file mode 100644 index 00000000..4f2ab091 --- /dev/null +++ b/packages/ccui/ui/timeline/src/timeline.scss @@ -0,0 +1,187 @@ +@use '../../style-var/index.scss' as *; + +.ccui-timeline { + margin: 0; + font-size: $ccui-font-size-lg; + list-style: none; + padding: 0; + + // 最后一个时间线项目不显示尾部连接线 + .ccui-timeline-item:last-child { + .ccui-timeline-item__tail { + display: none; + } + } + + // 垂直居中的时间线项目 + .ccui-timeline-item.ccui-timeline-item__center { + display: flex; + align-items: center; + min-height: 60px; + + .ccui-timeline-item__wrapper { + width: 100%; + } + + .ccui-timeline-item__tail { + top: 6px; + height: calc(100% + 14px); + } + + .ccui-timeline-item__node { + top: 50%; + transform: translateY(-50%); + } + + .ccui-timeline-item__dot { + top: 50%; + transform: translateY(-50%); + } + } + + // 第一个垂直居中项目的特殊处理 + .ccui-timeline-item.ccui-timeline-item__center:first-child { + .ccui-timeline-item__tail { + top: 50%; + height: calc(50% + 20px); + } + } + + // 最后一个垂直居中项目的特殊处理 + .ccui-timeline-item.ccui-timeline-item__center:last-child { + .ccui-timeline-item__tail { + display: block; + height: 50%; + top: 6px; + } + } +} + +.ccui-timeline-item { + position: relative; + padding-bottom: 20px; + list-style: none; + + // 确保隐藏所有列表标记 + &::marker { + display: none; + } + + &::before { + display: none; + } + + // 内容包装器 + &__wrapper { + position: relative; + padding-left: 28px; + top: -3px; + } + + // 连接线 + &__tail { + position: absolute; + left: 4px; + top: 0; + height: calc(100% + 20px); + border-left: 2px solid $ccui-dividing-line; + } + + // 图标样式 + &__icon { + color: $ccui-light-text; + font-size: $ccui-font-size-sm; + } + + // 节点 + &__node { + position: absolute; + background-color: $ccui-dividing-line; + border-color: $ccui-dividing-line; + border-radius: 50%; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + + // 普通尺寸 + &--normal { + left: -1px; + top: 0; + width: 12px; + height: 12px; + } + + // 大尺寸 + &--large { + left: -2px; + top: 0; + width: 14px; + height: 14px; + } + + // 空心样式 + &.is-hollow { + background: $ccui-base-bg; + border-style: solid; + border-width: 2px; + } + + // 不同类型的颜色 + &--primary { + background-color: $ccui-primary; + border-color: $ccui-primary; + } + + &--success { + background-color: $ccui-success; + border-color: $ccui-success; + } + + &--warning { + background-color: $ccui-warning; + border-color: $ccui-warning; + } + + &--danger { + background-color: $ccui-danger; + border-color: $ccui-danger; + } + + &--info { + background-color: $ccui-info; + border-color: $ccui-info; + } + } + + // 自定义节点 + &__dot { + position: absolute; + top: 0; + left: -6px; + display: flex; + justify-content: center; + align-items: center; + } + + // 内容区域 + &__content { + color: $ccui-text; + } + + // 时间戳 + &__timestamp { + color: $ccui-aide-text; + line-height: $ccui-line-height-base; + font-size: $ccui-font-size-sm; + + &.is-top { + margin-bottom: 8px; + padding-top: 4px; + } + + &.is-bottom { + margin-top: 8px; + } + } +} diff --git a/packages/ccui/ui/timeline/src/timeline.tsx b/packages/ccui/ui/timeline/src/timeline.tsx new file mode 100644 index 00000000..37eb969b --- /dev/null +++ b/packages/ccui/ui/timeline/src/timeline.tsx @@ -0,0 +1,22 @@ +import type { TimelineProps } from './timeline-types' +import { defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { timelineProps } from './timeline-types' +import './timeline.scss' + +export default defineComponent({ + name: 'CTimeline', + props: timelineProps, + emits: [], + setup(props: TimelineProps, { slots }) { + const ns = useNamespace('timeline') + + return () => { + return ( +
      + {slots.default && slots.default()} +
    + ) + } + }, +}) diff --git a/packages/ccui/ui/timeline/test/timeline.test.ts b/packages/ccui/ui/timeline/test/timeline.test.ts new file mode 100644 index 00000000..78db997e --- /dev/null +++ b/packages/ccui/ui/timeline/test/timeline.test.ts @@ -0,0 +1,197 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { h } from 'vue' +import { Timeline, TimelineItem } from '../index' + +describe('timeline', () => { + it('should render', () => { + const wrapper = mount(Timeline) + expect(wrapper.exists()).toBe(true) + expect(wrapper.classes()).toContain('ccui-timeline') + }) + + it('should render timeline items', () => { + const wrapper = mount(Timeline, { + slots: { + default: () => [ + h(TimelineItem, { timestamp: '2018/4/12' }, () => 'Test content 1'), + h(TimelineItem, { timestamp: '2018/4/3' }, () => 'Test content 2'), + ], + }, + }) + + expect(wrapper.element.tagName).toBe('UL') + expect(wrapper.findAll('.ccui-timeline-item')).toHaveLength(2) + expect(wrapper.findAll('.ccui-timeline-item__timestamp')[0].text()).toBe('2018/4/12') + expect(wrapper.findAll('.ccui-timeline-item__timestamp')[1].text()).toBe('2018/4/3') + }) +}) + +describe('timelineItem', () => { + it('should render with timestamp', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.classes()).toContain('ccui-timeline-item') + expect(wrapper.find('.ccui-timeline-item__timestamp').text()).toBe('2018/4/12') + expect(wrapper.find('.ccui-timeline-item__content').text()).toBe('Test content') + }) + + it('should hide timestamp when hideTimestamp is true', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + hideTimestamp: true, + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__timestamp').exists()).toBe(false) + }) + + it('should render with different types', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + type: 'primary', + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__node--primary').exists()).toBe(true) + }) + + it('should render with custom color', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + color: '#ff0000', + }, + slots: { + default: 'Test content', + }, + }) + + const node = wrapper.find('.ccui-timeline-item__node') + expect(node.attributes('style')).toContain('background-color: rgb(255, 0, 0)') + }) + + it('should render with large size', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + size: 'large', + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__node--large').exists()).toBe(true) + }) + + it('should render with hollow style', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + hollow: true, + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__node.is-hollow').exists()).toBe(true) + }) + + it('should render timestamp at top when placement is top', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + placement: 'top', + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__timestamp.is-top').exists()).toBe(true) + }) + + it('should render with center alignment', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + center: true, + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.classes()).toContain('ccui-timeline-item__center') + }) + + it('should render with string icon', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + icon: 'icon-class-name', + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__icon').exists()).toBe(true) + expect(wrapper.find('.icon-class-name').exists()).toBe(true) + }) + + it('should render with component icon', () => { + const IconComponent = { + name: 'TestIcon', + render() { + return h('span', { class: 'test-icon' }, 'Icon') + }, + } + + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + icon: IconComponent, + }, + slots: { + default: 'Test content', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__icon').exists()).toBe(true) + expect(wrapper.find('.test-icon').exists()).toBe(true) + }) + + it('should render with custom dot slot', () => { + const wrapper = mount(TimelineItem, { + props: { + timestamp: '2018/4/12', + }, + slots: { + default: 'Test content', + dot: '
    Custom
    ', + }, + }) + + expect(wrapper.find('.ccui-timeline-item__dot').exists()).toBe(true) + expect(wrapper.find('.custom-dot').exists()).toBe(true) + }) +}) diff --git a/packages/docs/components/divider/index.md b/packages/docs/components/divider/index.md index 088052e0..88c21799 100644 --- a/packages/docs/components/divider/index.md +++ b/packages/docs/components/divider/index.md @@ -82,7 +82,7 @@ export default defineComponent({ | color | string | -- | 设置分隔线的颜色 | | direction | [DirectionType](#directiontype) | horizontal | 设置分隔线方向 | | border-style | [BorderStyleType](#borderstyletype) | solid | 设置分隔符样式 | -| content-position | [ContentPositionType](#Contentpositiontype) | center | 设置分隔线文案的位置 | +| content-position | [ContentPositionType](#contentpositiontype) | center | 设置分隔线文案的位置 | | content-color | string | -- | 设置分隔线文案的颜色 | | content-background-color | string | -- | 设置分隔线文案的背景颜色 | diff --git a/packages/docs/components/timeline/index.md b/packages/docs/components/timeline/index.md new file mode 100644 index 00000000..06ca9169 --- /dev/null +++ b/packages/docs/components/timeline/index.md @@ -0,0 +1,263 @@ +# Timeline 时间线 + +可视化地呈现时间流信息。 + +## 何时使用 + +- 当有一系列信息需按时间排列时 +- 需要有一条时间轴进行视觉上的串联时 + +## 基本用法 + +Timeline 可拆分成多个按照时间戳排列的活动,时间戳是其区分于其他控件的重要特征。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自定义节点样式 + +可根据实际场景自定义节点尺寸、颜色,或直接使用图标。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自定义时间戳 + +当内容在垂直方向上过高时,可将时间戳置于内容之上。 + +:::demo + +```vue + + + + + +``` + +::: + +## 垂直居中 + +垂直居中样式的 Timeline-Item。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自定义节点 + +可以通过插槽自定义节点。 + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### Timeline Slots + +| 插槽名 | 说明 | 子标签 | +| ---- | ---- | ---- | +| default | timeline 组件的自定义默认内容 | Timeline-Item | + +### Timeline-Item Props + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ---- | -------- | ---- | ---- | ---- | +| timestamp | 时间戳 | string | — | '' | +| hide-timestamp | 是否隐藏时间戳 | boolean | — | false | +| center | 是否垂直居中 | boolean | — | false | +| placement | 时间戳位置 | string | top/bottom | bottom | +| type | 节点类型 | string | [TimelineItemType](#timelineitemtype) | — | +| color | 节点颜色 | string | — | — | +| size | 节点尺寸 | string | normal/large | normal | +| icon | 自定义图标 | string/Component | — | — | +| hollow | 是否空心点 | boolean | — | false | + +### Timeline-Item Events + +| 事件名 | 说明 | 回调参数 | +| ---- | ---- | ---- | +| — | — | — | + +### Timeline-Item Slots + +| 插槽名 | 说明 | +| ---- | ---- | +| default | 自定义内容 | +| dot | 自定义节点 | + +## Timeline类型定义 + +### TimelineItemType + +```ts +export type TimelineItemType = 'primary' | 'success' | 'warning' | 'danger' | 'info' | '' +```