diff --git a/packages/web-vue/components/tabs/README.en-US.md b/packages/web-vue/components/tabs/README.en-US.md index f5b257e07..294f3727d 100644 --- a/packages/web-vue/components/tabs/README.en-US.md +++ b/packages/web-vue/components/tabs/README.en-US.md @@ -24,6 +24,8 @@ description: Organize content in the same view. You can view the content of one @import ./__demo__/trigger.md +@import ./__demo__/scroll.md + ## API @@ -47,6 +49,7 @@ description: Organize content in the same view. You can view the content of one |auto-switch|Whether to switch to a new tab after creating a tab (the last one)|`boolean`|`false`|2.18.0| |hide-content|Whether to hide content|`boolean`|`false`|2.25.0| |trigger|Trigger method|`'hover' \| 'click'`|`'click'`|2.34.0| +|scroll-position|The scroll position of the selected tab, the default auto will scroll the activeTab to the visible area, but will not adjust the position intentionally|`'start' \| 'end' \| 'center' \| 'auto' \| number`|`'auto'`|| ### `` Events |Event Name|Description|Parameters| diff --git a/packages/web-vue/components/tabs/README.zh-CN.md b/packages/web-vue/components/tabs/README.zh-CN.md index cb5b44cb8..9678d0ddc 100644 --- a/packages/web-vue/components/tabs/README.zh-CN.md +++ b/packages/web-vue/components/tabs/README.zh-CN.md @@ -22,6 +22,8 @@ description: 将内容组织同一视图中,一次可查看一个视图内容 @import ./__demo__/trigger.md +@import ./__demo__/scroll.md + ## API @@ -45,6 +47,7 @@ description: 将内容组织同一视图中,一次可查看一个视图内容 |auto-switch|创建标签后是否切换到新标签(最后一个)|`boolean`|`false`|2.18.0| |hide-content|是否隐藏内容|`boolean`|`false`|2.25.0| |trigger|触发方式|`'hover' \| 'click'`|`'click'`|2.34.0| +|scroll-position|被选中 tab 的滚动位置,默认 auto 即会将 activeTab 滚动到可见区域,但不会特意做位置调整|`'start' \| 'end' \| 'center' \| 'auto' \| number`|`'auto'`|| ### `` Events |事件名|描述|参数| diff --git a/packages/web-vue/components/tabs/TEMPLATE.md b/packages/web-vue/components/tabs/TEMPLATE.md index aa1d4ddc5..e31315c3f 100644 --- a/packages/web-vue/components/tabs/TEMPLATE.md +++ b/packages/web-vue/components/tabs/TEMPLATE.md @@ -33,6 +33,8 @@ description: Organize content in the same view. You can view the content of one @import ./__demo__/trigger.md +@import ./__demo__/scroll.md + ## API %%API(tabs.tsx)%% diff --git a/packages/web-vue/components/tabs/__demo__/scroll.md b/packages/web-vue/components/tabs/__demo__/scroll.md new file mode 100644 index 000000000..ada25c406 --- /dev/null +++ b/packages/web-vue/components/tabs/__demo__/scroll.md @@ -0,0 +1,79 @@ +```yaml +title: + zh-CN: 滚动 + en-US: Scrollable +``` + +## zh-CN + +支持通过滚轮或者触摸板进行滚动操作,且可以通过 `scrollPosition` 属性设置滚动位置。 + +--- + +## en-US + +Support scrolling operation via scroll wheel or touch pad. And you can set the scroll position through the `scrollPosition` property. + +--- + +```vue + + + + +``` diff --git a/packages/web-vue/components/tabs/__test__/__snapshots__/demo.test.ts.snap b/packages/web-vue/components/tabs/__test__/__snapshots__/demo.test.ts.snap index d20019adf..00554a1e7 100644 --- a/packages/web-vue/components/tabs/__test__/__snapshots__/demo.test.ts.snap +++ b/packages/web-vue/components/tabs/__test__/__snapshots__/demo.test.ts.snap @@ -209,6 +209,130 @@ exports[` demo: render [position] correctly 1`] = ` " `; +exports[` demo: render [scroll] correctly 1`] = ` +"
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+ + +
+
+
+
+
+
Content of Tab Panel 1
+
+
+
Content of Tab Panel 2
+
+
+
Content of Tab Panel 3
+
+
+
Content of Tab Panel 4
+
+
+
Content of Tab Panel 5
+
+
+
Content of Tab Panel 6
+
+
+
Content of Tab Panel 7
+
+
+
Content of Tab Panel 8
+
+
+
Content of Tab Panel 9
+
+
+
Content of Tab Panel 10
+
+
+
Content of Tab Panel 11
+
+
+
Content of Tab Panel 12
+
+
+
Content of Tab Panel 13
+
+
+
Content of Tab Panel 14
+
+
+
Content of Tab Panel 15
+
+
+
Content of Tab Panel 16
+
+
+
Content of Tab Panel 17
+
+
+
Content of Tab Panel 18
+
+
+
Content of Tab Panel 19
+
+
+
Content of Tab Panel 20
+
+
+
Content of Tab Panel 21
+
+
+
Content of Tab Panel 22
+
+
+
Content of Tab Panel 23
+
+
+
Content of Tab Panel 24
+
+
+
Content of Tab Panel 25
+
+
+
Content of Tab Panel 26
+
+
+
Content of Tab Panel 27
+
+
+
Content of Tab Panel 28
+
+
+
Content of Tab Panel 29
+
+
+
Content of Tab Panel 30
+
+
+
+
" +`; + exports[` demo: render [trigger] correctly 1`] = ` "
diff --git a/packages/web-vue/components/tabs/interface.ts b/packages/web-vue/components/tabs/interface.ts index b1ee3ae59..e362eda29 100644 --- a/packages/web-vue/components/tabs/interface.ts +++ b/packages/web-vue/components/tabs/interface.ts @@ -19,3 +19,5 @@ export interface TabData { } export type TabTriggerEvent = 'click' | 'hover'; + +export type ScrollPosition = 'start' | 'end' | 'center' | 'auto' | number; diff --git a/packages/web-vue/components/tabs/tabs-nav.tsx b/packages/web-vue/components/tabs/tabs-nav.tsx index 61e5e78a8..e33f88130 100644 --- a/packages/web-vue/components/tabs/tabs-nav.tsx +++ b/packages/web-vue/components/tabs/tabs-nav.tsx @@ -9,17 +9,17 @@ import { toRefs, watch, } from 'vue'; -import { getTabListStyle } from './utils'; +import { getTabListStyle, updateScrollOffset } from './utils'; import { getPrefixCls } from '../_utils/global-config'; import type { Direction } from '../_utils/constant'; import TabsTab from './tabs-tab.vue'; import TabsButton from './tabs-button'; import TabsNavInk from './tabs-nav-ink.vue'; -import type { TabData, TabsType } from './interface'; +import type { TabData, TabsType, ScrollPosition } from './interface'; import IconHover from '../_components/icon-hover.vue'; import IconPlus from '../icon/icon-plus'; import ResizeObserver from '../_components/resize-observer'; -import { isUndefined } from '../_utils/is'; +import { isUndefined, isNumber } from '../_utils/is'; export default defineComponent({ name: 'TabsNav', @@ -67,10 +67,15 @@ export default defineComponent({ type: Boolean, default: true, }, + scrollPosition: { + type: String as PropType, + default: 'auto', + }, }, emits: ['click', 'add', 'delete'], setup(props, { emit, slots }) { - const { tabs, activeKey, activeIndex, direction } = toRefs(props); + const { tabs, activeKey, activeIndex, direction, scrollPosition } = + toRefs(props); const prefixCls = getPrefixCls('tabs-nav'); const wrapperRef = ref(); @@ -91,7 +96,6 @@ export default defineComponent({ const isScroll = ref(false); const wrapperLength = ref(0); const maxOffset = ref(0); - const tabEndOffsets = ref([]); const offset = ref(0); const getWrapperLength = () => { @@ -113,22 +117,11 @@ export default defineComponent({ return listRef.value.offsetWidth - wrapperRef.value.offsetWidth; }; - const getTabEndOffsets = () => { - return tabs.value.map((item) => { - const ele = tabsRef.value[item.key]; - if (direction.value === 'vertical') { - return ele.offsetTop + ele.offsetHeight; - } - return ele.offsetLeft + ele.offsetWidth; - }); - }; - const getSize = () => { isScroll.value = isOverflow(); if (isScroll.value) { wrapperLength.value = getWrapperLength(); maxOffset.value = getMaxOffset(); - tabEndOffsets.value = getTabEndOffsets(); if (offset.value > maxOffset.value) { offset.value = maxOffset.value; } @@ -146,31 +139,65 @@ export default defineComponent({ return false; }; - const isInView = (index: number) => { - return ( - (tabEndOffsets.value[index - 1] ?? 0) >= offset.value && - tabEndOffsets.value[index] <= offset.value + wrapperLength.value - ); + const setOffset = (newOffset: number) => { + if (!wrapperRef.value || !listRef.value || newOffset < 0) { + newOffset = 0; + } + offset.value = Math.min(newOffset, maxOffset.value); }; - const getNextOffset = (type: string) => { - if (!wrapperRef.value) { - return 0; - } + const setActiveTabOffset = () => { + if (!activeTabRef.value || !wrapperRef.value || !isScroll.value) return; - return type === 'previous' - ? offset.value - wrapperLength.value - : offset.value + wrapperLength.value; - }; + // 纠正浏览器默认行为导致的滚动偏移, 比如 Tab 聚焦 + updateScrollOffset(wrapperRef.value, direction.value); - const getValidOffset = (offset: number) => { - if (!wrapperRef.value || !listRef.value || offset < 0) { - return 0; + const isHorizontal = direction.value === 'horizontal'; + const offsetProperty = isHorizontal ? 'offsetLeft' : 'offsetTop'; + const sizeProperty = isHorizontal ? 'offsetWidth' : 'offsetHeight'; + const tabOffset = activeTabRef.value[offsetProperty]; + const tabSize = activeTabRef.value[sizeProperty]; + const wrapperSize = wrapperRef.value[sizeProperty]; + + // 纠正偏移缺少 margin + const tabStyle = window.getComputedStyle(activeTabRef.value); + const marginProperty = isHorizontal + ? scrollPosition.value === 'end' + ? 'marginRight' + : 'marginLeft' + : scrollPosition.value === 'end' + ? 'marginBottom' + : 'marginTop'; + const tabMargin = parseFloat(tabStyle[marginProperty]) || 0; + + if (scrollPosition.value === 'auto') { + if (tabOffset < offset.value) { + setOffset(tabOffset - tabMargin); + } else if (tabOffset + tabSize > offset.value + wrapperSize) { + setOffset(tabOffset + tabSize - wrapperSize + tabMargin); + } + } else if (scrollPosition.value === 'center') { + setOffset(tabOffset + (tabSize - wrapperSize + tabMargin) / 2); + } else if (scrollPosition.value === 'start') { + setOffset(tabOffset - tabMargin); + } else if (scrollPosition.value === 'end') { + setOffset(tabOffset + tabSize - wrapperSize + tabMargin); + } else if (isNumber(scrollPosition.value)) { + setOffset(tabOffset - scrollPosition.value); } - if (offset > maxOffset.value) { - return maxOffset.value; + }; + + const handleWheel = (ev: WheelEvent) => { + if (!isScroll.value) return; + ev.preventDefault(); + + const { deltaX, deltaY } = ev; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + setOffset(offset.value + deltaX); + } else { + setOffset(offset.value + deltaY); } - return offset; }; const handleClick = (key: string | number, ev: Event) => { @@ -182,7 +209,12 @@ export default defineComponent({ }; const handleButtonClick = (type: string) => { - offset.value = getValidOffset(getNextOffset(type)); + const nextOffset = + type === 'previous' + ? offset.value - wrapperLength.value + : offset.value + wrapperLength.value; + + setOffset(nextOffset); }; const handleResize = () => { @@ -198,24 +230,10 @@ export default defineComponent({ }); }); - watch(activeIndex, (current, pre) => { - nextTick(() => { - if (isScroll.value) { - if (current >= pre) { - const offsetIndex = - current < tabEndOffsets.value.length - 1 ? current + 1 : current; - if (!isInView(offsetIndex)) { - offset.value = - tabEndOffsets.value[offsetIndex] - wrapperLength.value; - } - } else { - const offsetIndex = current > 0 ? current - 1 : current; - if (!isInView(offsetIndex)) { - offset.value = tabEndOffsets.value[offsetIndex - 1] ?? 0; - } - } - } - }); + watch([activeIndex, scrollPosition], () => { + setTimeout(() => { + setActiveTabOffset(); + }, 0); }); onMounted(() => { @@ -282,7 +300,7 @@ export default defineComponent({ /> )} getSize()}> -
+
{props.tabs.map((tab, index) => ( diff --git a/packages/web-vue/components/tabs/tabs.tsx b/packages/web-vue/components/tabs/tabs.tsx index fbc576377..c5738312a 100644 --- a/packages/web-vue/components/tabs/tabs.tsx +++ b/packages/web-vue/components/tabs/tabs.tsx @@ -14,6 +14,7 @@ import type { TabsType, TabData, TabTriggerEvent, + ScrollPosition, } from './interface'; import { getPrefixCls } from '../_utils/global-config'; import TabsNav from './tabs-nav'; @@ -163,6 +164,15 @@ export default defineComponent({ type: String as PropType, default: 'click', }, + /** + * @zh 被选中 tab 的滚动位置,默认 auto 即会将 activeTab 滚动到可见区域,但不会特意做位置调整 + * @en The scroll position of the selected tab, the default auto will scroll the activeTab to the visible area, but will not adjust the position intentionally + * @values 'start', 'end', 'center', 'auto', number + */ + scrollPosition: { + type: [String, Number] as PropType, + default: 'auto', + }, }, emits: { 'update:activeKey': (key: string | number) => true, @@ -338,6 +348,7 @@ export default defineComponent({ animation={props.animation} showAddButton={props.showAddButton} headerPadding={props.headerPadding} + scrollPosition={props.scrollPosition} size={mergedSize.value} type={props.type} onClick={handleClick} diff --git a/packages/web-vue/components/tabs/utils.ts b/packages/web-vue/components/tabs/utils.ts index 0feb1bf89..8c9d8e022 100644 --- a/packages/web-vue/components/tabs/utils.ts +++ b/packages/web-vue/components/tabs/utils.ts @@ -27,3 +27,17 @@ export const getTabListStyle = ({ return { transform: `translateX(${-offset}px)` }; }; + +export const updateScrollOffset = ( + parentNode: HTMLElement, + direction: 'horizontal' | 'vertical' +) => { + const { scrollTop, scrollLeft } = parentNode; + + if (direction === 'horizontal' && scrollLeft) { + parentNode.scrollTo({ left: -1 * scrollLeft }); + } + if (direction === 'vertical' && scrollTop) { + parentNode.scrollTo({ top: -1 * scrollTop }); + } +};