Просмотр исходного кода

feat: integrate new component `Tippy` with demo (#5355)

* 添加新的工具提示组件Tippy
Netfan 4 месяцев назад
Родитель
Сommit
a2637313f8

+ 4 - 0
apps/web-antd/src/bootstrap.ts

@@ -1,6 +1,7 @@
 import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@vben/access';
+import { initTippy } from '@vben/common-ui';
 import { preferences } from '@vben/preferences';
 import { initStores } from '@vben/stores';
 import '@vben/styles';
@@ -29,6 +30,9 @@ async function bootstrap(namespace: string) {
   // 安装权限指令
   registerAccessDirective(app);
 
+  // 初始化 tippy
+  initTippy(app);
+
   // 配置路由及路由守卫
   app.use(router);
 

+ 4 - 0
apps/web-ele/src/bootstrap.ts

@@ -1,6 +1,7 @@
 import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@vben/access';
+import { initTippy } from '@vben/common-ui';
 import { preferences } from '@vben/preferences';
 import { initStores } from '@vben/stores';
 import '@vben/styles';
@@ -32,6 +33,9 @@ async function bootstrap(namespace: string) {
   // 安装权限指令
   registerAccessDirective(app);
 
+  // 初始化 tippy
+  initTippy(app);
+
   // 配置路由及路由守卫
   app.use(router);
 

+ 4 - 0
apps/web-naive/src/bootstrap.ts

@@ -1,6 +1,7 @@
 import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@vben/access';
+import { initTippy } from '@vben/common-ui';
 import { preferences } from '@vben/preferences';
 import { initStores } from '@vben/stores';
 import '@vben/styles';
@@ -28,6 +29,9 @@ async function bootstrap(namespace: string) {
   // 安装权限指令
   registerAccessDirective(app);
 
+  // 初始化 tippy
+  initTippy(app);
+
   // 配置路由及路由守卫
   app.use(router);
 

+ 4 - 1
packages/effects/common-ui/package.json

@@ -22,6 +22,7 @@
   "dependencies": {
     "@vben-core/form-ui": "workspace:*",
     "@vben-core/popup-ui": "workspace:*",
+    "@vben-core/preferences": "workspace:*",
     "@vben-core/shadcn-ui": "workspace:*",
     "@vben-core/shared": "workspace:*",
     "@vben/constants": "workspace:*",
@@ -32,8 +33,10 @@
     "@vueuse/core": "catalog:",
     "@vueuse/integrations": "catalog:",
     "qrcode": "catalog:",
+    "tippy.js": "catalog:",
     "vue": "catalog:",
-    "vue-router": "catalog:"
+    "vue-router": "catalog:",
+    "vue-tippy": "catalog:"
   },
   "devDependencies": {
     "@types/qrcode": "catalog:"

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

@@ -5,6 +5,7 @@ export * from './ellipsis-text';
 export * from './icon-picker';
 export * from './page';
 export * from './resize';
+export * from './tippy';
 export * from '@vben-core/form-ui';
 export * from '@vben-core/popup-ui';
 

+ 100 - 0
packages/effects/common-ui/src/components/tippy/directive.ts

@@ -0,0 +1,100 @@
+import type { ComputedRef, Directive } from 'vue';
+
+import { useTippy } from 'vue-tippy';
+
+export default function useTippyDirective(isDark: ComputedRef<boolean>) {
+  const directive: Directive = {
+    mounted(el, binding, vnode) {
+      const opts =
+        typeof binding.value === 'string'
+          ? { content: binding.value }
+          : binding.value || {};
+
+      const modifiers = Object.keys(binding.modifiers || {});
+      const placement = modifiers.find((modifier) => modifier !== 'arrow');
+      const withArrow = modifiers.includes('arrow');
+
+      if (placement) {
+        opts.placement = opts.placement || placement;
+      }
+
+      if (withArrow) {
+        opts.arrow = opts.arrow === undefined ? true : opts.arrow;
+      }
+
+      if (vnode.props && vnode.props.onTippyShow) {
+        opts.onShow = function (...args: any[]) {
+          return vnode.props?.onTippyShow(...args);
+        };
+      }
+
+      if (vnode.props && vnode.props.onTippyShown) {
+        opts.onShown = function (...args: any[]) {
+          return vnode.props?.onTippyShown(...args);
+        };
+      }
+
+      if (vnode.props && vnode.props.onTippyHidden) {
+        opts.onHidden = function (...args: any[]) {
+          return vnode.props?.onTippyHidden(...args);
+        };
+      }
+
+      if (vnode.props && vnode.props.onTippyHide) {
+        opts.onHide = function (...args: any[]) {
+          return vnode.props?.onTippyHide(...args);
+        };
+      }
+
+      if (vnode.props && vnode.props.onTippyMount) {
+        opts.onMount = function (...args: any[]) {
+          return vnode.props?.onTippyMount(...args);
+        };
+      }
+
+      if (el.getAttribute('title') && !opts.content) {
+        opts.content = el.getAttribute('title');
+        el.removeAttribute('title');
+      }
+
+      if (el.getAttribute('content') && !opts.content) {
+        opts.content = el.getAttribute('content');
+      }
+
+      useTippy(el, opts);
+    },
+    unmounted(el) {
+      if (el.$tippy) {
+        el.$tippy.destroy();
+      } else if (el._tippy) {
+        el._tippy.destroy();
+      }
+    },
+
+    updated(el, binding) {
+      const opts =
+        typeof binding.value === 'string'
+          ? { content: binding.value, theme: isDark.value ? '' : 'light' }
+          : Object.assign(
+              { theme: isDark.value ? '' : 'light' },
+              binding.value,
+            );
+
+      if (el.getAttribute('title') && !opts.content) {
+        opts.content = el.getAttribute('title');
+        el.removeAttribute('title');
+      }
+
+      if (el.getAttribute('content') && !opts.content) {
+        opts.content = el.getAttribute('content');
+      }
+
+      if (el.$tippy) {
+        el.$tippy.setProps(opts || {});
+      } else if (el._tippy) {
+        el._tippy.setProps(opts || {});
+      }
+    },
+  };
+  return directive;
+}

+ 66 - 0
packages/effects/common-ui/src/components/tippy/index.ts

@@ -0,0 +1,66 @@
+import type { DefaultProps, Props } from 'tippy.js';
+
+import type { App, SetupContext } from 'vue';
+
+import { h, watchEffect } from 'vue';
+import { setDefaultProps, Tippy as TippyComponent } from 'vue-tippy';
+
+import { usePreferences } from '@vben-core/preferences';
+
+import useTippyDirective from './directive';
+
+import 'tippy.js/dist/tippy.css';
+import 'tippy.js/themes/light.css';
+import 'tippy.js/animations/scale.css';
+import 'tippy.js/animations/scale-subtle.css';
+import 'tippy.js/animations/scale-extreme.css';
+import 'tippy.js/animations/shift-away.css';
+import 'tippy.js/animations/perspective.css';
+
+const { isDark } = usePreferences();
+export type TippyProps = Props & {
+  animation?:
+    | 'fade'
+    | 'perspective'
+    | 'scale'
+    | 'scale-extreme'
+    | 'scale-subtle'
+    | 'shift-away'
+    | boolean;
+  theme?: 'auto' | 'dark' | 'light';
+};
+
+export function initTippy(app: App<Element>, options?: DefaultProps) {
+  setDefaultProps({
+    allowHTML: true,
+    delay: [500, 200],
+    theme: isDark.value ? '' : 'light',
+    ...options,
+  });
+  if (!options || !Reflect.has(options, 'theme') || options.theme === 'auto') {
+    watchEffect(() => {
+      setDefaultProps({ theme: isDark.value ? '' : 'light' });
+    });
+  }
+
+  app.directive('tippy', useTippyDirective(isDark));
+}
+
+export const Tippy = (props: any, { attrs, slots }: SetupContext) => {
+  let theme: string = (attrs.theme as string) ?? 'auto';
+  if (theme === 'auto') {
+    theme = isDark.value ? '' : 'light';
+  }
+  if (theme === 'dark') {
+    theme = '';
+  }
+  return h(
+    TippyComponent,
+    {
+      ...props,
+      ...attrs,
+      theme,
+    },
+    slots,
+  );
+};

+ 4 - 0
playground/src/bootstrap.ts

@@ -1,6 +1,7 @@
 import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@vben/access';
+import { initTippy } from '@vben/common-ui';
 import { preferences } from '@vben/preferences';
 import { initStores } from '@vben/stores';
 import '@vben/styles';
@@ -30,6 +31,9 @@ async function bootstrap(namespace: string) {
   // 安装权限指令
   registerAccessDirective(app);
 
+  // 初始化 tippy
+  initTippy(app);
+
   // 配置路由及路由守卫
   app.use(router);
 

+ 9 - 0
playground/src/router/routes/modules/examples.ts

@@ -248,6 +248,15 @@ const routes: RouteRecordRaw[] = [
           title: $t('examples.layout.col-page'),
         },
       },
+      {
+        name: 'TippyDemo',
+        path: '/examples/tippy',
+        component: () => import('#/views/examples/tippy/index.vue'),
+        meta: {
+          icon: 'material-symbols:chat-bubble',
+          title: 'Tippy',
+        },
+      },
     ],
   },
 ];

+ 226 - 0
playground/src/views/examples/tippy/index.vue

@@ -0,0 +1,226 @@
+<script lang="ts" setup>
+import { computed, reactive } from 'vue';
+
+import { Page, Tippy } from '@vben/common-ui';
+
+import { Button, Card, Flex } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+
+const props = reactive({
+  animation: 'shift-away',
+  arrow: true,
+  content: '这是一个提示',
+  delay: 200,
+  duration: 200,
+  followCursor: '',
+  hideOnClick: '',
+  maxWidth: 'none',
+  placement: 'top',
+  theme: 'dark',
+});
+
+const tippyProps = computed(() => {
+  return {
+    ...props,
+    followCursor: ['', 'true'].includes(props.followCursor)
+      ? !!props.followCursor
+      : props.followCursor,
+    hideOnClick: ['', 'true'].includes(props.hideOnClick)
+      ? !!props.hideOnClick
+      : props.hideOnClick,
+  };
+});
+
+const [Form] = useVbenForm({
+  handleValuesChange(values) {
+    Object.assign(props, { ...values });
+  },
+  schema: [
+    {
+      component: 'Select',
+      componentProps: {
+        class: 'w-full',
+        options: [
+          { label: 'shift-away', value: 'shift-away' },
+          { label: 'scale', value: 'scale' },
+          { label: 'scale-extreme', value: 'scale-extreme' },
+          { label: 'scale-subtle', value: 'scale-subtle' },
+          { label: 'perspective', value: 'perspective' },
+          { label: 'fade', value: 'fade' },
+        ],
+      },
+      defaultValue: props.animation,
+      fieldName: 'animation',
+      label: '动画',
+    },
+    {
+      component: 'InputNumber',
+      defaultValue: props.duration,
+      fieldName: 'duration',
+      label: '动画时长',
+    },
+    {
+      component: 'Input',
+      defaultValue: props.content,
+      fieldName: 'content',
+      label: '内容',
+    },
+    {
+      component: 'Switch',
+      defaultValue: props.arrow,
+      fieldName: 'arrow',
+      label: '箭头',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        class: 'w-full',
+        options: [
+          { label: '不跟随', value: '' },
+          { label: '完全跟随', value: 'true' },
+          { label: '仅横向', value: 'horizontal' },
+          { label: '仅纵向', value: 'vertical' },
+          { label: '仅初始', value: 'initial' },
+        ],
+      },
+      defaultValue: props.followCursor,
+      fieldName: 'followCursor',
+      label: '跟随指针',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        class: 'w-full',
+        options: [
+          { label: '否', value: '' },
+          { label: '是', value: 'true' },
+          { label: '仅内部点击', value: 'toggle' },
+        ],
+      },
+      defaultValue: props.hideOnClick,
+      fieldName: 'hideOnClick',
+      label: '点击后隐藏',
+    },
+    {
+      component: 'InputNumber',
+      defaultValue: 100,
+      fieldName: 'delay',
+      label: '延时',
+    },
+
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: 'auto', value: 'auto' },
+          { label: 'dark', value: 'dark' },
+          { label: 'light', value: 'light' },
+        ],
+      },
+      defaultValue: props.theme,
+      fieldName: 'theme',
+      label: '主题',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: 'none、200px',
+      },
+      defaultValue: props.maxWidth,
+      fieldName: 'maxWidth',
+      label: '最大宽度',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        class: 'w-full',
+        options: [
+          { label: '顶部', value: 'top' },
+          { label: '顶左', value: 'top-start' },
+          { label: '顶右', value: 'top-end' },
+          { label: '底部', value: 'bottom' },
+          { label: '底左', value: 'bottom-start' },
+          { label: '底右', value: 'bottom-end' },
+          { label: '左侧', value: 'left' },
+          { label: '左上', value: 'left-start' },
+          { label: '左下', value: 'left-end' },
+          { label: '右侧', value: 'right' },
+          { label: '右上', value: 'right-start' },
+          { label: '右下', value: 'right-end' },
+        ],
+      },
+      defaultValue: 'top',
+      fieldName: 'placement',
+      label: '位置',
+    },
+  ],
+  wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
+});
+
+function goDoc() {
+  window.open('https://atomiks.github.io/tippyjs/v6/all-props/');
+}
+</script>
+<template>
+  <Page title="Tippy">
+    <template #description>
+      <div class="flex items-center">
+        <p>
+          Tippy
+          是一个轻量级的提示工具库,它可以用来创建各种交互式提示,如工具提示、引导提示等。
+        </p>
+        <Button type="link" size="small" @click="goDoc">查看文档</Button>
+      </div>
+    </template>
+    <Card title="指令形式使用">
+      <p class="mb-4">
+        指令形式使用比较简洁,直接在需要展示tooltip的组件上用v-tippy传递配置,适用于固定内容的工具提示。
+      </p>
+      <Flex warp="warp" gap="20">
+        <Button v-tippy="'这是一个提示,使用了默认的配置'">默认配置</Button>
+
+        <Button
+          v-tippy="{ theme: 'light', content: '这是一个提示,总是light主题' }"
+        >
+          指定主题
+        </Button>
+        <Button
+          v-tippy="{
+            theme: 'light',
+            content: '这个提示将在点燃组件100毫秒后激活',
+            delay: 100,
+          }"
+        >
+          指定延时
+        </Button>
+        <Button
+          v-tippy="{
+            content: '本提示的动画为`scale`',
+            animation: 'scale',
+          }"
+        >
+          指定动画
+        </Button>
+      </Flex>
+    </Card>
+    <Card title="组件形式使用" class="mt-4">
+      <div class="flex w-full justify-center">
+        <Tippy v-bind="tippyProps">
+          <Button>鼠标移到这个组件上来体验效果</Button>
+        </Tippy>
+      </div>
+
+      <Form class="mt-4" />
+      <template #actions>
+        <p
+          class="text-secondary-foreground hover:text-secondary-foreground cursor-default"
+        >
+          更多配置请
+          <Button type="link" size="small" @click="goDoc">查看文档</Button>
+          ,这里只列出了一些常用的配置
+        </p>
+      </template>
+    </Card>
+  </Page>
+</template>

