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

feat: add `resizable` and `ColPage` component (#5188)

* feat: add component resizable

* feat: component `ColPage` with demo
Netfan 3 месяцев назад
Родитель
Сommit
acd87b2250

+ 1 - 0
packages/@core/base/icons/src/lucide.ts

@@ -28,6 +28,7 @@ export {
   Fullscreen,
   Github,
   Grip,
+  GripVertical,
   Info,
   InspectionPanel,
   Languages,

+ 1 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/index.ts

@@ -16,6 +16,7 @@ export * from './pagination';
 export * from './pin-input';
 export * from './popover';
 export * from './radio-group';
+export * from './resizable';
 export * from './scroll-area';
 export * from './select';
 export * from './separator';

+ 48 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/resizable/ResizableHandle.vue

@@ -0,0 +1,48 @@
+<script setup lang="ts">
+import { computed, type HTMLAttributes } from 'vue';
+
+import { GripVertical } from '@vben-core/icons';
+import { cn } from '@vben-core/shared/utils';
+
+import {
+  SplitterResizeHandle,
+  type SplitterResizeHandleEmits,
+  type SplitterResizeHandleProps,
+  useForwardPropsEmits,
+} from 'radix-vue';
+
+const props = defineProps<
+  {
+    class?: HTMLAttributes['class'];
+    withHandle?: boolean;
+  } & SplitterResizeHandleProps
+>();
+const emits = defineEmits<SplitterResizeHandleEmits>();
+
+const delegatedProps = computed(() => {
+  const { class: _, ...delegated } = props;
+  return delegated;
+});
+
+const forwarded = useForwardPropsEmits(delegatedProps, emits);
+</script>
+
+<template>
+  <SplitterResizeHandle
+    v-bind="forwarded"
+    :class="
+      cn(
+        'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&[data-orientation=vertical]>div]:rotate-90 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0',
+        props.class,
+      )
+    "
+  >
+    <template v-if="props.withHandle">
+      <div
+        class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border"
+      >
+        <GripVertical class="h-2.5 w-2.5" />
+      </div>
+    </template>
+  </SplitterResizeHandle>
+</template>

+ 38 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/resizable/ResizablePanelGroup.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+import { computed, type HTMLAttributes } from 'vue';
+
+import { cn } from '@vben-core/shared/utils';
+
+import {
+  SplitterGroup,
+  type SplitterGroupEmits,
+  type SplitterGroupProps,
+  useForwardPropsEmits,
+} from 'radix-vue';
+
+const props = defineProps<
+  { class?: HTMLAttributes['class'] } & SplitterGroupProps
+>();
+const emits = defineEmits<SplitterGroupEmits>();
+
+const delegatedProps = computed(() => {
+  const { class: _, ...delegated } = props;
+  return delegated;
+});
+
+const forwarded = useForwardPropsEmits(delegatedProps, emits);
+</script>
+
+<template>
+  <SplitterGroup
+    v-bind="forwarded"
+    :class="
+      cn(
+        'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
+        props.class,
+      )
+    "
+  >
+    <slot></slot>
+  </SplitterGroup>
+</template>

+ 3 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/resizable/index.ts

@@ -0,0 +1,3 @@
+export { default as ResizableHandle } from './ResizableHandle.vue';
+export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue';
+export { SplitterPanel as ResizablePanel } from 'radix-vue';

+ 107 - 0
packages/effects/common-ui/src/components/col-page/col-page.vue

@@ -0,0 +1,107 @@
+<script lang="ts" setup>
+import type { ColPageProps } from './types';
+
+import { computed, ref, useSlots } from 'vue';
+
+import {
+  ResizableHandle,
+  ResizablePanel,
+  ResizablePanelGroup,
+} from '@vben-core/shadcn-ui';
+
+import Page from '../page/page.vue';
+
+defineOptions({
+  name: 'ColPage',
+  inheritAttrs: false,
+});
+
+const props = withDefaults(defineProps<ColPageProps>(), {
+  leftWidth: 30,
+  rightWidth: 70,
+  resizable: true,
+});
+
+const delegatedProps = computed(() => {
+  const { leftWidth: _, ...delegated } = props;
+  return delegated;
+});
+
+const slots = useSlots();
+
+const delegatedSlots = computed(() => {
+  const resultSlots: string[] = [];
+
+  for (const key of Object.keys(slots)) {
+    if (!['default', 'left'].includes(key)) {
+      resultSlots.push(key);
+    }
+  }
+  return resultSlots;
+});
+
+const leftPanelRef = ref<InstanceType<typeof ResizablePanel>>();
+
+function expandLeft() {
+  leftPanelRef.value?.expand();
+}
+
+function collapseLeft() {
+  leftPanelRef.value?.collapse();
+}
+
+defineExpose({
+  expandLeft,
+  collapseLeft,
+});
+</script>
+<template>
+  <Page v-bind="delegatedProps">
+    <!-- 继承默认的slot -->
+    <template
+      v-for="slotName in delegatedSlots"
+      :key="slotName"
+      #[slotName]="slotProps"
+    >
+      <slot :name="slotName" v-bind="slotProps"></slot>
+    </template>
+
+    <ResizablePanelGroup class="w-full" direction="horizontal">
+      <ResizablePanel
+        ref="leftPanelRef"
+        :collapsed-size="leftCollapsedWidth"
+        :collapsible="leftCollapsible"
+        :default-size="leftWidth"
+        :max-size="leftMaxWidth"
+        :min-size="leftMinWidth"
+      >
+        <template #default="slotProps">
+          <slot
+            name="left"
+            v-bind="{
+              ...slotProps,
+              expand: expandLeft,
+              collapse: collapseLeft,
+            }"
+          ></slot>
+        </template>
+      </ResizablePanel>
+      <ResizableHandle
+        v-if="resizable"
+        :style="{ backgroundColor: splitLine ? undefined : 'transparent' }"
+        :with-handle="splitHandle"
+      />
+      <ResizablePanel
+        :collapsed-size="rightCollapsedWidth"
+        :collapsible="rightCollapsible"
+        :default-size="rightWidth"
+        :max-size="rightMaxWidth"
+        :min-size="rightMinWidth"
+      >
+        <template #default>
+          <slot></slot>
+        </template>
+      </ResizablePanel>
+    </ResizablePanelGroup>
+  </Page>
+</template>

+ 2 - 0
packages/effects/common-ui/src/components/col-page/index.ts

@@ -0,0 +1,2 @@
+export { default as ColPage } from './col-page.vue';
+export * from './types';

+ 26 - 0
packages/effects/common-ui/src/components/col-page/types.ts

@@ -0,0 +1,26 @@
+import type { PageProps } from '../page/types';
+
+export interface ColPageProps extends PageProps {
+  /**
+   * 左侧宽度
+   * @default 30
+   */
+  leftWidth?: number;
+  leftMinWidth?: number;
+  leftMaxWidth?: number;
+  leftCollapsedWidth?: number;
+  leftCollapsible?: boolean;
+  /**
+   * 右侧宽度
+   * @default 70
+   */
+  rightWidth?: number;
+  rightMinWidth?: number;
+  rightCollapsedWidth?: number;
+  rightMaxWidth?: number;
+  rightCollapsible?: boolean;
+
+  resizable?: boolean;
+  splitLine?: boolean;
+  splitHandle?: boolean;
+}

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

@@ -1,5 +1,6 @@
 export * from './api-component';
 export * from './captcha';
+export * from './col-page';
 export * from './ellipsis-text';
 export * from './icon-picker';
 export * from './page';

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

@@ -1 +1,2 @@
 export { default as Page } from './page.vue';
+export * from './types';

+ 3 - 13
packages/effects/common-ui/src/components/page/page.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import type { PageProps } from './types';
+
 import {
   computed,
   nextTick,
@@ -11,23 +13,11 @@ import {
 import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
 import { cn } from '@vben-core/shared/utils';
 
-interface Props {
-  title?: string;
-  description?: string;
-  contentClass?: string;
-  /**
-   * 根据content可见高度自适应
-   */
-  autoContentHeight?: boolean;
-  headerClass?: string;
-  footerClass?: string;
-}
-
 defineOptions({
   name: 'Page',
 });
 
-const { autoContentHeight = false } = defineProps<Props>();
+const { autoContentHeight = false } = defineProps<PageProps>();
 
 const headerHeight = ref(0);
 const footerHeight = ref(0);

+ 11 - 0
packages/effects/common-ui/src/components/page/types.ts

@@ -0,0 +1,11 @@
+export interface PageProps {
+  title?: string;
+  description?: string;
+  contentClass?: string;
+  /**
+   * 根据content可见高度自适应
+   */
+  autoContentHeight?: boolean;
+  headerClass?: string;
+  footerClass?: string;
+}

+ 6 - 0
playground/src/locales/langs/en-US/examples.json

@@ -56,5 +56,11 @@
     "timestamp": "Timestamp:",
     "x": "x:",
     "y": "y:"
+  },
+  "resize": {
+    "title": "Resize"
+  },
+  "layout": {
+    "col-page": "ColPage Layout"
   }
 }

+ 3 - 0
playground/src/locales/langs/zh-CN/examples.json

@@ -59,5 +59,8 @@
     "timestamp": "时间戳:",
     "x": "x:",
     "y": "y:"
+  },
+  "layout": {
+    "col-page": "双列布局"
   }
 }

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

