Browse Source

feat: add `VbenButtonGroup` and `VbenCheckButtonGroup` with demo (#5591)

* 添加按钮组、选择按钮组以及相应的Demo
Netfan 1 month ago
parent
commit
4570d5b54b

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

@@ -14,6 +14,8 @@ export {
   ChevronRight,
   ChevronsLeft,
   ChevronsRight,
+  Circle,
+  CircleCheckBig,
   CircleHelp,
   Copy,
   CornerDownLeft,

+ 98 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/button-group.vue

@@ -0,0 +1,98 @@
+<script lang="ts" setup>
+import { cn } from '@vben-core/shared/utils';
+
+defineOptions({ name: 'VbenButtonGroup' });
+
+withDefaults(
+  defineProps<{
+    border?: boolean;
+    gap?: number;
+    size?: 'large' | 'middle' | 'small';
+  }>(),
+  { border: false, gap: 0, size: 'middle' },
+);
+</script>
+<template>
+  <div
+    :class="
+      cn(
+        'vben-button-group rounded-md',
+        `size-${size}`,
+        gap ? 'with-gap' : 'no-gap',
+        $attrs.class as string,
+      )
+    "
+    :style="{ gap: gap ? `${gap}px` : '0px' }"
+  >
+    <slot></slot>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.vben-button-group {
+  display: inline-flex;
+
+  &.size-large :deep(button) {
+    height: 2.25rem;
+    padding: 0.5rem 0.75rem;
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+
+    .icon-wrapper {
+      margin-right: 0.4rem;
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+
+  &.size-middle :deep(button) {
+    height: 2rem;
+    padding: 0.25rem 0.5rem;
+    font-size: 0.75rem;
+    line-height: 1rem;
+
+    .icon-wrapper {
+      margin-right: 0.2rem;
+
+      svg {
+        width: 0.75rem;
+        height: 0.75rem;
+      }
+    }
+  }
+
+  &.size-small :deep(button) {
+    height: 1.75rem;
+    padding: 0.2rem 0.4rem;
+    font-size: 0.65rem;
+    line-height: 0.75rem;
+
+    .icon-wrapper {
+      margin-right: 0.1rem;
+
+      svg {
+        width: 0.65rem;
+        height: 0.65rem;
+      }
+    }
+  }
+
+  &.no-gap > :deep(button):nth-of-type(1) {
+    border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
+  }
+
+  &.no-gap > :deep(button):last-of-type {
+    border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
+  }
+
+  &.no-gap {
+    :deep(button + button) {
+      border-left-width: 0;
+      border-radius: 0;
+    }
+  }
+}
+</style>

+ 18 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/button.ts

@@ -22,3 +22,21 @@ export interface VbenButtonProps {
   size?: ButtonVariantSize;
   variant?: ButtonVariants;
 }
+
+export type CustomRenderType = (() => Component | string) | string;
+
+export type ValueType = boolean | number | string;
+
+export interface VbenButtonGroupProps
+  extends Pick<VbenButtonProps, 'disabled'> {
+  beforeChange?: (
+    value: ValueType,
+    isChecked: boolean,
+  ) => boolean | PromiseLike<boolean | undefined> | undefined;
+  btnClass?: any;
+  gap?: number;
+  multiple?: boolean;
+  options?: { label: CustomRenderType; value: ValueType }[];
+  showIcon?: boolean;
+  size?: 'large' | 'middle' | 'small';
+}

+ 163 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/check-button-group.vue

@@ -0,0 +1,163 @@
+<script lang="ts" setup>
+import type { Arrayable } from '@vueuse/core';
+
+import type { ValueType, VbenButtonGroupProps } from './button';
+
+import { computed, ref, watch } from 'vue';
+
+import { Circle, CircleCheckBig, LoaderCircle } from '@vben-core/icons';
+import { VbenRenderContent } from '@vben-core/shadcn-ui';
+import { cn, isFunction } from '@vben-core/shared/utils';
+
+import { objectOmit } from '@vueuse/core';
+
+import VbenButtonGroup from './button-group.vue';
+import Button from './button.vue';
+
+const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
+  gap: 0,
+  multiple: false,
+  showIcon: true,
+  size: 'middle',
+});
+
+const btnDefaultProps = computed(() => {
+  return {
+    ...objectOmit(props, ['options', 'btnClass', 'size', 'disabled']),
+    class: cn(props.btnClass),
+  };
+});
+const modelValue = defineModel<Arrayable<ValueType> | undefined>();
+
+const innerValue = ref<Array<ValueType>>([]);
+const loadingValues = ref<Array<ValueType>>([]);
+watch(
+  () => props.multiple,
+  (val) => {
+    if (val) {
+      modelValue.value = innerValue.value;
+    } else {
+      modelValue.value =
+        innerValue.value.length > 0 ? innerValue.value[0] : undefined;
+    }
+  },
+  { immediate: true },
+);
+
+watch(
+  () => modelValue.value,
+  (val) => {
+    if (Array.isArray(val)) {
+      const arrVal = val.filter((v) => v !== undefined);
+      if (arrVal.length > 0) {
+        innerValue.value = props.multiple
+          ? [...arrVal]
+          : [arrVal[0] as ValueType];
+      } else {
+        innerValue.value = [];
+      }
+    } else {
+      innerValue.value = val === undefined ? [] : [val as ValueType];
+    }
+  },
+  { deep: true },
+);
+
+async function onBtnClick(value: ValueType) {
+  if (props.beforeChange && isFunction(props.beforeChange)) {
+    try {
+      loadingValues.value.push(value);
+      const canChange = await props.beforeChange(
+        value,
+        !innerValue.value.includes(value),
+      );
+      if (canChange === false) {
+        return;
+      }
+    } finally {
+      loadingValues.value.splice(loadingValues.value.indexOf(value), 1);
+    }
+  }
+
+  if (props.multiple) {
+    if (innerValue.value.includes(value)) {
+      innerValue.value = innerValue.value.filter((item) => item !== value);
+    } else {
+      innerValue.value.push(value);
+    }
+    modelValue.value = innerValue.value;
+  } else {
+    innerValue.value = [value];
+    modelValue.value = value;
+  }
+}
+</script>
+<template>
+  <VbenButtonGroup
+    :size="props.size"
+    :gap="props.gap"
+    class="vben-check-button-group"
+  >
+    <Button
+      v-for="(btn, index) in props.options"
+      :key="index"
+      :class="cn('border', props.btnClass)"
+      :disabled="
+        props.disabled ||
+        loadingValues.includes(btn.value) ||
+        (!props.multiple && loadingValues.length > 0)
+      "
+      v-bind="btnDefaultProps"
+      :variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
+      @click="onBtnClick(btn.value)"
+    >
+      <div class="icon-wrapper" v-if="props.showIcon">
+        <LoaderCircle
+          class="animate-spin"
+          v-if="loadingValues.includes(btn.value)"
+        />
+        <CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
+        <Circle v-else />
+      </div>
+      <slot name="option" :label="btn.label" :value="btn.value">
+        <VbenRenderContent :content="btn.label" />
+      </slot>
+    </Button>
+  </VbenButtonGroup>
+</template>
+<style lang="scss" scoped>
+.vben-check-button-group {
+  &:deep(.size-large) button {
+    .icon-wrapper {
+      margin-right: 0.3rem;
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+
+  &:deep(.size-middle) button {
+    .icon-wrapper {
+      margin-right: 0.2rem;
+
+      svg {
+        width: 0.75rem;
+        height: 0.75rem;
+      }
+    }
+  }
+
+  &:deep(.size-small) button {
+    .icon-wrapper {
+      margin-right: 0.1rem;
+
+      svg {
+        width: 0.65rem;
+        height: 0.65rem;
+      }
+    }
+  }
+}
+</style>

+ 2 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/index.ts

@@ -1,3 +1,5 @@
 export type * from './button';
+export { default as VbenButtonGroup } from './button-group.vue';
 export { default as VbenButton } from './button.vue';
+export { default as VbenCheckButtonGroup } from './check-button-group.vue';
 export { default as VbenIconButton } from './icon-button.vue';

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

@@ -15,6 +15,8 @@ export * from '@vben-core/popup-ui';
 // 给文档用
 export {
   VbenButton,
+  VbenButtonGroup,
+  VbenCheckButtonGroup,
   VbenCountToAnimator,
   VbenInputPassword,
   VbenLoading,

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

@@ -63,5 +63,8 @@
   },
   "layout": {
     "col-page": "ColPage Layout"
+  },
+  "button-group": {
+    "title": "Button Group"
   }
 }

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

@@ -63,5 +63,8 @@
   },
   "layout": {
     "col-page": "双列布局"
+  },
+  "button-group": {
+    "title": "按钮组"
   }
 }

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

@@ -299,6 +299,15 @@ const routes: RouteRecordRaw[] = [
           title: 'Loading',
         },
       },