+ 38 - 0
pnpm-lock.yaml

@@ -420,6 +420,9 @@ catalogs:
     theme-colors:
       specifier: ^0.1.0
       version: 0.1.0
+    tippy.js:
+      specifier: ^6.2.5
+      version: 6.3.7
     turbo:
       specifier: ^2.3.3
       version: 2.3.3
@@ -474,6 +477,9 @@ catalogs:
     vue-router:
       specifier: ^4.5.0
       version: 4.5.0
+    vue-tippy:
+      specifier: ^6.6.0
+      version: 6.6.0
     vue-tsc:
       specifier: 2.1.10
       version: 2.1.10
@@ -1488,6 +1494,9 @@ importers:
       '@vben-core/popup-ui':
         specifier: workspace:*
         version: link:../../@core/ui-kit/popup-ui
+      '@vben-core/preferences':
+        specifier: workspace:*
+        version: link:../../@core/preferences
       '@vben-core/shadcn-ui':
         specifier: workspace:*
         version: link:../../@core/ui-kit/shadcn-ui
@@ -1518,12 +1527,18 @@ importers:
       qrcode:
         specifier: 'catalog:'
         version: 1.5.4
+      tippy.js:
+        specifier: 'catalog:'
+        version: 6.3.7
       vue:
         specifier: ^3.5.13
         version: 3.5.13(typescript@5.7.3)
       vue-router:
         specifier: 'catalog:'
         version: 4.5.0(vue@3.5.13(typescript@5.7.3))
