diff --git a/devui/tabs/index.ts b/devui/tabs/index.ts index 85e2a0fd7552e31cbe02ecafcfbb6e0bf65db9fe..7eb180a645b0bee15d7fd2cec0b2e2038725b280 100644 --- a/devui/tabs/index.ts +++ b/devui/tabs/index.ts @@ -1,17 +1,20 @@ -import type { App } from 'vue' -import Tabs from './src/tabs' +import { App } from 'vue'; +// import type { App } from 'vue'; +import Tabs from './src/tabs'; +import Tab from './src/tab'; -Tabs.install = function(app: App) { - app.component(Tabs.name, Tabs) -} +Tabs.install = function (app: App) { + app.component(Tabs.name, Tabs); + app.component(Tab.name, Tab); +}; -export { Tabs } +export { Tabs }; export default { title: 'Tabs 选项卡', category: '导航', status: '60%', install(app: App): void { - app.use(Tabs as any) + app.use(Tabs as any); } -} +}; diff --git a/devui/tabs/src/tab.tsx b/devui/tabs/src/tab.tsx index 93a8d3691dd41cb836a7a3450ad11c7c2c9ad28c..b1f62a2ff59ae5f316e9b2cf680865797e7e0530 100644 --- a/devui/tabs/src/tab.tsx +++ b/devui/tabs/src/tab.tsx @@ -1,4 +1,4 @@ -import { defineComponent, inject } from 'vue' +import { defineComponent, inject } from 'vue'; import { Tabs } from './tabs'; export default defineComponent({ @@ -17,19 +17,20 @@ export default defineComponent({ default: false } }, - setup(props, {slots}) { - const tabs = inject( - 'tabs' - ); + setup(props, { slots }) { + const tabs = inject('tabs'); tabs.state.data.push(props); return () => { - const content = tabs.state.showContent && tabs.state.active === props.id ? ( -
-
- {slots.default()} -
-
): null; - return content - } + const { id } = props; + const content = + tabs.state.showContent && tabs.state.active === id ? ( +
+
+ {slots.default()} +
+
+ ) : null; + return content; + }; } -}) \ No newline at end of file +}); diff --git a/devui/tabs/src/tabs.scss b/devui/tabs/src/tabs.scss index a2cc63c2c283baafd78553656bf78a08f0df3571..92a19b169e95f98be3d6e097c1bb67bc04e4735c 100644 --- a/devui/tabs/src/tabs.scss +++ b/devui/tabs/src/tabs.scss @@ -1,8 +1,5 @@ -@import '../../style/theme/color'; -@import '../../style/theme/variables'; @import '../../style/mixins/index'; -@import '../../style/theme/font'; -@import '../../style/theme/corner'; +@import '../../styles-var/devui-var.scss'; $devui-tab-options-bg: $devui-list-item-hover-bg; @@ -15,6 +12,7 @@ $devui-tab-options-bg: $devui-list-item-hover-bg; font-size: $devui-font-size; background: transparent; font-weight: bold; + list-style: none; li { cursor: pointer; @@ -26,6 +24,7 @@ $devui-tab-options-bg: $devui-list-item-hover-bg; line-height: 30px; background-color: transparent; padding: 0; + text-decoration: none; color: $devui-text; } @@ -297,11 +296,18 @@ $devui-tab-options-bg: $devui-list-item-hover-bg; } .devui-nav { + list-style: none; + padding-left: 0; + li { - a.custom-width { - display: inline-block; - padding: 0; - text-align: center; + a { + text-decoration: none; + + &.custom-width { + display: inline-block; + padding: 0; + text-align: center; + } } } } @@ -353,5 +359,7 @@ $devui-tab-options-bg: $devui-list-item-hover-bg; box-shadow: 0 2px 4px 0 $devui-light-shadow; top: 1px; height: 30px; - transition: left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); + transition: + left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), + width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); } diff --git a/devui/tabs/src/tabs.tsx b/devui/tabs/src/tabs.tsx index f6225ce98f939cb32f11d1195256d22d7f2ff0de..430336510f8071fc92157db7e1ff6670aec9d645 100644 --- a/devui/tabs/src/tabs.tsx +++ b/devui/tabs/src/tabs.tsx @@ -1,8 +1,17 @@ -import { computed, defineComponent, provide, reactive } from 'vue' +import { + defineComponent, + onBeforeMount, + onMounted, + onUpdated, + PropType, + provide, + reactive, + ref +} from 'vue'; import './tabs.scss'; export type Active = string | number | null; -export type TabsType = 'tabs' | 'pills' | 'options' | 'wrapped' | 'slider' +export type TabsType = 'tabs' | 'pills' | 'options' | 'wrapped' | 'slider'; export interface Tabs { state: TabsState } @@ -18,7 +27,7 @@ export default defineComponent({ type: [String, Number], default: null }, - // TODO:其中 slider 类型还没有实现 + type: { type: String as () => TabsType, default: 'tabs' @@ -42,51 +51,149 @@ export default defineComponent({ cssClass: { type: String, default: '' + }, + beforeChange: { + type: Function as PropType<(id: Active) => boolean>, + default: null } }, - // TODO: beforeChange没有完成实现 - emits: ['update:modelValue', 'activeTabChange', 'beforeChange'], + + emits: ['update:modelValue', 'activeTabChange'], setup(props, { emit, slots }) { - const active = computed(() => { - return props.modelValue - }) + const tabsEle = ref(null); + const data = reactive({ offsetLeft: 0, offsetWidth: 0, id: null }); const state: TabsState = reactive({ data: [], - active, + active: props.modelValue, showContent: props.showContent }); provide('tabs', { state }); - function activateTab(tab: Active) { - emit('beforeChange'); - emit('update:modelValue', tab); - if (props.reactivable) { - emit('activeTabChange', tab) + + const canChange = function (currentTab: Active) { + let changeResult = Promise.resolve(true); + if (typeof props.beforeChange === 'function') { + const result: any = props.beforeChange(currentTab); + if (typeof result !== 'undefined') { + if (result.then) { + changeResult = result; + } else { + console.log(result); + changeResult = Promise.resolve(result); + } + } } - } + return changeResult; + }; + const activeClick = function (item, tabEl?) { + if (!props.reactivable && props.modelValue === item.id) { + return; + } + canChange(item.id).then((change) => { + if (!change) { + return; + } + const tab = state.data.find((itemOption) => itemOption.id === item.id); + if (tab && !tab.disabled) { + emit('update:modelValue', tab.id); + if (props.type === 'slider' && tabEl && tabsEle) { + this.offsetLeft = + tabEl.getBoundingClientRect().left - + this.tabsEle.nativeElement.getBoundingClientRect().left; + this.offsetWidth = tabEl.getBoundingClientRect().width; + } + emit('activeTabChange', tab.id); + } + }); + }; const ulClass: string[] = [props.type]; props.cssClass && ulClass.push(props.cssClass); - props.vertical && ulClass.push('devui-nav-stacked') - return () => { - return
-
    - { - state.data.map((item) => { - return
  • activateTab((item.id || item.tabId))} class={active.value === (item.id || item.tabId) ? 'active' : ''} id={item.id || item.tabId} > - - {item.title} - -
  • - }) + props.vertical && ulClass.push('devui-nav-stacked'); + onUpdated(() => { + if (props.type === 'slider') { + // 延时等待active样式切换至正确的tab + setTimeout(() => { + const tabEle = tabsEle.value.querySelector( + '#' + props.modelValue + '.active' + ); + if (tabEle) { + data.offsetLeft = + tabEle.getBoundingClientRect().left - + tabsEle.value.getBoundingClientRect().left; + data.offsetWidth = tabEle.getBoundingClientRect().width; } -
    -
- {slots.default()} -
- - } + }); + } + }); + onBeforeMount(() => { + if ( + props.type !== 'slider' && + props.modelValue === undefined && + state.data.length > 0 + ) { + activeClick(state.data[0]); + } + }); + onMounted(() => { + if ( + props.type === 'slider' && + props.modelValue === undefined && + state.data.length > 0 && + state.data[0] + ) { + activeClick( + state.data[0].tabsEle.value.getElementById(state.data[0].tabId) + ); + } + }); + return () => { + return ( +
+
    + {state.data.map((item) => { + return ( +
  • { + activeClick(item); + }} + class={ + (props.modelValue === (item.id || item.tabId) + ? 'active' + : '') + + ' ' + + (item.disabled ? 'disabled' : '') + } + id={item.id || item.tabId} + > + + {item.title} + +
  • + ); + })} +
    +
+ {slots.default()} +
+ ); + }; } -}) - +}); diff --git a/docs/components/tabs/index.md b/docs/components/tabs/index.md index 1fe147671caabaffff282bdff74f4af2dce92a39..65008afcf22e8ece930a82654ac93016e6c5ac36 100644 --- a/docs/components/tabs/index.md +++ b/docs/components/tabs/index.md @@ -5,3 +5,285 @@ ### 何时使用 用户需要通过平级的区域将大块内容进行收纳和展现,保持界面整洁。 + +### 基本用法 + +:::demo + +```vue + + +``` + +::: + +### Pills 类型 + +:::demo + +```vue + + +``` + +::: + +### Options 类型 + +:::demo + +```vue + + +``` + +::: + +### Wrapped 类型 + +:::demo + +```vue + + +``` + +::: + +### Slider 类型 + +:::demo + +```vue + + +``` + +::: + +### 禁用选项卡 + +:::demo + +```vue + + +``` + +::: + +### 拦截 tab 切换 + +:::demo + +```vue + + +``` + +::: + +### API + +| 参数 | 类型 | 默认 | 说明 | | +| :----------: | :---------------------------------------------: | :----: | :---------------------------------------------------------------------------------------------------: | --------------- | +| type | `tabs \| pills \| options \| wrapped \| slider` | 'tabs' | 可选,选项卡组的类型 | +| showContent | `boolean` | true | 可选,是否显示选项卡对应的内容 | +| v-model | `string` | -- | 可选,当前激活的选项卡,值为选项卡的 id | +| customWidth | `string` | -- | 可选,自定义选项卡的宽 | +| vertical | `boolean` | false | 可选,是否垂直显 | +| beforeChange | `function\|Promise` | -- | tab 切换前的回调函数,返回 boolean 类型,返回 false 可以阻止 tab 的切换 | +| reactivable | `boolean` | false | 可选,点击当前处于激活态的 tab 时是否触发`activeTabChange`事件,`true`为允许触发,`false`为不允许触发 | [拦截 tab 切换] | + +### d-tabs 事件 + +| 参数 | 类型 | 说明 | +| :-------------: | :------------------------: | :-------------------------------------------------- | +| activeTabChange | `function(string\|number)` | 可选,选项卡切换的回调函数,返回当前激活选项卡的 id | + +### d-tab 参数 + +| 参数 | 类型 | 默认 | 说明 | +| :------: | :--------------: | :---: | :------------------------------------- | +| id | `number\|string` | -- | 可选,选项卡的 id 值, 需要设置为唯一值 | +| title | `string` | -- | 可选,选项卡的标题 | +| disabled | `boolean` | false | 可选,选项卡是否不可用 |