From 2fcfc313e84b266dd6e4d0fbe15489a6bfa2dc6e Mon Sep 17 00:00:00 2001
From: webfansplz <308241863@qq.com>
Date: Tue, 13 Sep 2022 18:08:14 +0800
Subject: [PATCH] feat: `Tabs` Component

---
 packages/core/src/components/Tabs.ts         | 156 +++++++++++++++++++
 packages/core/src/components/Text.ts         |   9 +-
 packages/core/src/components/index.ts        |   1 +
 packages/playground/components.d.ts          |   4 +
 packages/playground/src/Tabs.vue             |  39 +++++
 packages/playground/src/main.ts              |   3 +-
 packages/vite-plugin-vue-termui/src/index.ts |   3 +
 7 files changed, 210 insertions(+), 5 deletions(-)
 create mode 100644 packages/core/src/components/Tabs.ts
 create mode 100644 packages/playground/src/Tabs.vue

diff --git a/packages/core/src/components/Tabs.ts b/packages/core/src/components/Tabs.ts
new file mode 100644
index 0000000..9d874ad
--- /dev/null
+++ b/packages/core/src/components/Tabs.ts
@@ -0,0 +1,156 @@
+import {
+  defineComponent,
+  h,
+  computed,
+  Comment,
+  Fragment,
+} from '@vue/runtime-core'
+import { ForegroundColor } from 'chalk'
+import type { PropType, VNode } from '@vue/runtime-core'
+import type { LiteralUnion } from '../utils'
+import { TuiBox } from './Box'
+import { TuiText } from './Text'
+import { Styles } from '../renderer/styles'
+import { onKeyData } from '../composables/keyboard'
+
+export const TuiTabs = defineComponent({
+  props: {
+    modelValue: {
+      type: [String, Number],
+      required: true,
+    },
+    flexDirection: {
+      type: String as PropType<Styles['flexDirection']>,
+      default: 'row',
+    },
+    color: {
+      type: String as PropType<LiteralUnion<ForegroundColor, string>>,
+      default: 'white',
+    },
+    bgColor: {
+      type: String as PropType<LiteralUnion<ForegroundColor, string>>,
+      default: 'black',
+    },
+    activeColor: {
+      type: String as PropType<LiteralUnion<ForegroundColor, string>>,
+      default: 'white',
+    },
+    activeBgColor: {
+      type: String as PropType<LiteralUnion<ForegroundColor, string>>,
+      default: 'blue',
+    },
+    useTab: {
+      type: Boolean,
+      default: true,
+    },
+  },
+  emits: ['onChange', 'update:modelValue'],
+  setup(props, { emit, slots }) {
+    const isColumn = computed(
+      () =>
+        props.flexDirection === 'column' ||
+        props.flexDirection === 'column-reverse'
+    )
+
+    const keyMap = computed(() => ({
+      previous: isColumn.value ? 'ArrowUp' : 'ArrowLeft',
+      next: isColumn.value ? 'ArrowDown' : 'ArrowRight',
+    }))
+
+    const children = computed(() => {
+      const defaultSlots = slots.default?.()
+      const children = defaultSlots
+        ?.filter((child) => child.type !== Comment)
+        ?.reduce(
+          (nodeList: VNode[], node: VNode) =>
+            node.type === Fragment
+              ? [...nodeList, ...(node.children as VNode[])]
+              : [...nodeList, node],
+          []
+        )
+
+      return children ?? []
+    })
+
+    function toggleTab(index: number) {
+      emit('update:modelValue', index)
+      emit('onChange', index)
+    }
+
+    onKeyData(
+      [
+        keyMap.value.previous,
+        keyMap.value.next,
+        ...(props.useTab ? ['Tab'] : []),
+      ],
+      ({ key }) => {
+        const step = {
+          [keyMap.value.previous]: -1,
+          [keyMap.value.next]: 1,
+          Tab: 1,
+        }[key]
+        const index = +props.modelValue + step
+        const toggleIndex =
+          index < 0
+            ? children.value.length - 1
+            : index > children.value.length - 1
+            ? 0
+            : index
+        toggleTab(toggleIndex)
+      }
+    )
+
+    function normalizeChild() {
+      return children.value.map((child, index) => {
+        const isActive = +props.modelValue === index
+        return h(
+          TuiBox,
+          {
+            flexGrow: 1,
+          },
+          {
+            default: () =>
+              h(
+                TuiText,
+                {
+                  color: isActive ? props.activeColor : props.color,
+                  bgColor: isActive ? props.activeBgColor : props.bgColor,
+                },
+                { default: () => child }
+              ),
+          }
+        )
+      })
+    }
+
+    return () => {
+      return h(
+        TuiBox,
+        {
+          flexDirection: props.flexDirection,
+          width: '100%',
+          height: '100%',
+        },
+        { default: normalizeChild }
+      )
+    }
+  },
+})
+
+export const TuiTab = defineComponent({
+  props: {
+    name: {
+      type: String,
+      default: '',
+    },
+  },
+  setup(props, { slots }) {
+    return () => {
+      return h(Fragment, {}, [
+        h('tui:text', {}, ' '),
+        h('tui:text', {}, slots?.default?.() ?? props.name),
+        h('tui:text', {}, ' '),
+      ])
+    }
+  },
+})
diff --git a/packages/core/src/components/Text.ts b/packages/core/src/components/Text.ts
index 1f412d9..cf20189 100644
--- a/packages/core/src/components/Text.ts
+++ b/packages/core/src/components/Text.ts
@@ -6,6 +6,7 @@ import {
   defineComponent,
   onUpdated,
 } from '@vue/runtime-core'