+      vue-tippy:
+        specifier: 'catalog:'
+        version: 6.6.0(vue@3.5.13(typescript@5.7.3))
     devDependencies:
       '@types/qrcode':
         specifier: 'catalog:'
@@ -3802,6 +3817,9 @@ packages:
   '@polka/url@1.0.0-next.28':
     resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
 
+  '@popperjs/core@2.11.8':
+    resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+
   '@redocly/ajv@8.11.2':
     resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
 
@@ -8872,6 +8890,7 @@ packages:
   rollup-plugin-visualizer@5.13.1:
     resolution: {integrity: sha512-vMg8i6BprL8aFm9DKvL2c8AwS8324EgymYQo9o6E26wgVvwMhsJxS37aNL6ZsU7X9iAcMYwdME7gItLfG5fwJg==}
     engines: {node: '>=18'}
+    deprecated: Contains unintended breaking changes
     hasBin: true
     peerDependencies:
       rolldown: 1.x
@@ -9518,6 +9537,9 @@ packages:
     resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
     engines: {node: '>=14.0.0'}
 
+  tippy.js@6.3.7:
+    resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
+
   tmp@0.0.33:
     resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
     engines: {node: '>=0.6.0'}
@@ -10097,6 +10119,11 @@ packages:
     peerDependencies:
       vue: ^3.5.13
 
