feat: add n-split component (#5290)

* feat: add `n-split` component

* feat: add disabled

* feat: add event

* fix: rename

* feat: add theme color

* feat: support slot

* feat: add demo

* fix: panel height for demo

---------

Co-authored-by: 07akioni <07akioni2@gmail.com>
This commit is contained in:
jahnli 2023-12-03 21:57:15 +08:00 committed by GitHub
parent b1acfe9d8a
commit 320bd7937a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 731 additions and 2 deletions

View File

@ -36,6 +36,8 @@
- `n-equation` export the `EquationProps` type.
- `n-popselect` adds `header` slot.
- `n-tree-select` adds `watch-props` prop.
- Adds `n-split` component, closes [#3557](https://github.com/tusen-ai/naive-ui/issues/3557).
## 2.35.0

View File

@ -36,6 +36,7 @@
- `n-equation` 导出 `EquationProps` 类型
- `n-popselect` 新增 `header` 插槽
- `n-tree-select` 新增 `watch-props` 属性
- 新增 `n-split` 组件,关闭 [#3557](https://github.com/tusen-ai/naive-ui/issues/3557)
## 2.35.0

View File

@ -534,6 +534,10 @@ export const enComponentRoutes = [
{
path: 'equation',
component: () => import('../../src/equation/demos/enUS/index.demo-entry.md')
},
{
path: 'split',
component: () => import('../../src/split/demos/enUS/index.demo-entry.md')
}
]
@ -911,6 +915,10 @@ export const zhComponentRoutes = [
{
path: 'equation',
component: () => import('../../src/equation/demos/zhCN/index.demo-entry.md')
},
{
path: 'split',
component: () => import('../../src/split/demos/zhCN/index.demo-entry.md')
}
]

View File

@ -685,6 +685,12 @@ export function createComponentMenuOptions ({ lang, theme, mode }) {
zh: '间距',
enSuffix: true,
path: '/space'
},
{
en: 'Split',
zh: '面板分割',
enSuffix: true,
path: '/split'
}
]
}),

View File

@ -68,6 +68,7 @@ export * from './skeleton'
export * from './slider'
export * from './space'
export * from './spin'
export * from './split'
export * from './statistic'
export * from './steps'
export * from './switch'

View File

@ -97,6 +97,7 @@ import type { CollapseTransitionTheme } from '../../collapse-transition/styles'
import type { ButtonGroupTheme } from '../../button-group/styles/light'
import type { RowTheme } from '../../legacy-grid/styles'
import type { Katex } from './katex'
import type { SplitTheme } from '../../split/styles'
export interface GlobalThemeWithoutCommon {
Alert?: AlertTheme
@ -179,6 +180,7 @@ export interface GlobalThemeWithoutCommon {
Typography?: TypographyTheme
Upload?: UploadTheme
Watermark?: WatermarkTheme
Split?: SplitTheme
Row?: RowTheme
// internal
InternalSelectMenu?: InternalSelectMenuTheme

View File

@ -0,0 +1,20 @@
<markdown>
# Basic
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '200px' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '200px' }">
Pane 2
</div>
</template>
</n-split>
</template>

View File

@ -0,0 +1,48 @@
<markdown>
# Event
</markdown>
<template>
<n-split
direction="horizontal"
style="height: 200px"
@move-start="handleOnMoveStart"
@moving="handleOnMoving"
@move-end="handleOnMoveEnd"
>
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
</n-split>
</template>
<script lang="ts">
import { useMessage } from 'naive-ui'
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const message = useMessage()
return {
handleOnMoveStart: () => {
message.info('Move Start')
},
handleOnMoving: () => {
message.info('Moving')
},
handleOnMoveEnd: () => {
message.info('Move end')
}
}
}
})
</script>

View File

@ -0,0 +1,34 @@
# Panel Split Split
The flexible layout tool provides the possibility of customizing the interface layout
## Demos
```demo
basic.vue
vertical.vue
nest.vue
event.vue
slot.vue
```
## API
### Split Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| disabled | `Boolean` | `false` | Whether to disable. |
| direction | `horizontal \| vertical` | `horizontal` | Split Indicates the direction of the split. |
| min | `Number` | `0` | Split Indicates the minimum threshold for splitting, 0-1 is a percentage. |
| max | `Number` | `1` | Split Indicates the maximum split threshold, 0-1 is a percentage. |
| size | `Number` | `0.5` | Split Indicates the split size, 0-1 is a percentage. |
| resize-trigger-size | `Number` | `3` | Split Specifies the size of the separator. |
### Split Slots
| Name | Parameters | Description |
| -------------- | ---------- | ------------------------- |
| first | `()` | The first panel content. |
| second | `()` | The Second panel content. |
| resize-trigger | `()` | Split bar content. |

