Skip to content

Commit 4876497

Browse files
feat(jetv-ui): add JeTabs and JeTabPane components with dynamic tab management
1 parent 1ea8bf9 commit 4876497

File tree

10 files changed

+407
-4
lines changed

10 files changed

+407
-4
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<script lang="ts" setup>
2+
import { reactive, ref } from 'vue'
3+
4+
import { JeSlimButton, JeTabPane, JeTabs } from '../../src'
5+
import GalleryCard from '../components/GalleryCard.vue'
6+
import ShowcaseFrame from '../components/ShowcaseFrame.vue'
7+
8+
// 基础
9+
const basicActive = ref<string | number>('tab1')
10+
11+
// 全局 closable
12+
const closableActive = ref<string | number>('c1')
13+
14+
// 动态增删
15+
interface DynTab { value: string, label: string, closable?: boolean }
16+
const dynActive = ref<string | number>('d1')
17+
const dynTabs = reactive<DynTab[]>([
18+
{ value: 'd1', label: '文件 1', closable: true },
19+
{ value: 'd2', label: '文件 2', closable: true },
20+
])
21+
let dynIndex = 3
22+
function handleDynAdd() {
23+
const v = `d${dynIndex++}`
24+
dynTabs.push({ value: v, label: `文件 ${v.slice(1)}`, closable: true })
25+
dynActive.value = v
26+
}
27+
function handleDynClose(v: string | number) {
28+
const i = dynTabs.findIndex(t => t.value === v)
29+
if (i !== -1)
30+
dynTabs.splice(i, 1)
31+
}
32+
33+
// 图标 + 禁用
34+
const iconActive = ref<string | number>('home')
35+
36+
// 懒加载
37+
const lazyActive = ref<string | number>('l1')
38+
</script>
39+
40+
<template>
41+
<ShowcaseFrame>
42+
<GalleryCard title="基础用法">
43+
<JeTabs v-model="basicActive">
44+
<JeTabPane value="tab1" label="概览" />
45+
<JeTabPane value="tab2" label="配置" />
46+
<JeTabPane value="tab3" label="日志" />
47+
</JeTabs>
48+
<div text="default" mt-8px>
49+
当前:{{ basicActive }}
50+
</div>
51+
</GalleryCard>
52+
53+
<GalleryCard title="全局可关闭 (closable)">
54+
<JeTabs v-model="closableActive" closable>
55+
<JeTabPane value="c1" label="文件 A" />
56+
<JeTabPane value="c2" label="文件 B" />
57+
<JeTabPane value="c3" label="文件 C" />
58+
</JeTabs>
59+
</GalleryCard>
60+
61+
<GalleryCard title="动态增删 (addable + 事件)">
62+
<JeTabs
63+
v-model="dynActive"
64+
addable
65+
@tab-add="handleDynAdd"
66+
@tab-close="handleDynClose"
67+
>
68+
<JeTabPane
69+
v-for="t in dynTabs"
70+
:key="t.value"
71+
:value="t.value"
72+
:label="t.label"
73+
:closable="t.closable"
74+
/>
75+
</JeTabs>
76+
<div flex gap-8px mt-8px>
77+
<JeSlimButton @click="handleDynAdd">
78+
新增标签
79+
</JeSlimButton>
80+
<span text="secondary">当前:{{ dynActive }}</span>
81+
</div>
82+
</GalleryCard>
83+
84+
<GalleryCard title="图标 + 禁用">
85+
<JeTabs v-model="iconActive">
86+
<JeTabPane value="home" label="Home" icon="light:i-jet:info dark:i-jet:info-dark" />
87+
<JeTabPane value="settings" label="Settings" icon="light:i-jet:settings dark:i-jet:settings-dark" />
88+
<JeTabPane value="disabled" label="Disabled" disabled icon="light:i-jet:warning dark:i-jet:warning-dark" />
89+
</JeTabs>
90+
</GalleryCard>
91+
92+
<GalleryCard title="懒加载 (lazy)">
93+
<JeTabs v-model="lazyActive">
94+
<JeTabPane value="l1" label="概览" lazy>
95+
<div p-8px>
96+
概览内容 (立即渲染)
97+
</div>
98+
</JeTabPane>
99+
<JeTabPane value="l2" label="大数据" lazy>
100+
<div p-8px>
101+
大数据内容(首次点击才渲染)
102+
</div>
103+
</JeTabPane>
104+
<JeTabPane value="l3" label="统计" lazy>
105+
<div p-8px>
106+
统计内容(首次点击才渲染)
107+
</div>
108+
</JeTabPane>
109+
</JeTabs>
110+
</GalleryCard>
111+
</ShowcaseFrame>
112+
</template>

jetv-ui/gallery/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ export { default as Radio } from './RadioGallery.vue'
1919
export { default as SegmentedControl } from './SegmentedControlGallery.vue'
2020
export { default as Shortcut } from './ShortcutGallery.vue'
2121
export { default as Switch } from './SwitchGallery.vue'
22+
export { default as Tabs } from './TabsGallery.vue'
2223
export { default as Tag } from './TagGallery.vue'
2324
export { default as Tooltip } from './TooltipGallery.vue'