+  vue-tippy@6.6.0:
+    resolution: {integrity: sha512-ISRIUQDlcEP05K1nCbvlVcd8yuWS6S3dI91qD0A2slgtwwWjih8Fn9Aymq4SNaHQsdiP5+MLRPZVDxFjKMPgKA==}
+    peerDependencies:
+      vue: ^3.5.13
+
   vue-tsc@2.1.10:
     resolution: {integrity: sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==}
     hasBin: true
@@ -12722,6 +12749,8 @@ snapshots:
 
   '@polka/url@1.0.0-next.28': {}
 
+  '@popperjs/core@2.11.8': {}
+
   '@redocly/ajv@8.11.2':
     dependencies:
       fast-deep-equal: 3.1.3
@@ -19034,6 +19063,10 @@ snapshots:
 
   tinyspy@3.0.2: {}
 
+  tippy.js@6.3.7:
+    dependencies:
+      '@popperjs/core': 2.11.8
+
   tmp@0.0.33:
     dependencies:
       os-tmpdir: 1.0.2
@@ -19728,6 +19761,11 @@ snapshots:
       '@vue/devtools-api': 6.6.4
       vue: 3.5.13(typescript@5.7.3)
 
+  vue-tippy@6.6.0(vue@3.5.13(typescript@5.7.3)):
+    dependencies:
+      tippy.js: 6.3.7
+      vue: 3.5.13(typescript@5.7.3)
+
   vue-tsc@2.1.10(typescript@5.7.3):
     dependencies:
       '@volar/typescript': 2.4.11

+ 2 - 0
pnpm-workspace.yaml

@@ -156,6 +156,7 @@ catalog:
   tailwindcss: ^3.4.17
   tailwindcss-animate: ^1.0.7
   theme-colors: ^0.1.0
+  tippy.js: ^6.2.5
   turbo: ^2.3.3
   typescript: ^5.7.3
   unbuild: ^3.2.0
@@ -175,6 +176,7 @@ catalog:
   vue-eslint-parser: ^9.4.3
   vue-i18n: ^11.0.1
   vue-router: ^4.5.0
+  vue-tippy: ^6.6.0
   vue-tsc: 2.1.10
   vxe-pc-ui: ^4.3.67
   vxe-table: 4.10.0