View File

@ -0,0 +1,42 @@
<markdown>
# Nested layout
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
<n-split direction="vertical" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
<template #second>
<n-split direction="horizontal" style="height: 100%">
<template #first>
<div :style="{ height: '100%' }">
Pane 3
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 4
</div>
</template>
</n-split>
</template>
</n-split>
</div>
</template>
</n-split>
</template>

View File

@ -0,0 +1,48 @@
<markdown>
# Slot.
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
<template #resize-trigger>
<div
:style="{
height: '100%',
width: '16px',
backgroundColor: '#409eff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}"
>
<n-icon color="white" :size="18">
<swap-horizontal-icon />
</n-icon>
</div>
</template>
</n-split>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { SwapHorizontal as SwapHorizontalIcon } from '@vicons/ionicons5'
export default defineComponent({
components: {
SwapHorizontalIcon
}
})
</script>

View File

@ -0,0 +1,20 @@
<markdown>
# Vertical layout
</markdown>
<template>
<n-split direction="vertical" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
</n-split>
</template>

View File

@ -0,0 +1,20 @@
<markdown>
# 基础用法
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
</n-split>
</template>

View File

@ -0,0 +1,48 @@
<markdown>
# 事件
</markdown>
<template>
<n-split
direction="horizontal"
style="height: 200px"
@move-start="handleOnMoveStart"
@moving="handleOnMoving"
@move-end="handleOnMoveEnd"
>
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
</n-split>
</template>
<script lang="ts">
import { useMessage } from 'naive-ui'
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
const message = useMessage()
return {
handleOnMoveStart: () => {
message.info('开始滚动')
},
handleOnMoving: () => {
message.info('滚动中')
},
handleOnMoveEnd: () => {
message.info('滚动结束')
}
}
}
})
</script>

View File

@ -0,0 +1,34 @@
# 面板分割 Split
灵活的布局工具,提供了用户自定义界面布局的可能性。
## 演示
```demo
basic.vue
vertical.vue
nest.vue
event.vue
slot.vue
```
## API
### Split Props
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| disabled | `Boolean` | `false` | 是否禁用 |
| direction | `horizontal \| vertical` | `horizontal` | Split 的分割方向 |
| min | `Number` | `0` | Split 的分割最小阈值0-1 代表百分比 |
| max | `Number` | `1` | Split 的分割最大阈值0-1 代表百分比 |
| size | `Number` | `0.5` | Split 的分割大小0-1 代表百分比 |
| resize-trigger-size | `Number` | `3` | Split 的分隔条大小 |
### Split Slots
| 名称 | 参数 | 说明 |
| -------------- | ---- | -------------- |
| first | `()` | 第一个面板内容 |
| second | `()` | 第二个面板内容 |
| resize-trigger | `()` | 分割条内容 |

View File

@ -0,0 +1,42 @@
<markdown>
# 嵌套布局
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
<n-split direction="vertical" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
<template #second>
<n-split direction="horizontal" style="height: 100%">
<template #first>
<div :style="{ height: '100%' }">
Pane 3
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 4
</div>
</template>
</n-split>
</template>
</n-split>
</div>
</template>
</n-split>
</template>

View File

@ -0,0 +1,48 @@
<markdown>
# 插槽
</markdown>
<template>
<n-split direction="horizontal" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
<template #resize-trigger>
<div
:style="{
height: '100%',
width: '16px',
backgroundColor: '#409eff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}"
>
<n-icon color="white" :size="18">
<swap-horizontal-icon />
</n-icon>
</div>
</template>
</n-split>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { SwapHorizontal as SwapHorizontalIcon } from '@vicons/ionicons5'
export default defineComponent({
components: {
SwapHorizontalIcon
}
})
</script>

View File

@ -0,0 +1,20 @@
<markdown>
# 垂直布局
</markdown>
<template>
<n-split direction="vertical" style="height: 200px">
<template #first>
<div :style="{ height: '100%' }">
Pane 1
</div>
</template>
<template #second>
<div :style="{ height: '100%' }">
Pane 2
</div>
</template>
</n-split>
</template>

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

