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

* 添加按钮组、选择按钮组以及相应的Demo
This commit is contained in:
Netfan 2025-02-24 13:50:50 +08:00 committed by GitHub
parent d49e3e81a4
commit 4570d5b54b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 494 additions and 0 deletions

View File

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

View File

@ -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>

View File

@ -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';
}

View File

@ -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>

View File

@ -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';

View File

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

View File

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

View File

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

View File

@ -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'),
},
},
],
},
];

View File

@ -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>