@@ -237,6 +237,17 @@ const routes: RouteRecordRaw[] = [
           title: $t('examples.resize.title'),
         },
       },
+      {
+        name: 'ColPageDemo',
+        path: '/examples/layout/col-page',
+        component: () => import('#/views/examples/layout/col-page.vue'),
+        meta: {
+          badge: 'Alpha',
+          badgeVariants: 'destructive',
+          icon: 'material-symbols:horizontal-distribute',
+          title: $t('examples.layout.col-page'),
+        },
+      },
     ],
   },
 ];

+ 106 - 0
playground/src/views/examples/layout/col-page.vue

@@ -0,0 +1,106 @@
+<script lang="ts" setup>
+import { reactive, ref } from 'vue';
+
+import { ColPage } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import {
+  Alert,
+  Button,
+  Card,
+  Checkbox,
+  Slider,
+  Tag,
+  Tooltip,
+} from 'ant-design-vue';
+
+const props = reactive({
+  leftCollapsedWidth: 5,
+  leftCollapsible: true,
+  leftMaxWidth: 50,
+  leftMinWidth: 20,
+  leftWidth: 30,
+  resizable: true,
+  rightWidth: 70,
+  splitHandle: false,
+  splitLine: false,
+});
+const leftMinWidth = ref(props.leftMinWidth || 1);
+const leftMaxWidth = ref(props.leftMaxWidth || 100);
+</script>
+<template>
+  <ColPage
+    auto-content-height
+    description="ColPage 是一个双列布局组件,支持左侧折叠、拖拽调整宽度等功能。"
+    v-bind="props"
+    title="ColPage 双列布局组件"
+  >
+    <template #title>
+      <span class="mr-2 text-2xl font-bold">ColPage 双列布局组件</span>
+      <Tag color="hsl(var(--destructive))">Alpha</Tag>
+    </template>
+    <template #left="{ isCollapsed, expand }">
+      <div v-if="isCollapsed" @click="expand">
+        <Tooltip title="点击展开左侧">
+          <Button shape="circle" type="primary">
+            <template #icon>
+              <IconifyIcon class="text-2xl" icon="bi:arrow-right" />
+            </template>
+          </Button>
+        </Tooltip>
+      </div>
+      <div
+        v-else
+        :style="{ minWidth: '200px' }"
+        class="border-border bg-card mr-2 rounded-[var(--radius)] border p-2"
+      >
+        <p>这里是左侧内容</p>
+        <p>这里是左侧内容</p>
+        <p>这里是左侧内容</p>
+        <p>这里是左侧内容</p>
+        <p>这里是左侧内容</p>
+      </div>
+    </template>
+    <Card class="ml-2" title="基本使用">
+      <div class="flex flex-col gap-2">
+        <div class="flex gap-2">
+          <Checkbox v-model:checked="props.resizable">可拖动调整宽度</Checkbox>
+          <Checkbox v-model:checked="props.splitLine">显示拖动分隔线</Checkbox>
+          <Checkbox v-model:checked="props.splitHandle">显示拖动手柄</Checkbox>
+          <Checkbox v-model:checked="props.leftCollapsible">
+            左侧可折叠
+          </Checkbox>
+        </div>
+        <div class="flex items-center gap-2">
+          <span>左侧最小宽度百分比:</span>
+          <Slider
+            v-model:value="leftMinWidth"
+            :max="props.leftMaxWidth - 1"
+            :min="1"
+            style="width: 100px"
+            @after-change="(value) => (props.leftMinWidth = value as number)"
+          />
+          <span>左侧最大宽度百分比:</span>
+          <Slider
+            v-model:value="props.leftMaxWidth"
+            :max="100"
+            :min="leftMaxWidth + 1"
+            style="width: 100px"
+            @after-change="(value) => (props.leftMaxWidth = value as number)"
+          />
+        </div>
+        <Alert message="实验性的组件" show-icon type="warning">
+          <template #description>
+            <p>
+              双列布局组件是一个在Page组件上扩展的相对基础的布局组件,支持左侧折叠(当拖拽导致左侧宽度比最小宽度还要小时,还可以进入折叠状态)、拖拽调整宽度等功能。
+            </p>
+            <p>以上宽度设置的数值是百分比,最小值为1,最大值为100。</p>
+            <p class="font-bold text-red-600">
+              这是一个实验性的组件,用法可能会发生变动,也可能最终不会被采用。在其用法正式出现在文档中之前,不建议在生产环境中使用。
+            </p>
+          </template>
+        </Alert>
+      </div>
+    </Card>
+  </ColPage>
+</template>