+import type { LiteralUnion } from '../utils'
 import type { Styles } from '../renderer/styles'
 import { scheduleUpdateSymbol } from '../injectionSymbols'
 import { colorize } from '../renderer/textColor'
@@ -17,8 +18,8 @@ export const defaultStyle: Styles = {
 }
 
 export interface TuiTextProps {
-  color?: ForegroundColor
-  bgColor?: ForegroundColor
+  color?: LiteralUnion<ForegroundColor, string>
+  bgColor?: LiteralUnion<ForegroundColor, string>
   dimmed?: boolean
   bold?: boolean
   italic?: boolean
@@ -32,8 +33,8 @@ export const TuiText = defineComponent({
   name: 'TuiText',
 
   props: {
-    color: String as PropType<ForegroundColor>,
-    bgColor: String as PropType<ForegroundColor>,
+    color: String as PropType<LiteralUnion<ForegroundColor, string>>,
+    bgColor: String as PropType<LiteralUnion<ForegroundColor, string>>,
     dimmed: Boolean,
     bold: Boolean,
     italic: Boolean,
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index 392921c..f132e6a 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -5,4 +5,5 @@ export { TuiApp } from './App'
 export { TuiBox } from './Box'
 
 export { TuiLink } from './Link'
+export { TuiTabs, TuiTab } from './Tabs'
 // export { default as TuiInput } from './Input.vue'
diff --git a/packages/playground/components.d.ts b/packages/playground/components.d.ts
index c44c4ad..8aaf39d 100644
--- a/packages/playground/components.d.ts
+++ b/packages/playground/components.d.ts
@@ -7,8 +7,12 @@ export {}
 
 declare module '@vue/runtime-core' {
   export interface GlobalComponents {
+    Box: typeof import('vue-termui')['TuiBox']
+    Br: typeof import('vue-termui')['TuiNewline']
     Div: typeof import('vue-termui')['TuiBox']
     Link: typeof import('vue-termui')['TuiLink']
+    Span: typeof import('vue-termui')['TuiText']
+    Tab: typeof import('vue-termui')['TuiTab']
     Text: typeof import('vue-termui')['TuiText']
   }
 }
diff --git a/packages/playground/src/Tabs.vue b/packages/playground/src/Tabs.vue
new file mode 100644
index 0000000..a5905e0
--- /dev/null
+++ b/packages/playground/src/Tabs.vue
@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import {
+  TuiBox as Box,
+  TuiText as Text,
+  TuiTabs as Tabs,
+  TuiTab as Tab,
+  ref,
+  computed,
+} from 'vue-termui'
+
+const tabIndex = ref(2)
+const tabs = ['Vue', 'Vite', 'Pinia', 'VueUse', 'VueTermui']
+const selected = computed(() => tabs[tabIndex.value])
+function onChange(index: number) {}
+</script>
+
+<template>
+  <Box
+    width="30"
+    justifyContent="center"
+    alignItems="center"
+    borderStyle="double"
+  >
+    <Text>
+      Selected:
+      <Text color="yellow">{{ selected }}</Text>
+    </Text>
+  </Box>
+  <Box
+    width="30"
+    justifyContent="center"
+    alignItems="center"
+    borderStyle="round"
+  >
+    <Tabs v-model="tabIndex" @on-change="onChange">
+      <Tab v-for="(tab, index) in tabs" :key="index">{{ tab }}</Tab>
+    </Tabs>
+  </Box>
+</template>
diff --git a/packages/playground/src/main.ts b/packages/playground/src/main.ts
index bbaa6b2..5324db4 100644
--- a/packages/playground/src/main.ts
+++ b/packages/playground/src/main.ts
@@ -1,12 +1,13 @@
 // import devtools from '@vue/devtools'
 // import devtools from '@vue/devtools/node'
 import { createApp } from 'vue-termui'
-import App from './Focusables.vue'
+// import App from './Focusables.vue'
 // import App from './Fragments.vue'
 // import App from './CenteredDemo.vue'
 // import App from './App.vue'
 // import App from './Counter.vue'
 // import App from './Borders.vue'
+import App from './Tabs.vue'
 
 createApp(App, {
   // swapScreens: true,
diff --git a/packages/vite-plugin-vue-termui/src/index.ts b/packages/vite-plugin-vue-termui/src/index.ts
index 35dce1b..35ca554 100644
--- a/packages/vite-plugin-vue-termui/src/index.ts
+++ b/packages/vite-plugin-vue-termui/src/index.ts
@@ -169,6 +169,9 @@ export const VueTuiComponents = new Map<string, ModuleExports>([
   ['a', 'TuiLink'],
   ['link', 'TuiLink'],
 
+  ['tabs', 'TuiTabs'],
+  ['tab', 'TuiTab'],
+
   ['div', 'TuiBox'],
   ['box', 'TuiBox'],