Skip to content

Commit

Permalink
refactor(comp:tabs): refactor scrolling ui (#1545)
Browse files Browse the repository at this point in the history
  • Loading branch information
liuzaijiang committed May 8, 2023
1 parent cf269a5 commit 945d3ef
Show file tree
Hide file tree
Showing 15 changed files with 743 additions and 341 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
exports[`Tabs > render work 1`] = `
"<div class=\\"ix-tabs ix-tabs-md ix-tabs-card ix-tabs-nav-top\\">
<div class=\\"ix-tabs-nav-wrapper\\">
<!---->
<div class=\\"ix-tabs-nav\\" style=\\"transform: translateX(-0px);\\">
<div class=\\"ix-tabs-nav\\">
<div style=\\"transform: translateX(-0px);\\" class=\\"ix-tabs-nav-list\\">
<!---->
</div>
</div>
<div class=\\"ix-tabs-nav-operations ix-tabs-nav-operations-hidden\\"><button class=\\"ix-button ix-button-icon-only ix-button-text ix-button-square ix-button-md\\" type=\\"button\\"><i class=\\"ix-icon ix-icon-more\\" role=\\"img\\" aria-label=\\"more\\"></i>
<!---->
</button>
<!---->
<!---->
</div>
<!---->
<div class=\\"ix-tabs-nav-border\\"></div>
<!---->
</div>
Expand Down
62 changes: 62 additions & 0 deletions packages/components/tabs/__tests__/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,5 +245,67 @@ describe('Tabs', () => {
await wait(1000)
expect(onUpdateSelectedKey).toBeCalledWith('tab2')
})

test('addable work', async () => {
const onAddFn = vi.fn()
const wrapper = TabsMount({
props: {
addable: true,
onAdd: onAddFn,
},
})

const addBtn = wrapper.find('.ix-tabs-nav-tab-add')
expect(addBtn.exists()).toBe(true)

await addBtn.trigger('click')
expect(onAddFn).toBeCalled()

await wrapper.setProps({
addable: false,
})

expect(wrapper.find('.ix-tabs-nav-tab-add').exists()).toBe(false)
})

test('closable work', async () => {
const wrapper = TabsMount({
props: {
closable: true,
onClose: key => {
switch (key) {
case 'tab1':
return false
case 'tab2':
return new Promise(resolve => {
setTimeout(() => {
resolve(true)
}, 1000)
}) as Promise<boolean>
default:
return true
}
},
},
})

const tabs = wrapper.findAll('.ix-tabs-nav-tab')
const closeBtn1 = tabs[0].find('.ix-tabs-nav-close-icon')
const closeBtn2 = tabs[1].find('.ix-tabs-nav-close-icon')
const closeBtn3 = tabs[2].find('.ix-tabs-nav-close-icon')

expect(tabs.length).toBe(3)
expect(closeBtn1.exists()).toBe(true)

await closeBtn1.trigger('click')
expect(wrapper.findAll('.ix-tabs-nav-tab').length).toBe(3)

await closeBtn2.trigger('click')
await wait(1000)
expect(wrapper.findAll('.ix-tabs-nav-tab').length).toBe(2)

await closeBtn3.trigger('click')
expect(wrapper.findAll('.ix-tabs-nav-tab').length).toBe(1)
})
})
})
2 changes: 1 addition & 1 deletion packages/components/tabs/demo/Dynamic.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ order: 10

## zh

卡片类型标签页,默认为此类型
标签页支持新增和关闭选项

## en

Expand Down
27 changes: 25 additions & 2 deletions packages/components/tabs/docs/Api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
| --- | --- | --- | --- | --- | --- |
| `v-model:selectedKey` | 选中标签的`key`| `VKey` | - | - | 当没有传此值时,默认选中第一个 |
| `addable` | 显示新增按钮 | `boolean` | `false`| - | - |
| `closable` | 显示关闭按钮 | `boolean` | `false`| - | - |
| `dataSource` | 数据源 | `TabsData[]` | - | - | 优先级高于 `default` 插槽 |
| `forceRender` | 内容被隐藏时是否渲染 DOM 结构 | `boolean` | `false` | - | - |
| `mode` |`type``segment`时按钮的样式 | `'default' \| 'primary'` | `'default'` | - | - |
| `placement` | 标签的方位 | `'top' \| 'start' \| 'end' \| 'bottom'` | `'top'` | - | 其他类型仅在type为`line`生效 |
| `type` | 标签的类型 | `'card' \| 'line' \| 'segment'` | `'card'`| - | - |
| `size` | 标签页的尺寸 | `'lg' \| 'md'` | `'md'` || - |
| `onPreClick` | 滚动状态下,Pre按钮被点击的回调 | `(evt: Event) => void`| - | - | - |
| `onNextClick` | 滚动状态下,Next按钮被点击的回调 | `(evt: Event) => void`| - | - | - |
| `onAdd` | 点击添加按钮后的回调 | `() => void \| boolean \| Promise<boolean>` | - | - |
| `onClose` | 点击关闭按钮后的回调,返回 `false` 或 promise resolve `false` 或 promise reject 会阻止关闭 | `(key: any) => void \| boolean \| Promise<boolean>` | - | - |
| `onBeforeLeave` | 切换标签之前的钩子函数,返回 `false` 或 promise resolve `false` 或 promise reject 会阻止切换 | `(key: VKey, oldKey?: VKey) => boolean \| Promise<boolean>`| - | - | - |

#### IxTabProps
Expand All @@ -23,3 +26,23 @@
| `forceRender` | 内容被隐藏时是否渲染 DOM 结构 | `boolean` | `false` | - | - |
| `key` | 被选中时标签的`key`| `boolean` | `false` | - | - |
| `title` | 标签内容 | `string \| #title` | - | - | - |

#### TabsData

```ts
export interface TabsData<K = VKey> extends TabProps {
key: K
customContent?: string | ((data: { key: VKey; content?: string; selected?: boolean }) => VNodeChild)
customTitle?: string | ((data: { key: VKey; disabled?: boolean; selected?: boolean; title?: string }) => VNodeChild)
[key: string]: any
}
```

#### IxTabsSlots

| 名称 | 说明 | 参数类型 | 备注 |
| --- | --- | --- | --- |
| `title` | 标题插槽 | `{ key:VKey, disabled:boolean, selected:boolean, title: string }` | - |
| `content` | 内容插槽 | `{key:VKey, content: any, selected: boolean}` | - |

若是通过`dataSource`进行渲染的,可以通过设置`customTitle``customContent`字段,自定义插槽进行渲染,[参考](/components/tabs/zh?tab=demo#components-tabs-custom-tab)
5 changes: 5 additions & 0 deletions packages/components/tabs/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export default defineComponent({
const [selectedKey, setSelectedKey] = useControlledProp(props, 'selectedKey')
const [closedKeys, setClosedKeys] = useState<VKey[]>([])

// 存储每个标签的尺寸和偏移
const navAttrMap = new Map<VKey, { offset: number; size: number }>()

const handleTabClick = async (key: VKey, evt: Event) => {
const result = await callEmit(props.onBeforeLeave, key, selectedKey.value)
if (result !== false) {
Expand Down Expand Up @@ -66,8 +69,10 @@ export default defineComponent({
mergedDataSource,
isHorizontal,
closedKeys,
navAttrMap,
handleTabClick,
handleTabClose,
setSelectedKey,
})

const classes = computed(() => {
Expand Down
139 changes: 84 additions & 55 deletions packages/components/tabs/src/composables/useSizeObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,64 @@
import { type ComputedRef, type ShallowRef, computed, nextTick, onMounted, watch } from 'vue'

import { useResizeObserver } from '@idux/cdk/resize'
import { useState } from '@idux/cdk/utils'
import { VKey, useState } from '@idux/cdk/utils'

import { TabsProps } from '../types'
import { getMarginSize } from '../utils'

export interface SizeObservableContext {
wrapperSize: ComputedRef<number>
prevNextSize: ComputedRef<number>
navSize: ComputedRef<number>
selectedNavSize: ComputedRef<number>
operationsSize: ComputedRef<number>
navOffset: ComputedRef<number>
selectedNavOffset: ComputedRef<number>
hasScroll: ComputedRef<boolean>
calcPrevOffset: () => void
calcNextOffset: () => void
calcNavSize: () => void
firstShow: ComputedRef<boolean>
lastShow: ComputedRef<boolean>
updateNavOffset: () => void
}

export function useSizeObservable(
props: TabsProps,
wrapperRef: ShallowRef<HTMLElement | undefined>,
prevNextRef: ShallowRef<HTMLElement | undefined>,
navRef: ShallowRef<HTMLElement | undefined>,
selectedNavRef: ShallowRef<HTMLElement | undefined>,
addBtnRef: ShallowRef<HTMLElement | undefined>,
operationsRef: ShallowRef<HTMLElement | undefined>,
isHorizontal: ComputedRef<boolean>,
navAttrMap: Map<VKey, { offset: number; size: number }>,
closedKeys: ComputedRef<VKey[]>,
): SizeObservableContext {
const [wrapperSize, setWrapperSize] = useState(0)
const [prevNextSize, setPreNextSize] = useState(0)
const [navSize, setNavSize] = useState(0)
const [selectedNavSize, setSelectedNavSize] = useState(0)
const [addBtnSize, setAddBtnSize] = useState(0)
const [operationsSize, setOperationsSize] = useState(0)

const [navOffset, setNavOffset] = useState(0)
const [selectedNavOffset, setSelectedNavOffset] = useState(0)

const sizeProp = computed(() => (isHorizontal.value ? 'offsetWidth' : 'offsetHeight'))
const offsetProp = computed(() => (isHorizontal.value ? 'offsetLeft' : 'offsetTop'))

useResizeObserver(wrapperRef, entry => {
nextTick(() => {
const target = entry.target as HTMLElement
setWrapperSize(target[sizeProp.value])
})
const hasScroll = computed(() => {
return navSize.value > wrapperSize.value
})

// 第一个nav显示完全
const firstShow = computed(() => hasScroll.value && navOffset.value === 0)
// 最后一个nav显示完全
const lastShow = computed(() => {
return (
hasScroll.value && navSize.value - addBtnSize.value - navOffset.value <= wrapperSize.value - operationsSize.value
)
})

useResizeObserver(prevNextRef, entry => {
useResizeObserver(wrapperRef, entry => {
nextTick(() => {
const target = entry.target as HTMLElement
setPreNextSize(target[sizeProp.value])
setWrapperSize(target[sizeProp.value])
})
})

Expand All @@ -65,37 +80,51 @@ export function useSizeObservable(
nextTick(() => {
const target = entry.target as HTMLElement
setSelectedNavSize(target[sizeProp.value])
setSelectedNavOffset((target as HTMLElement)[isHorizontal.value ? 'offsetLeft' : 'offsetTop'])
setSelectedNavOffset(target[offsetProp.value])
updateNavOffset()
})
})

const hasScroll = computed(() => navSize.value > wrapperSize.value)
const selectedNavVisibleSize = computed(
() => wrapperSize.value + navOffset.value - prevNextSize.value - selectedNavOffset.value,
)
useResizeObserver(addBtnRef, entry => {
nextTick(() => {
const target = entry.target as HTMLElement
setAddBtnSize(target[sizeProp.value] + getMarginSize(target, isHorizontal.value))
})
})

useResizeObserver(operationsRef, entry => {
nextTick(() => {
const target = entry.target as HTMLElement
setOperationsSize(target[sizeProp.value] + getMarginSize(target, isHorizontal.value))
})
})

const updateNavOffset = () => {
if (hasScroll.value) {
const _selectedNavVisibleSize = selectedNavVisibleSize.value
const _wrapperSize = wrapperSize.value
const _prevNextSize = prevNextSize.value
const _wrapperSize = wrapperSize.value - operationsSize.value
const _selectedNavSize = selectedNavSize.value
const _navOffset = navOffset.value
//const _navSize = navSize.value - addBtnSize.value
const _selectedNavOffset = selectedNavOffset.value
const _selectedNavVisibleSize = _wrapperSize + _navOffset - _selectedNavOffset

// 判断是否在可视范围内
const inVisibleRange = _selectedNavVisibleSize / _wrapperSize < 2

if (inVisibleRange) {
// 可视范围内需要处理展示不全的问题,需要修正
if (_selectedNavVisibleSize < _selectedNavSize) {
// 即可视范围内最后一个tab没有展示完全
setNavOffset(_navOffset + _selectedNavSize - _selectedNavVisibleSize + _prevNextSize)
setNavOffset(_navOffset + _selectedNavSize - _selectedNavVisibleSize)
} else if (_selectedNavVisibleSize / _wrapperSize > 1) {
// 即可视范围内第一个tab没有展示完全
setNavOffset(_navOffset - ((_selectedNavVisibleSize % _wrapperSize) + _prevNextSize))
setNavOffset(_navOffset - (_selectedNavVisibleSize % _wrapperSize))
}
// else if (_navSize - _navOffset < _wrapperSize) {
// setNavOffset(_wrapperSize - (_navSize - _navOffset))
// }
} else {
setNavOffset(prevNextSize.value + selectedNavOffset.value - _prevNextSize)
setNavOffset(_selectedNavOffset)
}
} else {
setNavOffset(0)
Expand All @@ -104,47 +133,47 @@ export function useSizeObservable(

watch(hasScroll, updateNavOffset, { flush: 'post' })

watch(closedKeys, (cur, old) => {
const curSet = new Set(cur)
const oldSet = new Set(old)
const closeTabKey = [...curSet].find(item => !oldSet.has(item))
if (closeTabKey !== undefined) {
const closeTabAttr = navAttrMap.get(closeTabKey) || { size: 0, offset: 0 }
const { size: closeTabSize, offset: closeTabOffset } = closeTabAttr
let nextTabOffset = 0
for (const { offset } of navAttrMap.values()) {
if (offset > closeTabOffset) {
nextTabOffset = offset
break
}
}
const diffOffset = navOffset.value - (nextTabOffset ? nextTabOffset - closeTabOffset : closeTabSize)
setNavOffset(diffOffset > 0 ? diffOffset : 0)
}

// 需要对line类型的bar进行额外处理
nextTick(() => {
if (props.type === 'line' && selectedNavRef.value) {
setSelectedNavOffset(selectedNavRef.value[offsetProp.value])
}
})
})

onMounted(() => {
// 需要等 DOM 渲染完成后,重新计算一次,才是最准确的
setTimeout(() => updateNavOffset())
})

const calcPrevOffset = () => {
const mergedOffset = navOffset.value + prevNextSize.value
const offset = mergedOffset < wrapperSize.value ? 0 : mergedOffset - wrapperSize.value
setNavOffset(offset)
}

const calcNextOffset = () => {
const mergedNavSize = navSize.value + prevNextSize.value * 2
const mergedOffset = navOffset.value + wrapperSize.value
let offset
if (mergedNavSize - mergedOffset < wrapperSize.value) {
offset = mergedNavSize - wrapperSize.value
} else {
offset = mergedOffset
}
setNavOffset(offset)
}

const calcNavSize = () => {
const element = navRef.value
if (!element) {
return
}
setPreNextSize(element[sizeProp.value])
}

return {
wrapperSize,
prevNextSize,
navSize,
selectedNavSize,
navOffset,
selectedNavOffset,
hasScroll,
calcPrevOffset,
calcNextOffset,
calcNavSize,
operationsSize,
firstShow,
lastShow,
updateNavOffset,
}
}

0 comments on commit 945d3ef

Please sign in to comment.