@ -0,0 +1,2 @@
export { default as NSplit, splitProps } from './src/Split'
export type { SplitProps } from './src/Split'

207
src/split/src/Split.tsx Normal file
View File

@ -0,0 +1,207 @@
import {
h,
defineComponent,
type PropType,
ref,
computed,
type CSSProperties
} from 'vue'
import type { ExtractPublicPropTypes } from '../../_utils'
import useConfig from '../../_mixins/use-config'
import style from './styles/index.cssr'
import { type ThemeProps, useTheme } from '../../_mixins'
import { type SplitTheme, splitLight } from '../styles'
import { onMounted } from 'vue'
export const splitProps = {
...(useTheme.props as ThemeProps<SplitTheme>),
direction: {
type: String as PropType<'horizontal' | 'vertical'>,
default: 'horizontal'
},
resizeTriggerSize: {
type: Number,
default: 3
},
disabled: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 0.5
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 1
},
onMoveStart: Function as PropType<(e: Event) => void>,
onMoving: Function as PropType<(e: Event) => void>,
onMoveEnd: Function as PropType<(e: Event) => void>
} as const
export type SplitProps = ExtractPublicPropTypes<typeof splitProps>
export default defineComponent({
name: 'Split',
props: splitProps,
setup (props) {
const { mergedClsPrefixRef, inlineThemeDisabled } = useConfig(props)
const themeRef = useTheme(
'Split',
'-split',
style,
splitLight,
props,
mergedClsPrefixRef
)
const cssVarsRef = computed(() => {
const {
self: { resizableTriggerColorHover }
} = themeRef.value
return {
'--n-resize-trigger-color-hover': resizableTriggerColorHover
}
})
const dividerRef = ref<HTMLElement | null>(null)
const isDraggingRef = ref(false)
const currentSize = ref(props.size)
const triggerSize = ref(0)
onMounted(() => {
if (!dividerRef.value) return
const { width, height } = dividerRef.value.getBoundingClientRect()
triggerSize.value = props.direction === 'horizontal' ? width : height
})
const firstPaneStyle = computed(() => {
const size = currentSize.value * 100
return {
flex: `0 0 calc(${size}% - ${triggerSize.value}px)`
}
})
const resizeTriggerStyle = computed(() => {
return props.direction === 'horizontal'
? {
width: `${props.resizeTriggerSize}px`,
height: '100%'
}
: {
width: '100%',
height: `${props.resizeTriggerSize}px`
}
})
const resizeTriggerWrapperStyle = computed(() => {
return props.direction === 'horizontal'
? {
cursor: 'col-resize'
}
: {
cursor: 'row-resize'
}
})
const handleMouseDown = (e: MouseEvent): void => {
e.preventDefault()
isDraggingRef.value = true
if (props.onMoveStart) props.onMoveStart(e)
const mouseMoveEvent = 'mousemove'
const mouseUpEvent = 'mouseup'
const onMouseMove = (e: MouseEvent): void => {
updateSize(e)
if (props.onMoving) props.onMoving(e)
}
const onMouseUp = (): void => {
document.removeEventListener(mouseMoveEvent, onMouseMove)
document.removeEventListener(mouseUpEvent, onMouseUp)
isDraggingRef.value = false
if (props.onMoveEnd) props.onMoveEnd(e)
}
document.addEventListener(mouseMoveEvent, onMouseMove)
document.addEventListener(mouseUpEvent, onMouseUp)
}
const updateSize = (event: MouseEvent): void => {
const parentRect =
dividerRef.value?.parentElement?.getBoundingClientRect()
if (!parentRect) return
const newSize =
props.direction === 'horizontal'
? (event.clientX - parentRect.left) / parentRect.width
: (event.clientY - parentRect.top) / parentRect.height
currentSize.value = newSize
if (props.min) {
currentSize.value = Math.max(newSize, props.min)
}
if (props.max) {
currentSize.value = Math.min(newSize, props.max)
}
}
return {
cssVars: inlineThemeDisabled ? undefined : cssVarsRef,
divider: dividerRef,
isDragging: isDraggingRef,
mergedClsPrefix: mergedClsPrefixRef,
resizeTriggerWrapperStyle,
resizeTriggerStyle,
handleMouseDown,
firstPaneStyle
}
},
render () {
return (
<div
class={[
`${this.mergedClsPrefix}-split`,
`${this.mergedClsPrefix}-split--${this.direction}`
]}
style={this.cssVars as CSSProperties}
>
<div
class={`${this.mergedClsPrefix}-split-pane`}
style={this.firstPaneStyle}
>
{this.$slots.first?.()}
</div>
{!this.disabled && (
<div
ref="divider"
class={[`${this.mergedClsPrefix}-split__resize-trigger-wrapper`]}
style={this.resizeTriggerWrapperStyle}
onMousedown={this.handleMouseDown}
>
{this.$slots['resize-trigger']?.() ?? (
<div
style={this.resizeTriggerStyle}
class={[
`${this.mergedClsPrefix}-split__resize-trigger`,
this.isDragging &&
`${this.mergedClsPrefix}-split__resize-trigger--hover`
]}
></div>
)}
</div>
)}
<div
class={[
`${this.mergedClsPrefix}-split-pane`,
`${this.mergedClsPrefix}-split-second-pane`
]}
>
{this.$slots.second?.()}
</div>
</div>
)
}
})