+      {
+        name: 'ButtonGroup',
+        path: '/examples/button-group',
+        component: () => import('#/views/examples/button-group/index.vue'),
+        meta: {
+          icon: 'mdi:check-circle',
+          title: $t('examples.button-group.title'),
+        },
+      },
     ],
   },
 ];

+ 194 - 0
playground/src/views/examples/button-group/index.vue

@@ -0,0 +1,194 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import { reactive, ref } from 'vue';
+
+import {
+  Page,
+  VbenButton,
+  VbenButtonGroup,
+  VbenCheckButtonGroup,
+} from '@vben/common-ui';
+
+import { Button, Card, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+
+const radioValue = ref<string | undefined>('a');
+const checkValue = ref(['a', 'b']);
+
+const options = [
+  { label: '选项1', value: 'a' },
+  { label: '选项2', value: 'b' },
+  { label: '选项3', value: 'c' },
+  { label: '选项4', value: 'd' },
+  { label: '选项5', value: 'e' },
+  { label: '选项6', value: 'f' },
+];
+
+function resetValues() {
+  radioValue.value = undefined;
+  checkValue.value = [];
+}
+
+function beforeChange(v: any, isChecked: boolean) {
+  return new Promise((resolve) => {
+    message.loading({
+      content: `正在设置${v}为${isChecked ? '选中' : '未选中'}...`,
+      duration: 0,
+      key: 'beforeChange',
+    });
+    setTimeout(() => {
+      message.success({ content: `${v} 已设置成功`, key: 'beforeChange' });
+      resolve(true);
+    }, 2000);
+  });
+}
+
+const compProps = reactive({
+  beforeChange: undefined,
+  disabled: false,
+  gap: 0,
+  showIcon: true,
+  size: 'middle',
+} as Recordable<any>);
+
+const [Form] = useVbenForm({
+  handleValuesChange(values) {
+    Object.keys(values).forEach((k) => {
+      if (k === 'beforeChange') {
+        compProps[k] = values[k] ? beforeChange : undefined;
+      } else {
+        compProps[k] = values[k];
+      }
+    });
+  },
+  schema: [
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '大', value: 'large' },
+          { label: '中', value: 'middle' },
+          { label: '小', value: 'small' },
+        ],
+      },
+      defaultValue: compProps.size,
+      fieldName: 'size',
+      label: '尺寸',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '无', value: 0 },
+          { label: '小', value: 5 },
+          { label: '中', value: 15 },
+          { label: '大', value: 30 },
+        ],
+      },
+      defaultValue: compProps.gap,
+      fieldName: 'gap',
+      label: '间距',
+    },
+    {
+      component: 'Switch',
+      defaultValue: compProps.showIcon,
+      fieldName: 'showIcon',
+      label: '显示图标',
+    },
+    {
+      component: 'Switch',
+      defaultValue: compProps.disabled,
+      fieldName: 'disabled',
+      label: '禁用',
+    },
+    {
+      component: 'Switch',
+      defaultValue: false,
+      fieldName: 'beforeChange',
+      label: '前置回调',
+    },
+  ],
+  showDefaultActions: false,
+  submitOnChange: true,
+});
+
+function onBtnClick(value: any) {
+  const opt = options.find((o) => o.value === value);
+  if (opt) {
+    message.success(`点击了按钮${opt.label},value = ${value}`);
+  }
+}
+</script>
+<template>
+  <Page
+    title="VbenButtonGroup 按钮组"
+    description="VbenButtonGroup是一个按钮容器,用于包裹一组按钮,协调整体样式。VbenCheckButtonGroup则可以作为一个表单组件,提供单选或多选功能"
+  >
+    <Card title="基本用法">
+      <template #extra>
+        <Button type="primary" @click="resetValues">清空值</Button>
+      </template>
+      <p class="mt-4">按钮组:</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenButtonGroup v-bind="compProps" border>
+          <VbenButton
+            v-for="btn in options"
+            :key="btn.value"
+            variant="link"
+            @click="onBtnClick(btn.value)"
+          >
+            {{ btn.label }}
+          </VbenButton>
+        </VbenButtonGroup>
+        <VbenButtonGroup v-bind="compProps" border>
+          <VbenButton
+            v-for="btn in options"
+            :key="btn.value"
+            variant="outline"
+            @click="onBtnClick(btn.value)"
+          >
+            {{ btn.label }}
+          </VbenButton>
+        </VbenButtonGroup>
+      </div>
+      <p class="mt-4">单选:{{ radioValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="radioValue"
+          :options="options"
+          v-bind="compProps"
+        />
+      </div>
+      <p class="mt-4">单选插槽:{{ radioValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="radioValue"
+          :options="options"
+          v-bind="compProps"
+        >
+          <template #option="{ label, value }">
+            <div class="flex items-center">
+              <span>{{ label }}</span>
+              <span class="ml-2 text-gray-400">{{ value }}</span>
+            </div>
+          </template>
+        </VbenCheckButtonGroup>
+      </div>
+      <p class="mt-4">多选{{ checkValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="checkValue"
+          multiple
+          :options="options"
+          v-bind="compProps"
+        />
+      </div>
+    </Card>
+
+    <Card title="设置" class="mt-4">
+      <Form />
+    </Card>
+  </Page>
+</template>