jetv-ui/gallery/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
SegmentedControl,
2424
Shortcut,
2525
Switch,
26+
Tabs,
2627
Tag,
2728
Tooltip,
2829
} from './pages'
@@ -51,6 +52,7 @@ const routes: RouteRecordRaw[] = [
5152
{ path: '/shortcut', component: Shortcut, name: 'shortcut', meta: { label: 'Shortcut' } },
5253
{ path: '/switch', component: Switch, name: 'switch', meta: { label: 'Switch' } },
5354
{ path: '/tag', component: Tag, name: 'tag', meta: { label: 'Tag' } },
55+
{ path: '/tabs', component: Tabs, name: 'tabs', meta: { label: 'Tabs' } },
5456
{ path: '/tooltip', component: Tooltip, name: 'tooltip', meta: { label: 'Tooltip' } },
5557
]
5658

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<script lang="ts" setup>
2+
import { computed, inject, onBeforeUnmount, ref, watchEffect } from 'vue'
3+
4+
import { TABS_INJECT_KEY } from './key'
5+
import type { JeTabPaneProps } from './types'
6+
7+
const props = withDefaults(defineProps<JeTabPaneProps>(), {
8+
disabled: false,
9+
closable: undefined,
10+
lazy: false,
11+
})
12+
13+
// 从 Tabs 注入注册方法
14+
const tabsApi = inject<any>(TABS_INJECT_KEY, null)
15+
16+
// 注册 / 更新
17+
if (tabsApi) {
18+
watchEffect(() => {
19+
tabsApi.registerPane({
20+
value: props.value,
21+
label: props.label,
22+
disabled: props.disabled,
23+
closable: props.closable,
24+
icon: props.icon,
25+
lazy: props.lazy,
26+
})
27+
})
28+
onBeforeUnmount(() => tabsApi.unregisterPane(props.value))
29+
}
30+
31+
// 激活与懒加载逻辑
32+
const loaded = ref(false)
33+
const isActive = computed(() => tabsApi?.activeValue.value === props.value)
34+
watchEffect(() => {
35+
if (isActive.value)
36+
loaded.value = true
37+
})
38+
</script>
39+
40+
<template>
41+
<div v-show="isActive" class="je-tab-pane">
42+
<slot v-if="!props.lazy || loaded" />
43+
</div>
44+
</template>
45+
46+
<style lang="scss" scoped>
47+
.je-tab-pane {
48+
@apply w-full;
49+
}
50+
</style>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<script lang="ts" setup>
2+
import { JeTransparentToolButton } from '../Button'
3+
import { TABS_INJECT_KEY } from './key'
4+
import type { JeTabPaneProps, JeTabsProps } from './types'
5+
6+
interface InternalPane extends JeTabPaneProps { order: number }
7+
8+
const props = withDefaults(defineProps<JeTabsProps>(), {
9+
closable: false,
10+
addable: false,
11+
scrollable: true,
12+
})
13+
14+
const emit = defineEmits<{
15+
(e: 'update:modelValue', v: string | number): void
16+
(e: 'tabAdd'): void
17+
(e: 'tabClose', v: string | number): void
18+
}>()
19+
20+
const panes = reactive<InternalPane[]>([])
21+
let count = 0
22+
23+
const activeValue = computed(() => props.modelValue)
24+
25+
function registerPane(pane: JeTabPaneProps) {
26+
const exist = panes.find(p => p.value === pane.value)
27+
if (exist) {
28+
exist.label = pane.label
29+
exist.disabled = pane.disabled
30+
exist.closable = pane.closable
31+
exist.icon = pane.icon
32+
exist.lazy = pane.lazy
33+
}
34+
else if (!panes.find(p => p.value === pane.value)) {
35+
panes.push({ ...pane, order: count++ })
36+
}
37+
}
38+
function unregisterPane(value: string | number) {
39+
const idx = panes.findIndex(p => p.value === value)
40+
if (idx !== -1)
41+
panes.splice(idx, 1)
42+
}
43+
44+
function updateActive(v: string | number) {
45+
if (v === props.modelValue)
46+
return
47+
emit('update:modelValue', v)
48+
}
49+
50+
function handleClose(e: MouseEvent, pane: InternalPane) {
51+
e.stopPropagation()
52+
emit('tabClose', pane.value)
53+
const idx = panes.findIndex(p => p.value === pane.value)
54+
if (idx !== -1)
55+
panes.splice(idx, 1)
56+
if (pane.value === props.modelValue) {
57+
if (panes.length) {
58+
const newIdx = idx < panes.length ? idx : panes.length - 1
59+
emit('update:modelValue', panes[newIdx].value)
60+
}
61+
else {
62+
emit('update:modelValue', '' as any)
63+
}
64+
}
65+
}
66+
67+
function handleAdd() {
68+
emit('tabAdd')
69+
}
70+
71+
provide(TABS_INJECT_KEY, {
72+
registerPane,
73+
unregisterPane,
74+
activeValue,
75+
parentClosable: toRef(props, 'closable'),
76+
})
77+
78+
const sortedPanes = computed(() => [...panes].sort((a, b) => a.order - b.order))
79+
</script>
80+
81+
<template>
82+
<div class="je-tabs">
83+
<div class="je-tabs__nav-wrapper" :class="{ 'je-tabs__nav-wrapper--scroll': scrollable }">
84+
<div class="je-tabs__nav" :class="{ 'je-tabs__nav--scroll': scrollable }">
85+
<div
86+
v-for="pane in sortedPanes"
87+
:key="pane.value"
88+
class="je-tabs__tab"
89+
:class="[
90+
{ 'je-tabs__tab--active': pane.value === activeValue, 'je-tabs__tab--disabled': pane.disabled },
91+
]"
92+
tabindex="0"
93+
role="tab"
94+
@click="!pane.disabled && updateActive(pane.value)"
95+
@keydown.enter.prevent="!pane.disabled && updateActive(pane.value)"
96+
>
97+
<i
98+
v-if="pane.icon"
99+
class="je-tabs__tab-icon"
100+
:class="pane.icon"
101+
/>
102+
<span class="je-tabs__tab-label truncate">{{ pane.label }}</span>
103+
<i
104+
v-if="(pane.closable ?? closable) && !pane.disabled"
105+
class="je-tabs__tab-close light:i-jet:close-small dark:i-jet:close-small-dark"
106+
@click="e => handleClose(e, pane)"
107+
/>
108+
</div>
109+
110+
<JeTransparentToolButton
111+
v-if="addable"
112+
class="je-tabs__add-btn"
113+
icon="light:i-jet:add dark:i-jet:add-dark"
114+
icon-size="14px"
115+
@click="handleAdd"
116+
/>
117+
</div>
118+
</div>
119+
<div class="je-tabs__content">
120+
<slot />
121+
</div>
122+
</div>
123+
</template>
124+
125+
<style lang="scss" scoped>
126+
.je-tabs {
127+
// 基础排版
128+
@apply flex flex-col w-full;
129+
@apply font-sans text-13px lh-20px;
130+
}
131+
132+
.je-tabs__nav-wrapper {
133+
@apply relative flex items-end; // 底部分隔线
134+
@apply b-b-solid b-b-1px light:b-b-$gray-12 dark:b-b-$gray-3;
135+
136+
&--scroll {
137+
@apply overflow-x-auto scrollbar-none;
138+
}
139+
}
140+
141+
.je-tabs__nav {
142+
@apply flex items-stretch gap-2px px-2px pt-2px; // 间距
143+
144+
&--scroll {
145+
@apply min-w-full;
146+
}
147+
}
148+
149+
.je-tabs__tab {
150+
@apply relative flex items-center gap-6px select-none cursor-pointer;
151+
@apply rounded-t-4px px-10px py-5px outline-0;
152+
@apply light:color-$gray-6 dark:color-$gray-8; // 非激活颜色
153+
@apply light:hover:bg-$gray-13 dark:hover:bg-$gray-2;
154+
@apply transition-colors duration-130 ease-in-out;
155+
156+
&:focus-visible:not(.je-tabs__tab--active):not(.je-tabs__tab--disabled) {
157+
@apply outline outline-2px light:outline-$blue-4 dark:outline-$blue-6 rounded-4px;
158+
}
159+
160+
&--active {
161+
@apply light:bg-$gray-13 dark:bg-$gray-2; // 背景
162+
@apply light:color-$gray-1 dark:color-$gray-12; // 文本
163+
@apply after:absolute after:bottom-0 after:left-0 after:right-0 after:h-2px after:content-empty;
164+
@apply light:after:bg-$blue-4 dark:after:bg-$blue-6; // 下划线指示
165+
}
166+
167+
&--disabled {
168+
@apply cursor-not-allowed light:color-$gray-9 dark:color-$gray-5;
169+
@apply opacity-60;
170+
}
171+
}
172+
173+
.je-tabs__tab-label {
174+
@apply text-default;
175+
}
176+
177+
.je-tabs__tab-icon {
178+
@apply text-14px;
179+
}
180+
181+
.je-tabs__tab-close {
182+
@apply text-14px opacity-70 hover:opacity-100 transition-opacity duration-120 cursor-pointer;
183+
}
184+
185+
.je-tabs__add-btn {
186+
@apply ml-4px my-auto;
187+
}
188+
189+
.je-tabs__content {
190+
@apply pt-8px;
191+
}
192+
</style>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import JeTabPane from './JeTabPane.vue'
2+
import JeTabs from './JeTabs.vue'
3+
import type { JeTabPaneProps, JeTabsProps } from './types'
4+
5+
export { JeTabPane, JeTabs }
6+
export type { JeTabPaneProps, JeTabsProps }

jetv-ui/src/components/Tabs/key.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const TABS_INJECT_KEY = Symbol('je-tabs')

0 commit comments

Comments
 (0)