View File

@ -0,0 +1,39 @@
import { c, cB, cM, cE } from '../../../_utils/cssr'
// vars:
// --n-border-color
// --n-color
export default c([
cB('split', `
display: flex;
width: 100%;
height: 100%;
`, [
cM('horizontal', `
flex-direction: row;
`),
cM('vertical', `
flex-direction: column;
`),
cB('split-pane', `
overflow: hidden;
`),
cB('split-second-pane', `
flex: 1;
`),
cE('resize-trigger', `
background-color: var(--n-border-color);
transition: background-color .3s var(--n-bezier);
`, [
cM('hover', `
background-color: var(--n-resize-trigger-color-hover);
`),
c('&:hover', `
background-color: var(--n-resize-trigger-color-hover);
`
)
])
]
)
])

9
src/split/styles/dark.ts Normal file
View File

@ -0,0 +1,9 @@
import { commonDark } from '../../_styles/common'
import { type SplitTheme } from './light'
const splitDark: SplitTheme = {
name: 'Split',
common: commonDark
}
export default splitDark

View File

@ -0,0 +1,3 @@
export { default as splitDark } from './dark'
export { default as splitLight } from './light'
export type { SplitTheme, SplitThemeVars } from './light'

21
src/split/styles/light.ts Normal file
View File

@ -0,0 +1,21 @@
import { commonLight } from '../../_styles/common'
import type { ThemeCommonVars } from '../../_styles/common'
import { type Theme } from '../../_mixins'
export const self = (vars: ThemeCommonVars) => {
const { primaryColorHover } = vars
return {
resizableTriggerColorHover: primaryColorHover
}
}
export type SplitThemeVars = ReturnType<typeof self>
const themeLight: Theme<'Split', SplitThemeVars> = {
name: 'Split',
common: commonLight,
self
}
export default themeLight
export type SplitTheme = typeof themeLight

View File

@ -80,6 +80,7 @@ import { typographyDark } from '../typography/styles'
import { treeDark } from '../tree/styles'
import { uploadDark } from '../upload/styles'
import { watermarkDark } from '../watermark/styles'
import { splitDark } from '../split/styles'
import type { BuiltInGlobalTheme } from './interface'
export const darkTheme: BuiltInGlobalTheme = {
@ -165,5 +166,6 @@ export const darkTheme: BuiltInGlobalTheme = {
TreeSelect: treeSelectDark,
Typography: typographyDark,
Upload: uploadDark,
Watermark: watermarkDark
Watermark: watermarkDark,
Split: splitDark
}

View File

@ -82,6 +82,7 @@ import { treeLight } from '../tree/styles'
import { treeSelectLight } from '../tree-select/styles'
import { uploadLight } from '../upload/styles'
import { watermarkLight } from '../watermark/styles'
import { splitLight } from '../split/styles'
import type { BuiltInGlobalTheme } from './interface'
export const lightTheme: BuiltInGlobalTheme = {
@ -167,5 +168,6 @@ export const lightTheme: BuiltInGlobalTheme = {
TreeSelect: treeSelectLight,
Typography: typographyLight,
Upload: uploadLight,
Watermark: watermarkLight
Watermark: watermarkLight,
Split: splitLight
}