更新最新代码

This commit is contained in:
luob
2025-12-24 23:48:38 +08:00
parent e728cf2c5e
commit 1fd17ef73a
1320 changed files with 83513 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# ui-kit
用于管理公共组件、不同UI组件库封装的组件

View File

@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormApi } from '../src/form-api';
describe('formApi', () => {
let formApi: FormApi;
beforeEach(() => {
formApi = new FormApi();
});
it('should initialize with default state', () => {
expect(formApi.state).toEqual(
expect.objectContaining({
actionWrapperClass: '',
collapsed: false,
collapsedRows: 1,
commonConfig: {},
handleReset: undefined,
handleSubmit: undefined,
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
wrapperClass: 'grid-cols-1',
}),
);
expect(formApi.isMounted).toBe(false);
});
it('should mount form actions', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
setFieldValue: vi.fn(),
setValues: vi.fn(),
submitForm: vi.fn(),
validate: vi.fn(),
values: { name: 'test' },
};
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
});
it('should get values from form', async () => {
const formActions: any = {
meta: {},
values: { name: 'test' },
};
await formApi.mount(formActions);
const values = await formApi.getValues();
expect(values).toEqual({ name: 'test' });
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
meta: {},
setFieldValue: setFieldValueMock,
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
'new value',
undefined,
);
});
it('should reset form', async () => {
const resetFormMock = vi.fn();
const formActions: any = {
meta: {},
resetForm: resetFormMock,
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submitForm: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
const state = {
handleSubmit: handleSubmitMock,
};
formApi.setState(state);
await formApi.mount(formActions);
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
expect(result).toEqual({ name: 'test' });
});
it('should unmount form and reset state', () => {
formApi.unmount();
expect(formApi.isMounted).toBe(false);
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
meta: {},
validate: validateMock,
};
await formApi.mount(formActions);
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);
});
});
describe('updateSchema', () => {
let instance: FormApi;
beforeEach(() => {
instance = new FormApi();
instance.state = {
schema: [
{ component: 'text', fieldName: 'name' },
{ component: 'number', fieldName: 'age', label: 'Age' },
],
};
});
it('should update the schema correctly when fieldName matches', () => {
const newSchema = [
{ component: 'text', fieldName: 'name' },
{ component: 'number', fieldName: 'age', label: 'Age' },
];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },
{ component: 'number' },
];
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
instance.updateSchema(newSchema);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'All items in the schema array must have a valid `fieldName` property to be updated',
);
});
it('should not update schema if fieldName does not match', () => {
const newSchema = [{ component: 'textarea', fieldName: 'unknown' }];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.component).toBe('number');
});
it('should not update schema if updatedMap is empty', () => {
const newSchema: any[] = [{ component: 'textarea' }];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.component).toBe('number');
});
});

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,87 @@
import type { Component } from 'vue';
import type {
BaseFormComponentType,
FormCommonConfig,
VbenFormAdapterOptions,
} from './types';
import { h } from 'vue';
import {
VbenButton,
VbenCheckbox,
Input as VbenInput,
VbenInputPassword,
VbenPinInput,
VbenSelect,
} from '@vben-core/shadcn-ui';
import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
VbenInput,
VbenInputPassword,
VbenPinInput,
VbenSelect,
};
export const COMPONENT_BIND_EVENT_MAP: Partial<
Record<BaseFormComponentType, string>
> = {
VbenCheckbox: 'checked',
};
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
const { config, defineRules } = options;
const {
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
} = (config || {}) as FormCommonConfig;
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
});
if (defineRules) {
for (const key of Object.keys(defineRules)) {
defineRule(key, defineRules[key as never]);
}
}
const baseModelPropName =
config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME;
const modelPropNameMap = config?.modelPropNameMap as
| Record<BaseFormComponentType, string>
| undefined;
const components = globalShareState.getComponents();
for (const component of Object.keys(components)) {
const key = component as BaseFormComponentType;
COMPONENT_MAP[key] = components[component as never];
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
}
// 覆盖特殊组件的modelPropName
if (modelPropNameMap && modelPropNameMap[key]) {
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
}
}
}

View File

@@ -0,0 +1,24 @@
import type { FormRenderProps } from '../types';
import { computed } from 'vue';
import { createContext } from '@vben-core/shadcn-ui';
export const [injectRenderFormProps, provideFormRenderProps] =
createContext<FormRenderProps>('FormRenderProps');
export const useFormContext = () => {
const formRenderProps = injectRenderFormProps();
const isVertical = computed(() => formRenderProps.layout === 'vertical');
const componentMap = computed(() => formRenderProps.componentMap);
const componentBindEventMap = computed(
() => formRenderProps.componentBindEventMap,
);
return {
componentBindEventMap,
componentMap,
isVertical,
};
};

View File

@@ -0,0 +1,124 @@
import type {
FormItemDependencies,
FormSchemaRuleType,
MaybeComponentProps,
} from '../types';
import { computed, ref, watch } from 'vue';
import { isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
import { injectRenderFormProps } from './context';
export default function useDependencies(
getDependencies: () => FormItemDependencies | undefined,
) {
const values = useFormValues();
const formRenderProps = injectRenderFormProps();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const formApi = formRenderProps.form!;
if (!values) {
throw new Error('useDependencies should be used within <VbenForm>');
}
const isIf = ref(true);
const isDisabled = ref(false);
const isShow = ref(true);
const isRequired = ref(false);
const dynamicComponentProps = ref<MaybeComponentProps>({});
const dynamicRules = ref<FormSchemaRuleType>();
const triggerFieldValues = computed(() => {
// 该字段可能会被多个字段触发
const triggerFields = getDependencies()?.triggerFields ?? [];
return triggerFields.map((dep) => {
return values.value[dep];
});
});
const resetConditionState = () => {
isDisabled.value = false;
isIf.value = true;
isShow.value = true;
isRequired.value = false;
dynamicRules.value = undefined;
dynamicComponentProps.value = {};
};
watch(
[triggerFieldValues, getDependencies],
async ([_values, dependencies]) => {
if (!dependencies || !dependencies?.triggerFields?.length) {
return;
}
resetConditionState();
const {
componentProps,
disabled,
if: whenIf,
required,
rules,
show,
trigger,
} = dependencies;
// 1. 优先判断if如果if为false则不渲染dom后续判断也不再执行
const formValues = values.value;
if (isFunction(whenIf)) {
isIf.value = !!(await whenIf(formValues, formApi));
// 不渲染
if (!isIf.value) return;
} else if (isBoolean(whenIf)) {
isIf.value = whenIf;
if (!isIf.value) return;
}
// 2. 判断show如果show为false则隐藏
if (isFunction(show)) {
isShow.value = !!(await show(formValues, formApi));
if (!isShow.value) return;
} else if (isBoolean(show)) {
isShow.value = show;
if (!isShow.value) return;
}
if (isFunction(componentProps)) {
dynamicComponentProps.value = await componentProps(formValues, formApi);
}
if (isFunction(rules)) {
dynamicRules.value = await rules(formValues, formApi);
}
if (isFunction(disabled)) {
isDisabled.value = !!(await disabled(formValues, formApi));
} else if (isBoolean(disabled)) {
isDisabled.value = disabled;
}
if (isFunction(required)) {
isRequired.value = !!(await required(formValues, formApi));
}
if (isFunction(trigger)) {
await trigger(formValues, formApi);
}
},
{ deep: true, immediate: true },
);
return {
dynamicComponentProps,
dynamicRules,
isDisabled,
isIf,
isRequired,
isShow,
};
}

View File

@@ -0,0 +1,105 @@
import type { FormRenderProps } from '../types';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import {
breakpointsTailwind,
useBreakpoints,
useElementVisibility,
} from '@vueuse/core';
/**
* 动态计算行数
*/
export function useExpandable(props: FormRenderProps) {
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
const isVisible = useElementVisibility(wrapperRef);
const rowMapping = ref<Record<number, number>>({});
// 是否已经计算过一次
const isCalculated = ref(false);
const breakpoints = useBreakpoints(breakpointsTailwind);
const keepFormItemIndex = computed(() => {
const rows = props.collapsedRows ?? 1;
const mapping = rowMapping.value;
let maxItem = 0;
for (let index = 1; index <= rows; index++) {
maxItem += mapping?.[index] ?? 0;
}
// 保持一行
return maxItem - 1 || 1;
});
watch(
[
() => props.showCollapseButton,
() => breakpoints.active().value,
() => props.schema?.length,
() => isVisible.value,
],
async ([val]) => {
if (val) {
await nextTick();
rowMapping.value = {};
isCalculated.value = false;
await calculateRowMapping();
}
},
);
async function calculateRowMapping() {
if (!props.showCollapseButton) {
return;
}
await nextTick();
if (!wrapperRef.value) {
return;
}
// 小屏幕不计算
// if (breakpoints.smaller('sm').value) {
// // 保持一行
// rowMapping.value = { 1: 2 };
// return;
// }
const formItems = [...wrapperRef.value.children];
const container = wrapperRef.value;
const containerStyles = window.getComputedStyle(container);
const rowHeights = containerStyles
.getPropertyValue('grid-template-rows')
.split(' ');
const containerRect = container?.getBoundingClientRect();
formItems.forEach((el) => {
const itemRect = el.getBoundingClientRect();
// 计算元素在第几行
const itemTop = itemRect.top - containerRect.top;
let rowStart = 0;
let cumulativeHeight = 0;
for (const [i, rowHeight] of rowHeights.entries()) {
cumulativeHeight += Number.parseFloat(rowHeight);
if (itemTop < cumulativeHeight) {
rowStart = i + 1;
break;
}
}
if (rowStart > (props?.collapsedRows ?? 1)) {
return;
}
rowMapping.value[rowStart] = (rowMapping.value[rowStart] ?? 0) + 1;
isCalculated.value = true;
});
}
onMounted(() => {
calculateRowMapping();
});
return { isCalculated, keepFormItemIndex, wrapperRef };
}

View File

@@ -0,0 +1,60 @@
import type {
AnyZodObject,
ZodDefault,
ZodEffects,
ZodNumber,
ZodString,
ZodTypeAny,
} from 'zod';
import { isObject, isString } from '@vben-core/shared/utils';
/**
* Get the lowest level Zod type.
* This will unpack optionals, refinements, etc.
*/
export function getBaseRules<
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
if (!schema || isString(schema)) return null;
if ('innerType' in schema._def)
return getBaseRules(schema._def.innerType as ChildType);
if ('schema' in schema._def)
return getBaseRules(schema._def.schema as ChildType);
return schema as ChildType;
}
/**
* Search for a "ZodDefault" in the Zod stack and return its value.
*/
export function getDefaultValueInZodStack(schema: ZodTypeAny): any {
if (!schema || isString(schema)) {
return;
}
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
if (typedSchema._def.typeName === 'ZodDefault')
return typedSchema._def.defaultValue();
if ('innerType' in typedSchema._def) {
return getDefaultValueInZodStack(
typedSchema._def.innerType as unknown as ZodTypeAny,
);
}
if ('schema' in typedSchema._def) {
return getDefaultValueInZodStack(
(typedSchema._def as any).schema as ZodTypeAny,
);
}
return undefined;
}
export function isEventObjectLike(obj: any) {
if (!obj || !isObject(obj)) {
return false;
}
return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation');
}

View File

@@ -0,0 +1,3 @@
export { default as FormField } from './form-field.vue';
export { default as FormLabel } from './form-label.vue';
export { default as Form } from './form.vue';

View File

@@ -0,0 +1,12 @@
export { setupVbenForm } from './config';
export type {
BaseFormComponentType,
ExtendedFormApi,
VbenFormProps,
FormSchema as VbenFormSchema,
} from './types';
export * from './use-vben-form';
// export { default as VbenForm } from './vben-form.vue';
export * as z from 'zod';

View File

@@ -0,0 +1,50 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
VbenFormProps,
} from './types';
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
import { useStore } from '@vben-core/shared/store';
import { FormApi } from './form-api';
import VbenUseForm from './vben-use-form.vue';
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormProps<T>) {
const IS_REACTIVE = isReactive(options);
const api = new FormApi(options);
const extendedApi: ExtendedFormApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Form = defineComponent(
(props: VbenFormProps, { attrs, slots }) => {
onBeforeUnmount(() => {
api.unmount();
});
api.setState({ ...props, ...attrs });
return () =>
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
},
{
name: 'VbenUseForm',
inheritAttrs: false,
},
);
// Add reactivity support
if (IS_REACTIVE) {
watch(
() => options.schema,
() => {
api.setState({ schema: options.schema });
},
{ immediate: true },
);
}
return [Form, extendedApi] as const;
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src", "__tests__"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,5 @@
export { default as LayoutContent } from './layout-content.vue';
export { default as LayoutFooter } from './layout-footer.vue';
export { default as LayoutHeader } from './layout-header.vue';
export { default as LayoutSidebar } from './layout-sidebar.vue';
export { default as LayoutTabbar } from './layout-tabbar.vue';

View File

@@ -0,0 +1,2 @@
export { default as SidebarCollapseButton } from './sidebar-collapse-button.vue';
export { default as SidebarFixedButton } from './sidebar-fixed-button.vue';

View File

@@ -0,0 +1,53 @@
import type { LayoutType } from '@vben-core/typings';
import type { VbenLayoutProps } from '../vben-layout';
import { computed } from 'vue';
export function useLayout(props: VbenLayoutProps) {
const currentLayout = computed(() =>
props.isMobile ? 'sidebar-nav' : (props.layout as LayoutType),
);
/**
* 是否全屏显示content不需要侧边、底部、顶部、tab区域
*/
const isFullContent = computed(() => currentLayout.value === 'full-content');
/**
* 是否侧边混合模式
*/
const isSidebarMixedNav = computed(
() => currentLayout.value === 'sidebar-mixed-nav',
);
/**
* 是否为头部导航模式
*/
const isHeaderNav = computed(() => currentLayout.value === 'header-nav');
/**
* 是否为混合导航模式
*/
const isMixedNav = computed(
() =>
currentLayout.value === 'mixed-nav' ||
currentLayout.value === 'header-sidebar-nav',
);
/**
* 是否为头部混合模式
*/
const isHeaderMixedNav = computed(
() => currentLayout.value === 'header-mixed-nav',
);
return {
currentLayout,
isFullContent,
isHeaderMixedNav,
isHeaderNav,
isMixedNav,
isSidebarMixedNav,
};
}

View File

@@ -0,0 +1,2 @@
export type * from './vben-layout';
export { default as VbenAdminLayout } from './vben-layout.vue';

View File

@@ -0,0 +1,175 @@
import type {
ContentCompactType,
LayoutHeaderModeType,
LayoutType,
ThemeModeType,
} from '@vben-core/typings';
interface VbenLayoutProps {
/**
* 内容区域定宽
* @default 'wide'
*/
contentCompact?: ContentCompactType;
/**
* 定宽布局宽度
* @default 1200
*/
contentCompactWidth?: number;
/**
* padding
* @default 16
*/
contentPadding?: number;
/**
* paddingBottom
* @default 16
*/
contentPaddingBottom?: number;
/**
* paddingLeft
* @default 16
*/
contentPaddingLeft?: number;
/**
* paddingRight
* @default 16
*/
contentPaddingRight?: number;
/**
* paddingTop
* @default 16
*/
contentPaddingTop?: number;
/**
* footer 是否可见
* @default false
*/
footerEnable?: boolean;
/**
* footer 是否固定
* @default true
*/
footerFixed?: boolean;
/**
* footer 高度
* @default 32
*/
footerHeight?: number;
/**
* header高度
* @default 48
*/
headerHeight?: number;
/**
* 顶栏是否隐藏
* @default false
*/
headerHidden?: boolean;
/**
* header 显示模式
* @default 'fixed'
*/
headerMode?: LayoutHeaderModeType;
/**
* header 顶栏主题
*/
headerTheme?: ThemeModeType;
/**
* 是否显示header切换侧边栏按钮
* @default
*/
headerToggleSidebarButton?: boolean;
/**
* header是否显示
* @default true
*/
headerVisible?: boolean;
/**
* 是否移动端显示
* @default false
*/
isMobile?: boolean;
/**
* 布局方式
* sidebar-nav 侧边菜单布局
* header-nav 顶部菜单布局
* mixed-nav 侧边&顶部菜单布局
* sidebar-mixed-nav 侧边混合菜单布局
* full-content 全屏内容布局
* @default sidebar-nav
*/
layout?: LayoutType;
/**
* 侧边菜单折叠状态
* @default false
*/
sidebarCollapse?: boolean;
/**
* 侧边菜单折叠按钮
* @default true
*/
sidebarCollapsedButton?: boolean;
/**
* 侧边菜单是否折叠时是否显示title
* @default true
*/
sidebarCollapseShowTitle?: boolean;
/**
* 侧边栏是否可见
* @default true
*/
sidebarEnable?: boolean;
/**
* 侧边菜单折叠额外宽度
* @default 48
*/
sidebarExtraCollapsedWidth?: number;
/**
* 侧边菜单折叠按钮是否固定
* @default true
*/
sidebarFixedButton?: boolean;
/**
* 侧边栏是否隐藏
* @default false
*/
sidebarHidden?: boolean;
/**
* 混合侧边栏宽度
* @default 80
*/
sidebarMixedWidth?: number;
/**
* 侧边栏
* @default dark
*/
sidebarTheme?: ThemeModeType;
/**
* 侧边栏宽度
* @default 210
*/
sidebarWidth?: number;
/**
* 侧边菜单折叠宽度
* @default 48
*/
sideCollapseWidth?: number;
/**
* tab是否可见
* @default true
*/
tabbarEnable?: boolean;
/**
* tab高度
* @default 30
*/
tabbarHeight?: number;
/**
* zIndex
* @default 100
*/
zIndex?: number;
}
export type { VbenLayoutProps };

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1 @@
# 菜单组件

View File

@@ -0,0 +1,26 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
pattern: ['**/*'],
},
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { RendererElement } from 'vue';
defineOptions({
name: 'CollapseTransition',
});
const reset = (el: RendererElement) => {
el.style.maxHeight = '';
el.style.overflow = el.dataset.oldOverflow;
el.style.paddingTop = el.dataset.oldPaddingTop;
el.style.paddingBottom = el.dataset.oldPaddingBottom;
};
const on = {
afterEnter(el: RendererElement) {
el.style.maxHeight = '';
el.style.overflow = el.dataset.oldOverflow;
},
afterLeave(el: RendererElement) {
reset(el);
},
beforeEnter(el: RendererElement) {
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingTop = el.style.paddingTop;
el.dataset.oldMarginTop = el.style.marginTop;
el.dataset.oldPaddingBottom = el.style.paddingBottom;
el.dataset.oldMarginBottom = el.style.marginBottom;
if (el.style.height) el.dataset.elExistsHeight = el.style.height;
el.style.maxHeight = 0;
el.style.paddingTop = 0;
el.style.marginTop = 0;
el.style.paddingBottom = 0;
el.style.marginBottom = 0;
},
beforeLeave(el: RendererElement) {
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingTop = el.style.paddingTop;
el.dataset.oldMarginTop = el.style.marginTop;
el.dataset.oldPaddingBottom = el.style.paddingBottom;
el.dataset.oldMarginBottom = el.style.marginBottom;
el.dataset.oldOverflow = el.style.overflow;
el.style.maxHeight = `${el.scrollHeight}px`;
el.style.overflow = 'hidden';
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow;
if (el.dataset.elExistsHeight) {
el.style.maxHeight = el.dataset.elExistsHeight;
} else if (el.scrollHeight === 0) {
el.style.maxHeight = 0;
} else {
el.style.maxHeight = `${el.scrollHeight}px`;
}
el.style.paddingTop = el.dataset.oldPaddingTop;
el.style.paddingBottom = el.dataset.oldPaddingBottom;
el.style.marginTop = el.dataset.oldMarginTop;
el.style.marginBottom = el.dataset.oldMarginBottom;
el.style.overflow = 'hidden';
});
},
enterCancelled(el: RendererElement) {
reset(el);
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0;
el.style.paddingTop = 0;
el.style.paddingBottom = 0;
el.style.marginTop = 0;
el.style.marginBottom = 0;
}
},
leaveCancelled(el: RendererElement) {
reset(el);
},
};
</script>
<template>
<transition name="collapse-transition" v-on="on">
<slot></slot>
</transition>
</template>

View File

@@ -0,0 +1,4 @@
export { default as MenuBadge } from './menu-badge.vue';
export { default as MenuItem } from './menu-item.vue';
export { default as Menu } from './menu.vue';
export { default as SubMenu } from './sub-menu.vue';

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
interface Props {
dotClass?: string;
dotStyle?: CSSProperties;
}
withDefaults(defineProps<Props>(), {
dotClass: '',
dotStyle: () => ({}),
});
</script>
<template>
<span class="relative mr-1 flex size-1.5">
<span
:class="dotClass"
:style="dotStyle"
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
>
</span>
<span
:class="dotClass"
:style="dotStyle"
class="relative inline-flex size-1.5 rounded-full"
></span>
</span>
</template>

View File

@@ -0,0 +1,122 @@
<script lang="ts" setup>
import type { MenuItemProps, MenuItemRegistered } from '../types';
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
import { useNamespace } from '@vben-core/composables';
import { VbenIcon, VbenTooltip } from '@vben-core/shadcn-ui';
import { MenuBadge } from '../components';
import { useMenu, useMenuContext, useSubMenuContext } from '../hooks';
interface Props extends MenuItemProps {}
defineOptions({ name: 'MenuItem' });
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{ click: [MenuItemRegistered] }>();
const slots = useSlots();
const { b, e, is } = useNamespace('menu-item');
const nsMenu = useNamespace('menu');
const rootMenu = useMenuContext();
const subMenu = useSubMenuContext();
const { parentMenu, parentPaths } = useMenu();
const active = computed(() => props.path === rootMenu?.activePath);
const menuIcon = computed(() =>
active.value ? props.activeIcon || props.icon : props.icon,
);
const isTopLevelMenuItem = computed(
() => parentMenu.value?.type.name === 'Menu',
);
const collapseShowTitle = computed(
() =>
rootMenu.props?.collapseShowTitle &&
isTopLevelMenuItem.value &&
rootMenu.props.collapse,
);
const showTooltip = computed(
() =>
rootMenu.props.mode === 'vertical' &&
isTopLevelMenuItem.value &&
rootMenu.props?.collapse &&
slots.title,
);
const item: MenuItemRegistered = reactive({
active,
parentPaths: parentPaths.value,
path: props.path || '',
});
/**
* 菜单项点击事件
*/
function handleClick() {
if (props.disabled) {
return;
}
rootMenu?.handleMenuItemClick?.({
parentPaths: parentPaths.value,
path: props.path,
});
emit('click', item);
}
onMounted(() => {
subMenu?.addSubMenu?.(item);
rootMenu?.addMenuItem?.(item);
});
onBeforeUnmount(() => {
subMenu?.removeSubMenu?.(item);
rootMenu?.removeMenuItem?.(item);
});
</script>
<template>
<li
:class="[
rootMenu.theme,
b(),
is('active', active),
is('disabled', disabled),
is('collapse-show-title', collapseShowTitle),
]"
role="menuitem"
@click.stop="handleClick"
>
<VbenTooltip
v-if="showTooltip"
:content-class="[rootMenu.theme]"
side="right"
>
<template #trigger>
<div :class="[nsMenu.be('tooltip', 'trigger')]">
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" fallback />
<slot></slot>
<span v-if="collapseShowTitle" :class="nsMenu.e('name')">
<slot name="title"></slot>
</span>
</div>
</template>
<slot name="title"></slot>
</VbenTooltip>
<div v-show="!showTooltip" :class="[e('content')]">
<MenuBadge
v-if="rootMenu.props.mode !== 'horizontal'"
class="right-2"
v-bind="props"
/>
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
<slot></slot>
<slot name="title"></slot>
</div>
</li>
</template>

View File

@@ -0,0 +1,872 @@
<script lang="ts" setup>
import type { UseResizeObserverReturn } from '@vueuse/core';
import type { SetupContext, VNodeArrayChildren } from 'vue';
import type {
MenuItemClicked,
MenuItemRegistered,
MenuProps,
MenuProvider,
} from '../types';
import {
computed,
nextTick,
reactive,
ref,
toRef,
useSlots,
watch,
watchEffect,
} from 'vue';
import { useNamespace } from '@vben-core/composables';
import { Ellipsis } from '@vben-core/icons';
import { useResizeObserver } from '@vueuse/core';
import {
createMenuContext,
createSubMenuContext,
useMenuStyle,
} from '../hooks';
import { useMenuScroll } from '../hooks/use-menu-scroll';
import { flattedChildren } from '../utils';
import SubMenu from './sub-menu.vue';
interface Props extends MenuProps {}
defineOptions({ name: 'Menu' });
const props = withDefaults(defineProps<Props>(), {
accordion: true,
collapse: false,
mode: 'vertical',
rounded: true,
theme: 'dark',
scrollToActive: false,
});
const emit = defineEmits<{
close: [string, string[]];
open: [string, string[]];
select: [string, string[]];
}>();
const { b, is } = useNamespace('menu');
const menuStyle = useMenuStyle();
const slots: SetupContext['slots'] = useSlots();
const menu = ref<HTMLUListElement>();
const sliceIndex = ref(-1);
const openedMenus = ref<MenuProvider['openedMenus']>(
props.defaultOpeneds && !props.collapse ? [...props.defaultOpeneds] : [],
);
const activePath = ref<MenuProvider['activePath']>(props.defaultActive);
const items = ref<MenuProvider['items']>({});
const subMenus = ref<MenuProvider['subMenus']>({});
const mouseInChild = ref(false);
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
return (
props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
);
});
const getSlot = computed(() => {
// 更新插槽内容
const defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
const originalSlot = flattedChildren(defaultSlots) as VNodeArrayChildren;
const slotDefault =
sliceIndex.value === -1
? originalSlot
: originalSlot.slice(0, sliceIndex.value);
const slotMore =
sliceIndex.value === -1 ? [] : originalSlot.slice(sliceIndex.value);
return { showSlotMore: slotMore.length > 0, slotDefault, slotMore };
});
watch(
() => props.collapse,
(value) => {
if (value) openedMenus.value = [];
},
);
watch(items.value, initMenu);
watch(
() => props.defaultActive,
(currentActive = '') => {
if (!items.value[currentActive]) {
activePath.value = '';
}
updateActiveName(currentActive);
},
);
let resizeStopper: UseResizeObserverReturn['stop'];
watchEffect(() => {
if (props.mode === 'horizontal') {
resizeStopper = useResizeObserver(menu, handleResize).stop;
} else {
resizeStopper?.();
}
});
// 注入上下文
createMenuContext(
reactive({
activePath,
addMenuItem,
addSubMenu,
closeMenu,
handleMenuItemClick,
handleSubMenuClick,
isMenuPopup,
openedMenus,
openMenu,
props,
removeMenuItem,
removeSubMenu,
subMenus,
theme: toRef(props, 'theme'),
items,
}),
);
createSubMenuContext({
addSubMenu,
level: 1,
mouseInChild,
removeSubMenu,
});
function calcMenuItemWidth(menuItem: HTMLElement) {
const computedStyle = getComputedStyle(menuItem);
const marginLeft = Number.parseInt(computedStyle.marginLeft, 10);
const marginRight = Number.parseInt(computedStyle.marginRight, 10);
return menuItem.offsetWidth + marginLeft + marginRight || 0;
}
function calcSliceIndex() {
if (!menu.value) {
return -1;
}
const items = [...(menu.value?.childNodes ?? [])].filter(
(item) =>
// remove comment type node #12634
item.nodeName !== '#comment' &&
(item.nodeName !== '#text' || item.nodeValue),
) as HTMLElement[];
const moreItemWidth = 46;
const computedMenuStyle = getComputedStyle(menu?.value);
const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10);
const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10);
const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight;
let calcWidth = 0;
let sliceIndex = 0;
items.forEach((item, index) => {
calcWidth += calcMenuItemWidth(item);
if (calcWidth <= menuWidth - moreItemWidth) {
sliceIndex = index + 1;
}
});
return sliceIndex === items.length ? -1 : sliceIndex;
}
function debounce(fn: () => void, wait = 33.34) {
let timer: null | ReturnType<typeof setTimeout>;
return () => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, wait);
};
}
let isFirstTimeRender = true;
function handleResize() {
if (sliceIndex.value === calcSliceIndex()) {
return;
}
const callback = () => {
sliceIndex.value = -1;
nextTick(() => {
sliceIndex.value = calcSliceIndex();
});
};
callback();
// // execute callback directly when first time resize to avoid shaking
isFirstTimeRender ? callback() : debounce(callback)();
isFirstTimeRender = false;
}
const enableScroll = computed(
() => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
);
const { scrollToActiveItem } = useMenuScroll(activePath, {
enable: enableScroll,
delay: 320,
});
// 监听 activePath 变化,自动滚动到激活项
watch(activePath, () => {
scrollToActiveItem();
});
// 默认展开菜单
function initMenu() {
const parentPaths = getActivePaths();
// 展开该菜单项的路径上所有子菜单
// expand all subMenus of the menu item
parentPaths.forEach((path) => {
const subMenu = subMenus.value[path];
subMenu && openMenu(path, subMenu.parentPaths);
});
}
function updateActiveName(val: string) {
const itemsInData = items.value;
const item =
itemsInData[val] ||
(activePath.value && itemsInData[activePath.value]) ||
itemsInData[props.defaultActive || ''];
activePath.value = item ? item.path : val;
}
function handleMenuItemClick(data: MenuItemClicked) {
const { collapse, mode } = props;
if (mode === 'horizontal' || collapse) {
openedMenus.value = [];
}
const { parentPaths, path } = data;
if (!path || !parentPaths) {
return;
}
emit('select', path, parentPaths);
}
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
const isOpened = openedMenus.value.includes(path);
if (isOpened) {
closeMenu(path, parentPaths);
} else {
openMenu(path, parentPaths);
}
}
function close(path: string) {
const i = openedMenus.value.indexOf(path);
if (i !== -1) {
openedMenus.value.splice(i, 1);
}
}
/**
* 关闭、折叠菜单
*/
function closeMenu(path: string, parentPaths: string[]) {
if (props.accordion) {
openedMenus.value = subMenus.value[path]?.parentPaths ?? [];
}
close(path);
emit('close', path, parentPaths);
}
/**
* 点击展开菜单
*/
function openMenu(path: string, parentPaths: string[]) {
if (openedMenus.value.includes(path)) {
return;
}
// 手风琴模式菜单
if (props.accordion) {
const activeParentPaths = getActivePaths();
if (activeParentPaths.includes(path)) {
parentPaths = activeParentPaths;
}
openedMenus.value = openedMenus.value.filter((path: string) =>
parentPaths.includes(path),
);
}
openedMenus.value.push(path);
emit('open', path, parentPaths);
}
function addMenuItem(item: MenuItemRegistered) {
items.value[item.path] = item;
}
function addSubMenu(subMenu: MenuItemRegistered) {
subMenus.value[subMenu.path] = subMenu;
}
function removeSubMenu(subMenu: MenuItemRegistered) {
Reflect.deleteProperty(subMenus.value, subMenu.path);
}
function removeMenuItem(item: MenuItemRegistered) {
Reflect.deleteProperty(items.value, item.path);
}
function getActivePaths() {
const activeItem = activePath.value && items.value[activePath.value];
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
return [];
}
return activeItem.parentPaths;
}
</script>
<template>
<ul
ref="menu"
:class="[
theme,
b(),
is(mode, true),
is(theme, true),
is('rounded', rounded),
is('collapse', collapse),
is('menu-align', mode === 'horizontal'),
]"
:style="menuStyle"
role="menu"
>
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
<template v-for="item in getSlot.slotDefault" :key="item.key">
<component :is="item" />
</template>
<SubMenu is-sub-menu-more path="sub-menu-more">
<template #title>
<Ellipsis class="size-4" />
</template>
<template v-for="item in getSlot.slotMore" :key="item.key">
<component :is="item" />
</template>
</SubMenu>
</template>
<template v-else>
<slot></slot>
</template>
</ul>
</template>
<style lang="scss">
$namespace: vben;
@mixin menu-item-active {
color: var(--menu-item-active-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-item-active-background-color);
}
@mixin menu-item {
position: relative;
display: flex;
// gap: 12px;
align-items: center;
height: var(--menu-item-height);
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
margin: 0 var(--menu-item-margin-x) var(--menu-item-margin-y)
var(--menu-item-margin-x);
font-size: var(--menu-font-size);
color: var(--menu-item-color);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
list-style: none;
background: var(--menu-item-background-color);
border: none;
border-radius: var(--menu-item-radius);
transition:
background 0.15s ease,
color 0.15s ease,
padding 0.15s ease,
border-color 0.15s ease;
&.is-disabled {
cursor: not-allowed;
background: none !important;
opacity: 0.25;
}
.#{$namespace}-menu__icon {
transition: transform 0.25s;
}
&:hover {
.#{$namespace}-menu__icon {
transform: scale(1.2);
}
}
&:hover,
&:focus {
outline: none;
}
* {
vertical-align: bottom;
}
}
@mixin menu-title {
max-width: var(--menu-title-width);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 1;
}
.is-menu-align {
justify-content: var(--menu-align, start);
}
.#{$namespace}-menu__popup-container,
.#{$namespace}-menu {
--menu-title-width: 140px;
--menu-item-icon-size: 16px;
--menu-item-height: 38px;
--menu-item-padding-y: 21px;
--menu-item-padding-x: 12px;
--menu-item-popup-padding-y: 20px;
--menu-item-popup-padding-x: 12px;
--menu-item-margin-y: 2px;
--menu-item-margin-x: 0px;
--menu-item-collapse-padding-y: 23.5px;
--menu-item-collapse-padding-x: 0px;
--menu-item-collapse-margin-y: 4px;
--menu-item-collapse-margin-x: 0px;
--menu-item-radius: 0px;
--menu-item-indent: 16px;
--menu-font-size: 14px;
&.is-dark {
--menu-background-color: hsl(var(--menu));
// --menu-submenu-opened-background-color: hsl(var(--menu-opened-dark));
--menu-item-background-color: var(--menu-background-color);
--menu-item-color: hsl(var(--foreground) / 80%);
--menu-item-hover-color: hsl(var(--accent-foreground));
--menu-item-hover-background-color: hsl(var(--accent));
--menu-item-active-color: hsl(var(--accent-foreground));
--menu-item-active-background-color: hsl(var(--accent));
--menu-submenu-hover-color: hsl(var(--foreground));
--menu-submenu-hover-background-color: hsl(var(--accent));
--menu-submenu-active-color: hsl(var(--foreground));
--menu-submenu-active-background-color: transparent;
--menu-submenu-background-color: var(--menu-background-color);
}
&.is-light {
--menu-background-color: hsl(var(--menu));
// --menu-submenu-opened-background-color: hsl(var(--menu-opened));
--menu-item-background-color: var(--menu-background-color);
--menu-item-color: hsl(var(--foreground));
--menu-item-hover-color: var(--menu-item-color);
--menu-item-hover-background-color: hsl(var(--accent));
--menu-item-active-color: hsl(var(--primary));
--menu-item-active-background-color: hsl(var(--primary) / 15%);
--menu-submenu-hover-color: hsl(var(--primary));
--menu-submenu-hover-background-color: hsl(var(--accent));
--menu-submenu-active-color: hsl(var(--primary));
--menu-submenu-active-background-color: transparent;
--menu-submenu-background-color: var(--menu-background-color);
}
&.is-rounded {
--menu-item-margin-x: 8px;
--menu-item-collapse-margin-x: 6px;
--menu-item-radius: 8px;
}
&.is-horizontal:not(.is-rounded) {
--menu-item-height: 40px;
--menu-item-radius: 6px;
}
&.is-horizontal.is-rounded {
--menu-item-height: 40px;
--menu-item-radius: 6px;
--menu-item-padding-x: 12px;
}
// .vben-menu__popup,
&.is-horizontal {
--menu-item-padding-y: 0px;
--menu-item-padding-x: 10px;
--menu-item-margin-y: 0px;
--menu-item-margin-x: 1px;
--menu-background-color: transparent;
&.is-dark {
--menu-item-hover-color: hsl(var(--accent-foreground));
--menu-item-hover-background-color: hsl(var(--accent));
--menu-item-active-color: hsl(var(--accent-foreground));
--menu-item-active-background-color: hsl(var(--accent));
--menu-submenu-active-color: hsl(var(--foreground));
--menu-submenu-active-background-color: hsl(var(--accent));
--menu-submenu-hover-color: hsl(var(--accent-foreground));
--menu-submenu-hover-background-color: hsl(var(--accent));
}
&.is-light {
--menu-item-active-color: hsl(var(--primary));
--menu-item-active-background-color: hsl(var(--primary) / 15%);
--menu-item-hover-background-color: hsl(var(--accent));
--menu-item-hover-color: hsl(var(--primary));
--menu-submenu-active-color: hsl(var(--primary));
--menu-submenu-active-background-color: hsl(var(--primary) / 15%);
--menu-submenu-hover-color: hsl(var(--primary));
--menu-submenu-hover-background-color: hsl(var(--accent));
}
}
}
.#{$namespace}-menu {
position: relative;
box-sizing: border-box;
padding-left: 0;
margin: 0;
list-style: none;
background: hsl(var(--menu-background-color));
// 垂直菜单
&.is-vertical {
&:not(.#{$namespace}-menu.is-collapse) {
& .#{$namespace}-menu-item,
& .#{$namespace}-sub-menu-content,
& .#{$namespace}-menu-item-group__title {
padding-left: calc(
var(--menu-item-indent) + var(--menu-level) * var(--menu-item-indent)
);
white-space: nowrap;
}
& > .#{$namespace}-sub-menu {
& > .#{$namespace}-menu {
& > .#{$namespace}-menu-item {
padding-left: calc(
0px + var(--menu-item-indent) + var(--menu-level) *
var(--menu-item-indent)
);
}
}
& > .#{$namespace}-sub-menu-content {
padding-left: calc(var(--menu-item-indent) - 8px);
}
}
& > .#{$namespace}-menu-item {
padding-left: calc(var(--menu-item-indent) - 8px);
}
}
}
&.is-horizontal {
display: flex;
flex-wrap: nowrap;
max-width: 100%;
height: var(--height-horizontal-height);
border-right: none;
.#{$namespace}-menu-item {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--menu-item-height);
padding-right: calc(var(--menu-item-padding-x) + 6px);
margin: 0;
margin-right: 2px;
// border-bottom: 2px solid transparent;
border-radius: var(--menu-item-radius);
}
& > .#{$namespace}-sub-menu {
height: var(--menu-item-height);
margin-right: 2px;
&:focus,
&:hover {
outline: none;
}
& .#{$namespace}-sub-menu-content {
height: 100%;
padding-right: 40px;
// border-bottom: 2px solid transparent;
border-radius: var(--menu-item-radius);
}
}
& .#{$namespace}-menu-item:not(.is-disabled):hover,
& .#{$namespace}-menu-item:not(.is-disabled):focus {
outline: none;
}
& > .#{$namespace}-menu-item.is-active {
color: var(--menu-item-active-color);
}
// &.is-light {
// & > .#{$namespace}-sub-menu {
// &.is-active {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// &:not(.is-active) .#{$namespace}-sub-menu-content {
// &:hover {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// }
// }
// & > .#{$namespace}-menu-item.is-active {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// & .#{$namespace}-menu-item:not(.is-disabled):hover,
// & .#{$namespace}-menu-item:not(.is-disabled):focus {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// }
}
// 折叠菜单
&.is-collapse {
.#{$namespace}-menu__icon {
margin-right: 0;
}
.#{$namespace}-sub-menu__icon-arrow {
display: none;
}
.#{$namespace}-sub-menu-content,
.#{$namespace}-menu-item {
display: flex;
align-items: center;
justify-content: center;
padding: var(--menu-item-collapse-padding-y)
var(--menu-item-collapse-padding-x);
margin: var(--menu-item-collapse-margin-y)
var(--menu-item-collapse-margin-x);
transition: all 0.3s;
&.is-active {
background: var(--menu-item-active-background-color) !important;
border-radius: var(--menu-item-radius);
}
}
&.is-light {
.#{$namespace}-sub-menu-content,
.#{$namespace}-menu-item {
&.is-active {
// color: hsl(var(--primary-foreground)) !important;
background: var(--menu-item-active-background-color) !important;
}
}
}
&.is-rounded {
.#{$namespace}-sub-menu-content,
.#{$namespace}-menu-item {
&.is-collapse-show-title {
// padding: 32px 0 !important;
margin: 4px 8px !important;
}
}
}
}
&__popup-container {
max-width: 240px;
height: unset;
padding: 0;
background: var(--menu-background-color);
}
&__popup {
padding: 10px 0;
border-radius: var(--menu-item-radius);
.#{$namespace}-sub-menu-content,
.#{$namespace}-menu-item {
padding: var(--menu-item-popup-padding-y) var(--menu-item-popup-padding-x);
}
}
&__icon {
flex-shrink: 0;
width: var(--menu-item-icon-size);
height: var(--menu-item-icon-size);
margin-right: 8px;
vertical-align: middle;
text-align: center;
}
}
.#{$namespace}-menu-item {
fill: var(--menu-item-color);
@include menu-item;
&.is-active {
fill: var(--menu-item-active-color);
@include menu-item-active;
}
&__content {
display: inline-flex;
align-items: center;
width: 100%;
height: var(--menu-item-height);
span {
@include menu-title;
}
}
&.is-collapse-show-title {
padding: 32px 0 !important;
// margin: 4px 8px !important;
.#{$namespace}-menu-tooltip__trigger {
flex-direction: column;
}
.#{$namespace}-menu__icon {
display: block;
font-size: 20px !important;
transition: all 0.25s ease;
}
.#{$namespace}-menu__name {
display: inline-flex;
margin-top: 8px;
margin-bottom: 0;
font-size: 12px;
font-weight: 400;
line-height: normal;
transition: all 0.25s ease;
}
}
&:not(.is-active):hover {
color: var(--menu-item-hover-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-item-hover-background-color) !important;
}
.#{$namespace}-menu-tooltip__trigger {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0 var(--menu-item-padding-x);
font-size: var(--menu-font-size);
line-height: var(--menu-item-height);
}
}
.#{$namespace}-sub-menu {
padding-left: 0;
margin: 0;
list-style: none;
background: var(--menu-submenu-background-color);
fill: var(--menu-item-color);
&.is-active {
div[data-state='open'] > .#{$namespace}-sub-menu-content,
> .#{$namespace}-sub-menu-content {
// font-weight: 500;
color: var(--menu-submenu-active-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-submenu-active-background-color);
fill: var(--menu-submenu-active-color);
}
}
}
.#{$namespace}-sub-menu-content {
height: var(--menu-item-height);
@include menu-item;
&__icon-arrow {
position: absolute;
top: 50%;
right: 10px;
width: inherit;
margin-top: -8px;
margin-right: 0;
// font-size: 16px;
font-weight: normal;
opacity: 1;
transition: transform 0.25s ease;
}
&__title {
@include menu-title;
}
&.is-collapse-show-title {
flex-direction: column;
padding: 32px 0 !important;
// margin: 4px 8px !important;
.#{$namespace}-menu__icon {
display: block;
font-size: 20px !important;
transition: all 0.25s ease;
}
.#{$namespace}-sub-menu-content__title {
display: inline-flex;
flex-shrink: 0;
margin-top: 8px;
margin-bottom: 0;
font-size: 12px;
font-weight: 400;
line-height: normal;
transition: all 0.25s ease;
}
}
&.is-more {
padding-right: 12px !important;
}
// &:not(.is-active):hover {
&:hover {
color: var(--menu-submenu-hover-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-submenu-hover-background-color) !important;
// svg {
// fill: var(--menu-submenu-hover-color);
// }
}
}
</style>

View File

@@ -0,0 +1,2 @@
export type * from './normal-menu';
export { default as NormalMenu } from './normal-menu.vue';

View File

@@ -0,0 +1,27 @@
import type { MenuRecordRaw } from '@vben-core/typings';
interface NormalMenuProps {
/**
* 菜单数据
*/
activePath?: string;
/**
* 是否折叠
*/
collapse?: boolean;
/**
* 菜单项
*/
menus?: MenuRecordRaw[];
/**
* @zh_CN 是否圆润风格
* @default true
*/
rounded?: boolean;
/**
* 主题
*/
theme?: 'dark' | 'light';
}
export type { NormalMenuProps };

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { MenuItemProps } from '../types';
import { computed } from 'vue';
import { useNamespace } from '@vben-core/composables';
import { ChevronDown, ChevronRight } from '@vben-core/icons';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { useMenuContext } from '../hooks';
interface Props extends MenuItemProps {
isMenuMore?: boolean;
isTopLevelMenuSubmenu: boolean;
level?: number;
}
defineOptions({ name: 'SubMenuContent' });
const props = withDefaults(defineProps<Props>(), {
isMenuMore: false,
level: 0,
});
const rootMenu = useMenuContext();
const { b, e, is } = useNamespace('sub-menu-content');
const nsMenu = useNamespace('menu');
const opened = computed(() => {
return rootMenu?.openedMenus.includes(props.path);
});
const collapse = computed(() => {
return rootMenu.props.collapse;
});
const isFirstLevel = computed(() => {
return props.level === 1;
});
const getCollapseShowTitle = computed(() => {
return (
rootMenu.props.collapseShowTitle && isFirstLevel.value && collapse.value
);
});
const mode = computed(() => {
return rootMenu?.props.mode;
});
const showArrowIcon = computed(() => {
return mode.value === 'horizontal' || !(isFirstLevel.value && collapse.value);
});
const hiddenTitle = computed(() => {
return (
mode.value === 'vertical' &&
isFirstLevel.value &&
collapse.value &&
!getCollapseShowTitle.value
);
});
const iconComp = computed(() => {
return (mode.value === 'horizontal' && !isFirstLevel.value) ||
(mode.value === 'vertical' && collapse.value)
? ChevronRight
: ChevronDown;
});
const iconArrowStyle = computed(() => {
return opened.value ? { transform: `rotate(180deg)` } : {};
});
</script>
<template>
<div
:class="[
b(),
is('collapse-show-title', getCollapseShowTitle),
is('more', isMenuMore),
]"
>
<slot></slot>
<VbenIcon
v-if="!isMenuMore"
:class="nsMenu.e('icon')"
:icon="icon"
fallback
/>
<div v-if="!hiddenTitle" :class="[e('title')]">
<slot name="title"></slot>
</div>
<component
:is="iconComp"
v-if="!isMenuMore"
v-show="showArrowIcon"
:class="[e('icon-arrow')]"
:style="iconArrowStyle"
class="size-4"
/>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export * from './use-menu';
export * from './use-menu-context';

View File

@@ -0,0 +1,55 @@
import type { MenuProvider, SubMenuProvider } from '../types';
import { getCurrentInstance, inject, provide } from 'vue';
import { findComponentUpward } from '../utils';
const menuContextKey = Symbol('menuContext');
/**
* @zh_CN Provide menu context
*/
function createMenuContext(injectMenuData: MenuProvider) {
provide(menuContextKey, injectMenuData);
}
/**
* @zh_CN Provide menu context
*/
function createSubMenuContext(injectSubMenuData: SubMenuProvider) {
const instance = getCurrentInstance();
provide(`subMenu:${instance?.uid}`, injectSubMenuData);
}
/**
* @zh_CN Inject menu context
*/
function useMenuContext() {
const instance = getCurrentInstance();
if (!instance) {
throw new Error('instance is required');
}
const rootMenu = inject(menuContextKey) as MenuProvider;
return rootMenu;
}
/**
* @zh_CN Inject menu context
*/
function useSubMenuContext() {
const instance = getCurrentInstance();
if (!instance) {
throw new Error('instance is required');
}
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
return subMenu;
}
export {
createMenuContext,
createSubMenuContext,
useMenuContext,
useSubMenuContext,
};

View File

@@ -0,0 +1,46 @@
import type { Ref } from 'vue';
import { watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
interface UseMenuScrollOptions {
delay?: number;
enable?: boolean | Ref<boolean>;
}
export function useMenuScroll(
activePath: Ref<string | undefined>,
options: UseMenuScrollOptions = {},
) {
const { enable = true, delay = 320 } = options;
function scrollToActiveItem() {
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
if (!isEnabled) return;
const activeElement = document.querySelector(
`aside li[role=menuitem].is-active`,
);
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
}
const debouncedScroll = useDebounceFn(scrollToActiveItem, delay);
watch(activePath, () => {
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
if (!isEnabled) return;
debouncedScroll();
});
return {
scrollToActiveItem,
};
}

View File

@@ -0,0 +1,48 @@
import type { SubMenuProvider } from '../types';
import { computed, getCurrentInstance } from 'vue';
import { findComponentUpward } from '../utils';
function useMenu() {
const instance = getCurrentInstance();
if (!instance) {
throw new Error('instance is required');
}
/**
* @zh_CN 获取所有父级菜单链路
*/
const parentPaths = computed(() => {
let parent = instance.parent;
const paths: string[] = [instance.props.path as string];
while (parent?.type.name !== 'Menu') {
if (parent?.props.path) {
paths.unshift(parent.props.path as string);
}
parent = parent?.parent ?? null;
}
return paths;
});
const parentMenu = computed(() => {
return findComponentUpward(instance, ['Menu', 'SubMenu']);
});
return {
parentMenu,
parentPaths,
};
}
function useMenuStyle(menu?: SubMenuProvider) {
const subMenuStyle = computed(() => {
return {
'--menu-level': menu ? (menu?.level ?? 0 + 1) : 0,
};
});
return subMenuStyle;
}
export { useMenu, useMenuStyle };

View File

@@ -0,0 +1,4 @@
export { default as MenuBadge } from './components/menu-badge.vue';
export * from './components/normal-menu';
export { default as Menu } from './menu.vue';
export type * from './types';

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import type { MenuProps } from './types';
import { useForwardProps } from '@vben-core/composables';
import { Menu } from './components';
import SubMenu from './sub-menu.vue';
interface Props extends MenuProps {
menus: MenuRecordRaw[];
}
defineOptions({
name: 'MenuView',
});
const props = withDefaults(defineProps<Props>(), {
collapse: false,
});
const forward = useForwardProps(props);
</script>
<template>
<Menu v-bind="forward">
<template v-for="menu in menus" :key="menu.path">
<SubMenu :menu="menu" />
</template>
</Menu>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { computed } from 'vue';
import { MenuBadge, MenuItem, SubMenu as SubMenuComp } from './components';
// eslint-disable-next-line import/no-self-import
import SubMenu from './sub-menu.vue';
interface Props {
/**
* 菜单项
*/
menu: MenuRecordRaw;
}
defineOptions({
name: 'SubMenuUi',
});
const props = withDefaults(defineProps<Props>(), {});
/**
* 判断是否有子节点,动态渲染 menu-item/sub-menu-item
*/
const hasChildren = computed(() => {
const { menu } = props;
return (
Reflect.has(menu, 'children') && !!menu.children && menu.children.length > 0
);
});
</script>
<template>
<MenuItem
v-if="!hasChildren"
:key="menu.path"
:active-icon="menu.activeIcon"
:badge="menu.badge"
:badge-type="menu.badgeType"
:badge-variants="menu.badgeVariants"
:icon="menu.icon"
:path="menu.path"
>
<template #title>
<span>{{ menu.name }}</span>
</template>
</MenuItem>
<SubMenuComp
v-else
:key="`${menu.path}_sub`"
:active-icon="menu.activeIcon"
:icon="menu.icon"
:path="menu.path"
>
<template #content>
<MenuBadge
:badge="menu.badge"
:badge-type="menu.badgeType"
:badge-variants="menu.badgeVariants"
class="right-6"
/>
</template>
<template #title>
<span>{{ menu.name }}</span>
</template>
<template v-for="childItem in menu.children || []" :key="childItem.path">
<SubMenu :menu="childItem" />
</template>
</SubMenuComp>
</template>

View File

@@ -0,0 +1,145 @@
import type { Component, Ref } from 'vue';
import type { MenuRecordBadgeRaw, ThemeModeType } from '@vben-core/typings';
interface MenuProps {
/**
* @zh_CN 是否开启手风琴模式
* @default true
*/
accordion?: boolean;
/**
* @zh_CN 菜单是否折叠
* @default false
*/
collapse?: boolean;
/**
* @zh_CN 菜单折叠时是否显示菜单名称
* @default false
*/
collapseShowTitle?: boolean;
/**
* @zh_CN 默认激活的菜单
*/
defaultActive?: string;
/**
* @zh_CN 默认展开的菜单
*/
defaultOpeneds?: string[];
/**
* @zh_CN 菜单模式
* @default vertical
*/
mode?: 'horizontal' | 'vertical';
/**
* @zh_CN 是否圆润风格
* @default true
*/
rounded?: boolean;
/**
* @zh_CN 是否自动滚动到激活的菜单项
* @default false
*/
scrollToActive?: boolean;
/**
* @zh_CN 菜单主题
* @default dark
*/
theme?: ThemeModeType;
}
interface SubMenuProps extends MenuRecordBadgeRaw {
/**
* @zh_CN 激活图标
*/
activeIcon?: string;
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 图标
*/
icon?: Component | string;
/**
* @zh_CN submenu 名称
*/
path: string;
}
interface MenuItemProps extends MenuRecordBadgeRaw {
/**
* @zh_CN 图标
*/
activeIcon?: string;
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 图标
*/
icon?: Component | string;
/**
* @zh_CN menuitem 名称
*/
path: string;
}
interface MenuItemRegistered {
active: boolean;
parentPaths: string[];
path: string;
}
interface MenuItemClicked {
parentPaths: string[];
path: string;
}
interface MenuProvider {
activePath?: string;
addMenuItem: (item: MenuItemRegistered) => void;
addSubMenu: (item: MenuItemRegistered) => void;
closeMenu: (path: string, parentLinks: string[]) => void;
handleMenuItemClick: (item: MenuItemClicked) => void;
handleSubMenuClick: (subMenu: MenuItemRegistered) => void;
isMenuPopup: boolean;
items: Record<string, MenuItemRegistered>;
openedMenus: string[];
openMenu: (path: string, parentLinks: string[]) => void;
props: MenuProps;
removeMenuItem: (item: MenuItemRegistered) => void;
removeSubMenu: (item: MenuItemRegistered) => void;
subMenus: Record<string, MenuItemRegistered>;
theme: string;
}
interface SubMenuProvider {
addSubMenu: (item: MenuItemRegistered) => void;
handleMouseleave?: (deepDispatch: boolean) => void;
level: number;
mouseInChild: Ref<boolean>;
removeSubMenu: (item: MenuItemRegistered) => void;
}
export type {
MenuItemClicked,
MenuItemProps,
MenuItemRegistered,
MenuProps,
MenuProvider,
SubMenuProps,
SubMenuProvider,
};

View File

@@ -0,0 +1,52 @@
import type {
ComponentInternalInstance,
VNode,
VNodeChild,
VNodeNormalizedChildren,
} from 'vue';
import { isVNode } from 'vue';
type VNodeChildAtom = Exclude<VNodeChild, Array<any>>;
type RawSlots = Exclude<VNodeNormalizedChildren, Array<any> | null | string>;
type FlattenVNodes = Array<RawSlots | VNodeChildAtom>;
/**
* @zh_CN Find the parent component upward
* @param instance
* @param parentNames
*/
function findComponentUpward(
instance: ComponentInternalInstance,
parentNames: string[],
) {
let parent = instance.parent;
while (parent && !parentNames.includes(parent?.type?.name ?? '')) {
parent = parent.parent;
}
return parent;
}
const flattedChildren = (
children: FlattenVNodes | VNode | VNodeNormalizedChildren,
): FlattenVNodes => {
const vNodes = Array.isArray(children) ? children : [children];
const result: FlattenVNodes = [];
vNodes.forEach((child) => {
if (Array.isArray(child)) {
result.push(...flattedChildren(child));
} else if (isVNode(child) && Array.isArray(child.children)) {
result.push(...flattedChildren(child.children));
} else {
result.push(child);
if (isVNode(child) && child.component?.subTree) {
result.push(...flattedChildren(child.component.subTree));
}
}
});
return result;
};
export { findComponentUpward, flattedChildren };

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1,48 @@
{
"name": "@vben-core/popup-ui",
"version": "5.2.1",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/popup-ui"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,99 @@
import type { Component, VNode, VNodeArrayChildren } from 'vue';
import type { Recordable } from '@vben-core/typings';
import { createContext } from '@vben-core/shadcn-ui';
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
export type BeforeCloseScope = {
isConfirm: boolean;
};
export type AlertProps = {
/** 关闭前的回调如果返回false则终止关闭 */
beforeClose?: (
scope: BeforeCloseScope,
) => boolean | Promise<boolean | undefined> | undefined;
/** 边框 */
bordered?: boolean;
/**
* 按钮对齐方式
* @default 'end'
*/
buttonAlign?: 'center' | 'end' | 'start';
/** 取消按钮的标题 */
cancelText?: string;
/** 是否居中显示 */
centered?: boolean;
/** 确认按钮的标题 */
confirmText?: string;
/** 弹窗容器的额外样式 */
containerClass?: string;
/** 弹窗提示内容 */
content: Component | string;
/** 弹窗内容的额外样式 */
contentClass?: string;
/** 执行beforeClose回调期间在内容区域显示一个loading遮罩*/
contentMasking?: boolean;
/** 弹窗底部内容(与按钮在同一个容器中) */
footer?: Component | string;
/** 弹窗的图标(在标题的前面) */
icon?: Component | IconType;
/**
* 弹窗遮罩模糊效果
*/
overlayBlur?: number;
/** 是否显示取消按钮 */
showCancel?: boolean;
/** 弹窗标题 */
title?: string;
};
/** Prompt属性 */
export type PromptProps<T = any> = {
/** 关闭前的回调如果返回false则终止关闭 */
beforeClose?: (scope: {
isConfirm: boolean;
value: T | undefined;
}) => boolean | Promise<boolean | undefined> | undefined;
/** 用于接受用户输入的组件 */
component?: Component;
/** 输入组件的属性 */
componentProps?: Recordable<any>;
/** 输入组件的插槽 */
componentSlots?:
| (() => any)
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
/** 默认值 */
defaultValue?: T;
/** 输入组件的值属性名 */
modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>;
/**
* Alert上下文
*/
export type AlertContext = {
/** 执行取消操作 */
doCancel: () => void;
/** 执行确认操作 */
doConfirm: () => void;
};
export const [injectAlertContext, provideAlertContext] =
createContext<AlertContext>('VbenAlertContext');
/**
* 获取Alert上下文
* @returns AlertContext
*/
export function useAlertContext() {
const context = injectAlertContext();
if (!context) {
throw new Error('useAlertContext must be used within an AlertProvider');
}
return context;
}

View File

@@ -0,0 +1,14 @@
export type {
AlertProps,
BeforeCloseScope,
IconType,
PromptProps,
} from './alert';
export { useAlertContext } from './alert';
export { default as Alert } from './alert.vue';
export {
vbenAlert as alert,
clearAllAlerts,
vbenConfirm as confirm,
vbenPrompt as prompt,
} from './AlertBuilder';

View File

@@ -0,0 +1,116 @@
import type { DrawerState } from '../drawer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DrawerApi } from '../drawer-api';
// 模拟 Store 类
vi.mock('@vben-core/shared/store', () => {
return {
isFunction: (fn: any) => typeof fn === 'function',
Store: class {
get state() {
return this._state;
}
private _state: DrawerState;
private options: any;
constructor(initialState: DrawerState, options: any) {
this._state = initialState;
this.options = options;
}
batch(cb: () => void) {
cb();
}
setState(fn: (prev: DrawerState) => DrawerState) {
this._state = fn(this._state);
this.options.onUpdate();
}
},
};
});
describe('drawerApi', () => {
let drawerApi: DrawerApi;
let drawerState: DrawerState;
beforeEach(() => {
drawerApi = new DrawerApi();
drawerState = drawerApi.store.state;
});
it('should initialize with default state', () => {
expect(drawerState.isOpen).toBe(false);
expect(drawerState.cancelText).toBe(undefined);
expect(drawerState.confirmText).toBe(undefined);
});
it('should open the drawer', () => {
drawerApi.open();
expect(drawerApi.store.state.isOpen).toBe(true);
});
it('should close the drawer if onBeforeClose allows it', () => {
drawerApi.close();
expect(drawerApi.store.state.isOpen).toBe(false);
});
it('should not close the drawer if onBeforeClose returns false', () => {
const onBeforeClose = vi.fn(() => false);
const drawerApiWithHook = new DrawerApi({ onBeforeClose });
drawerApiWithHook.open();
drawerApiWithHook.close();
expect(drawerApiWithHook.store.state.isOpen).toBe(true);
expect(onBeforeClose).toHaveBeenCalled();
});
it('should trigger onCancel and keep drawer open if onCancel is provided', () => {
const onCancel = vi.fn();
const drawerApiWithHook = new DrawerApi({ onCancel });
drawerApiWithHook.open();
drawerApiWithHook.onCancel();
expect(onCancel).toHaveBeenCalled();
expect(drawerApiWithHook.store.state.isOpen).toBe(true); // 关闭逻辑不在 onCancel 内
});
it('should update shared data correctly', () => {
const testData = { key: 'value' };
drawerApi.setData(testData);
expect(drawerApi.getData()).toEqual(testData);
});
it('should set state correctly using an object', () => {
drawerApi.setState({ title: 'New Title' });
expect(drawerApi.store.state.title).toBe('New Title');
});
it('should set state correctly using a function', () => {
drawerApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
expect(drawerApi.store.state.confirmText).toBe('Yes');
});
it('should call onOpenChange when state changes', () => {
const onOpenChange = vi.fn();
const drawerApiWithHook = new DrawerApi({ onOpenChange });
drawerApiWithHook.open();
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it('should call onClosed callback when provided', () => {
const onClosed = vi.fn();
const drawerApiWithHook = new DrawerApi({ onClosed });
drawerApiWithHook.onClosed();
expect(onClosed).toHaveBeenCalled();
});
it('should call onOpened callback when provided', () => {
const onOpened = vi.fn();
const drawerApiWithHook = new DrawerApi({ onOpened });
drawerApiWithHook.open();
drawerApiWithHook.onOpened();
expect(onOpened).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,183 @@
import type { DrawerApiOptions, DrawerState } from './drawer';
import { Store } from '@vben-core/shared/store';
import { bindMethods, isFunction } from '@vben-core/shared/utils';
export class DrawerApi {
// 共享数据
public sharedData: Record<'payload', any> = {
payload: {},
};
public store: Store<DrawerState>;
private api: Pick<
DrawerApiOptions,
| 'onBeforeClose'
| 'onCancel'
| 'onClosed'
| 'onConfirm'
| 'onOpenChange'
| 'onOpened'
>;
// private prevState!: DrawerState;
private state!: DrawerState;
constructor(options: DrawerApiOptions = {}) {
const {
connectedComponent: _,
onBeforeClose,
onCancel,
onClosed,
onConfirm,
onOpenChange,
onOpened,
...storeState
} = options;
const defaultState: DrawerState = {
class: '',
closable: true,
closeIconPlacement: 'right',
closeOnClickModal: true,
closeOnPressEscape: true,
confirmLoading: false,
contentClass: '',
footer: true,
header: true,
isOpen: false,
loading: false,
modal: true,
openAutoFocus: false,
placement: 'right',
showCancelButton: true,
showConfirmButton: true,
submitting: false,
title: '',
};
this.store = new Store<DrawerState>(
{
...defaultState,
...storeState,
},
{
onUpdate: () => {
const state = this.store.state;
if (state?.isOpen === this.state?.isOpen) {
this.state = state;
} else {
this.state = state;
this.api.onOpenChange?.(!!state?.isOpen);
}
},
},
);
this.state = this.store.state;
this.api = {
onBeforeClose,
onCancel,
onClosed,
onConfirm,
onOpenChange,
onOpened,
};
bindMethods(this);
}
/**
* 关闭抽屉
* @description 关闭抽屉时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false则不关闭弹窗
*/
async close() {
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
// 如果 onBeforeClose 返回 false则不关闭弹窗
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
if (allowClose) {
this.store.setState((prev) => ({
...prev,
isOpen: false,
submitting: false,
}));
}
}
getData<T extends object = Record<string, any>>() {
return (this.sharedData?.payload ?? {}) as T;
}
/**
* 锁定抽屉状态(用于提交过程中的等待状态)
* @description 锁定状态将禁用默认的取消按钮使用spinner覆盖抽屉内容隐藏关闭按钮阻止手动关闭弹窗将默认的提交按钮标记为loading状态
* @param isLocked 是否锁定
*/
lock(isLocked: boolean = true) {
return this.setState({ submitting: isLocked });
}
/**
* 取消操作
*/
onCancel() {
if (this.api.onCancel) {
this.api.onCancel?.();
} else {
this.close();
}
}
/**
* 弹窗关闭动画播放完毕后的回调
*/
onClosed() {
if (!this.state.isOpen) {
this.api.onClosed?.();
}
}
/**
* 确认操作
*/
onConfirm() {
this.api.onConfirm?.();
}
/**
* 弹窗打开动画播放完毕后的回调
*/
onOpened() {
if (this.state.isOpen) {
this.api.onOpened?.();
}
}
open() {
this.store.setState((prev) => ({ ...prev, isOpen: true }));
}
setData<T>(payload: T) {
this.sharedData.payload = payload;
return this;
}
setState(
stateOrFn:
| ((prev: DrawerState) => Partial<DrawerState>)
| Partial<DrawerState>,
) {
if (isFunction(stateOrFn)) {
this.store.setState(stateOrFn);
} else {
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
}
return this;
}
/**
* 解除抽屉的锁定状态
* @description 解除由lock方法设置的锁定状态是lock(false)的别名
*/
unlock() {
return this.lock(false);
}
}

View File

@@ -0,0 +1,179 @@
import type { Component, Ref } from 'vue';
import type { ClassType, MaybePromise } from '@vben-core/typings';
import type { DrawerApi } from './drawer-api';
export type DrawerPlacement = 'bottom' | 'left' | 'right' | 'top';
export type CloseIconPlacement = 'left' | 'right';
export interface DrawerProps {
/**
* 是否挂载到内容区域
* @default false
*/
appendToMain?: boolean;
/**
* 取消按钮文字
*/
cancelText?: string;
class?: ClassType;
/**
* 是否显示关闭按钮
* @default true
*/
closable?: boolean;
/**
* 关闭按钮的位置
*/
closeIconPlacement?: CloseIconPlacement;
/**
* 点击弹窗遮罩是否关闭弹窗
* @default true
*/
closeOnClickModal?: boolean;
/**
* 按下 ESC 键是否关闭弹窗
* @default true
*/
closeOnPressEscape?: boolean;
/**
* 确定按钮 loading
* @default false
*/
confirmLoading?: boolean;
/**
* 确定按钮文字
*/
confirmText?: string;
contentClass?: string;
/**
* 弹窗描述
*/
description?: string;
/**
* 在关闭时销毁抽屉
*/
destroyOnClose?: boolean;
/**
* 是否显示底部
* @default true
*/
footer?: boolean;
/**
* 弹窗底部样式
*/
footerClass?: ClassType;
/**
* 是否显示顶栏
* @default true
*/
header?: boolean;
/**
* 弹窗头部样式
*/
headerClass?: ClassType;
/**
* 弹窗是否显示
* @default false
*/
loading?: boolean;
/**
* 是否显示遮罩
* @default true
*/
modal?: boolean;
/**
* 是否自动聚焦
*/
openAutoFocus?: boolean;
/**
* 弹窗遮罩模糊效果
*/
overlayBlur?: number;
/**
* 抽屉位置
* @default right
*/
placement?: DrawerPlacement;
/**
* 是否显示取消按钮
* @default true
*/
showCancelButton?: boolean;
/**
* 是否显示确认按钮
* @default true
*/
showConfirmButton?: boolean;
/**
* 提交中(锁定抽屉状态)
*/
submitting?: boolean;
/**
* 弹窗标题
*/
title?: string;
/**
* 弹窗标题提示
*/
titleTooltip?: string;
/**
* 抽屉层级
*/
zIndex?: number;
}
export interface DrawerState extends DrawerProps {
/** 弹窗打开状态 */
isOpen?: boolean;
/**
* 共享数据
*/
sharedData?: Record<string, any>;
}
export type ExtendedDrawerApi = DrawerApi & {
useStore: <T = NoInfer<DrawerState>>(
selector?: (state: NoInfer<DrawerState>) => T,
) => Readonly<Ref<T>>;
};
export interface DrawerApiOptions extends DrawerState {
/**
* 独立的抽屉组件
*/
connectedComponent?: Component;
/**
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => MaybePromise<boolean | undefined>;
/**
* 点击取消按钮的回调
*/
onCancel?: () => void;
/**
* 弹窗关闭动画结束的回调
* @returns
*/
onClosed?: () => void;
/**
* 点击确定按钮的回调
*/
onConfirm?: () => void;
/**
* 弹窗状态变化回调
* @param isOpen
* @returns
*/
onOpenChange?: (isOpen: boolean) => void;
/**
* 弹窗打开动画结束的回调
* @returns
*/
onOpened?: () => void;
}

View File

@@ -0,0 +1,3 @@
export type * from './drawer';
export { default as VbenDrawer } from './drawer.vue';
export { setDefaultDrawerProps, useVbenDrawer } from './use-drawer';

View File

@@ -0,0 +1,142 @@
import type {
DrawerApiOptions,
DrawerProps,
ExtendedDrawerApi,
} from './drawer';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { useStore } from '@vben-core/shared/store';
import { DrawerApi } from './drawer-api';
import VbenDrawer from './drawer.vue';
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT');
const DEFAULT_DRAWER_PROPS: Partial<DrawerProps> = {};
export function setDefaultDrawerProps(props: Partial<DrawerProps>) {
Object.assign(DEFAULT_DRAWER_PROPS, props);
}
export function useVbenDrawer<
TParentDrawerProps extends DrawerProps = DrawerProps,
>(options: DrawerApiOptions = {}) {
// Drawer一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Drawer通过provide/inject传递api
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isDrawerReady = ref(true);
const Drawer = defineComponent(
(props: TParentDrawerProps, { attrs, slots }) => {
provide(USER_DRAWER_INJECT_KEY, {
extendApi(api: ExtendedDrawerApi) {
// 不能直接给 reactive 赋值,会丢失响应
// 不能用 Object.assign,会丢失 api 的原型函数
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateDrawer() {
isDrawerReady.value = false;
await nextTick();
isDrawerReady.value = true;
},
});
checkProps(extendedApi as ExtendedDrawerApi, {
...props,
...attrs,
...slots,
});
return () =>
h(
isDrawerReady.value ? connectedComponent : 'div',
{ ...props, ...attrs },
slots,
);
},
// eslint-disable-next-line vue/one-component-per-file
{
name: 'VbenParentDrawer',
inheritAttrs: false,
},
);
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
}
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {});
const mergedOptions = {
...DEFAULT_DRAWER_PROPS,
...injectData.options,
...options,
} as DrawerApiOptions;
mergedOptions.onOpenChange = (isOpen: boolean) => {
options.onOpenChange?.(isOpen);
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateDrawer?.();
}
};
const api = new DrawerApi(mergedOptions);
const extendedApi: ExtendedDrawerApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Drawer = defineComponent(
(props: DrawerProps, { attrs, slots }) => {
return () =>
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots);
},
// eslint-disable-next-line vue/one-component-per-file
{
name: 'VbenDrawer',
inheritAttrs: false,
},
);
injectData.extendApi?.(extendedApi);
return [Drawer, extendedApi] as const;
}
async function checkProps(api: ExtendedDrawerApi, attrs: Record<string, any>) {
if (!attrs || Object.keys(attrs).length === 0) {
return;
}
await nextTick();
const state = api?.store?.state;
if (!state) {
return;
}
const stateKeys = new Set(Object.keys(state));
for (const attr of Object.keys(attrs)) {
if (stateKeys.has(attr) && !['class'].includes(attr)) {
// connectedComponent存在时不要传入Drawer的props会造成复杂度提升如果你需要修改Drawer的props请使用 useVbenDrawer 或者api
console.warn(
`[Vben Drawer]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Drawer, please use useVbenDrawer or api.`,
);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './alert';
export * from './drawer';
export * from './modal';

View File

@@ -0,0 +1,117 @@
import type { ModalState } from '../modal';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ModalApi } from '../modal-api';
vi.mock('@vben-core/shared/store', () => {
return {
isFunction: (fn: any) => typeof fn === 'function',
Store: class {
get state() {
return this._state;
}
private _state: ModalState;
private options: any;
constructor(initialState: ModalState, options: any) {
this._state = initialState;
this.options = options;
}
batch(cb: () => void) {
cb();
}
setState(fn: (prev: ModalState) => ModalState) {
this._state = fn(this._state);
this.options.onUpdate();
}
},
};
});
describe('modalApi', () => {
let modalApi: ModalApi;
// 使用 modalState 而不是 state
let modalState: ModalState;
beforeEach(() => {
modalApi = new ModalApi();
// 获取 modalApi 内的 state
modalState = modalApi.store.state;
});
it('should initialize with default state', () => {
expect(modalState.isOpen).toBe(false);
expect(modalState.cancelText).toBe(undefined);
expect(modalState.confirmText).toBe(undefined);
});
it('should open the modal', () => {
modalApi.open();
expect(modalApi.store.state.isOpen).toBe(true);
});
it('should close the modal if onBeforeClose allows it', () => {
modalApi.close();
expect(modalApi.store.state.isOpen).toBe(false);
});
it('should not close the modal if onBeforeClose returns false', () => {
const onBeforeClose = vi.fn(() => false);
const modalApiWithHook = new ModalApi({ onBeforeClose });
modalApiWithHook.open();
modalApiWithHook.close();
expect(modalApiWithHook.store.state.isOpen).toBe(true);
expect(onBeforeClose).toHaveBeenCalled();
});
it('should trigger onCancel and close the modal if no onCancel hook is provided', () => {
const onCancel = vi.fn();
const modalApiWithHook = new ModalApi({ onCancel });
modalApiWithHook.open();
modalApiWithHook.onCancel();
expect(onCancel).toHaveBeenCalled();
expect(modalApiWithHook.store.state.isOpen).toBe(true);
});
it('should update shared data correctly', () => {
const testData = { key: 'value' };
modalApi.setData(testData);
expect(modalApi.getData()).toEqual(testData);
});
it('should set state correctly using an object', () => {
modalApi.setState({ title: 'New Title' });
expect(modalApi.store.state.title).toBe('New Title');
});
it('should set state correctly using a function', () => {
modalApi.setState((prev) => ({ ...prev, confirmText: 'Yes' }));
expect(modalApi.store.state.confirmText).toBe('Yes');
});
it('should call onOpenChange when state changes', () => {
const onOpenChange = vi.fn();
const modalApiWithHook = new ModalApi({ onOpenChange });
modalApiWithHook.open();
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it('should call onClosed callback when provided', () => {
const onClosed = vi.fn();
const modalApiWithHook = new ModalApi({ onClosed });
modalApiWithHook.onClosed();
expect(onClosed).toHaveBeenCalled();
});
it('should call onOpened callback when provided', () => {
const onOpened = vi.fn();
const modalApiWithHook = new ModalApi({ onOpened });
modalApiWithHook.open();
modalApiWithHook.onOpened();
expect(onOpened).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,3 @@
export type * from './modal';
export { default as VbenModal } from './modal.vue';
export { setDefaultModalProps, useVbenModal } from './use-modal';

View File

@@ -0,0 +1,151 @@
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { useStore } from '@vben-core/shared/store';
import { ModalApi } from './modal-api';
import VbenModal from './modal.vue';
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT');
const DEFAULT_MODAL_PROPS: Partial<ModalProps> = {};
export function setDefaultModalProps(props: Partial<ModalProps>) {
Object.assign(DEFAULT_MODAL_PROPS, props);
}
export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
options: ModalApiOptions = {},
) {
// Modal一般会抽离出来所以如果有传入 connectedComponent则表示为外部调用与内部组件进行连接
// 外部的Modal通过provide/inject传递api
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isModalReady = ref(true);
const Modal = defineComponent(
(props: TParentModalProps, { attrs, slots }) => {
provide(USER_MODAL_INJECT_KEY, {
extendApi(api: ExtendedModalApi) {
// 不能直接给 reactive 赋值,会丢失响应
// 不能用 Object.assign,会丢失 api 的原型函数
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateModal() {
isModalReady.value = false;
await nextTick();
isModalReady.value = true;
},
});
checkProps(extendedApi as ExtendedModalApi, {
...props,
...attrs,
...slots,
});
return () =>
h(
isModalReady.value ? connectedComponent : 'div',
{
...props,
...attrs,
},
slots,
);
},
// eslint-disable-next-line vue/one-component-per-file
{
name: 'VbenParentModal',
inheritAttrs: false,
},
);
return [Modal, extendedApi as ExtendedModalApi] as const;
}
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
const mergedOptions = {
...DEFAULT_MODAL_PROPS,
...injectData.options,
...options,
} as ModalApiOptions;
mergedOptions.onOpenChange = (isOpen: boolean) => {
options.onOpenChange?.(isOpen);
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateModal?.();
}
};
const api = new ModalApi(mergedOptions);
const extendedApi: ExtendedModalApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Modal = defineComponent(
(props: ModalProps, { attrs, slots }) => {
return () =>
h(
VbenModal,
{
...props,
...attrs,
modalApi: extendedApi,
},
slots,
);
},
// eslint-disable-next-line vue/one-component-per-file
{
name: 'VbenModal',
inheritAttrs: false,
},
);
injectData.extendApi?.(extendedApi);
return [Modal, extendedApi] as const;
}
async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) {
if (!attrs || Object.keys(attrs).length === 0) {
return;
}
await nextTick();
const state = api?.store?.state;
if (!state) {
return;
}
const stateKeys = new Set(Object.keys(state));
for (const attr of Object.keys(attrs)) {
if (stateKeys.has(attr) && !['class'].includes(attr)) {
// connectedComponent存在时不要传入Modal的props会造成复杂度提升如果你需要修改Modal的props请使用 useModal 或者api
console.warn(
`[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useVbenModal or api.`,
);
}
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,27 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
pattern: ['**/*'],
},
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.mjs",
"css": "src/assets/index.css",
"baseColor": "slate",
"cssVariables": true
},
"framework": "vite",
"aliases": {
"components": "@vben-core/shadcn-ui/components",
"utils": "@vben-core/shared/utils"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1 @@
export { default as VbenAvatar } from './avatar.vue';

View File

@@ -0,0 +1,38 @@
export const backtopProps = {
/**
* @zh_CN bottom distance.
*/
bottom: {
default: 40,
type: Number,
},
/**
* @zh_CN right distance.
*/
right: {
default: 40,
type: Number,
},
/**
* @zh_CN the target to trigger scroll.
*/
target: {
default: '',
type: String,
},
/**
* @zh_CN the button will not show until the scroll height reaches this value.
*/
visibilityHeight: {
default: 200,
type: Number,
},
} as const;
export interface BacktopProps {
bottom?: number;
isGroup?: boolean;
right?: number;
target?: string;
visibilityHeight?: number;
}

View File

@@ -0,0 +1 @@
export { default as VbenBackTop } from './back-top.vue';

View File

@@ -0,0 +1,45 @@
import type { BacktopProps } from './backtop';
import { onMounted, ref, shallowRef } from 'vue';
import { useEventListener, useThrottleFn } from '@vueuse/core';
export const useBackTop = (props: BacktopProps) => {
const el = shallowRef<HTMLElement>();
const container = shallowRef<Document | HTMLElement>();
const visible = ref(false);
const handleScroll = () => {
if (el.value) {
visible.value = el.value.scrollTop >= (props?.visibilityHeight ?? 0);
}
};
const handleClick = () => {
el.value?.scrollTo({ behavior: 'smooth', top: 0 });
};
const handleScrollThrottled = useThrottleFn(handleScroll, 300, true);
useEventListener(container, 'scroll', handleScrollThrottled);
onMounted(() => {
container.value = document;
el.value = document.documentElement;
if (props.target) {
el.value = document.querySelector<HTMLElement>(props.target) ?? undefined;
if (!el.value) {
throw new Error(`target does not exist: ${props.target}`);
}
container.value = el.value;
}
// Give visible an initial value, fix #13066
handleScroll();
});
return {
handleClick,
visible,
};
};

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { BreadcrumbProps } from './types';
import { ChevronDown } from '@vben-core/icons';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../ui';
import { VbenIcon } from '../icon';
interface Props extends BreadcrumbProps {}
defineOptions({ name: 'Breadcrumb' });
withDefaults(defineProps<Props>(), {
showIcon: false,
});
const emit = defineEmits<{ select: [string] }>();
function handleClick(path?: string) {
if (!path) {
return;
}
emit('select', path);
}
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<TransitionGroup name="breadcrumb-transition">
<template
v-for="(item, index) in breadcrumbs"
:key="`${item.path}-${item.title}-${index}`"
>
<BreadcrumbItem>
<div v-if="item.items?.length ?? 0 > 0">
<DropdownMenu>
<DropdownMenuTrigger class="flex items-center gap-1">
<VbenIcon v-if="showIcon" :icon="item.icon" class="size-5" />
{{ item.title }}
<ChevronDown class="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<template
v-for="menuItem in item.items"
:key="`sub-${menuItem.path}`"
>
<DropdownMenuItem @click.stop="handleClick(menuItem.path)">
{{ menuItem.title }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
<BreadcrumbLink
v-else-if="index !== breadcrumbs.length - 1"
href="javascript:void 0"
@click.stop="handleClick(item.path)"
>
<div class="flex-center">
<VbenIcon
v-if="showIcon"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
class="mr-1 size-4"
/>
{{ item.title }}
</div>
</BreadcrumbLink>
<BreadcrumbPage v-else>
<div class="flex-center">
<VbenIcon
v-if="showIcon"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
class="mr-1 size-4"
/>
{{ item.title }}
</div>
</BreadcrumbPage>
<BreadcrumbSeparator
v-if="index < breadcrumbs.length - 1 && !item.isHome"
/>
</BreadcrumbItem>
</template>
</TransitionGroup>
</BreadcrumbList>
</Breadcrumb>
</template>

View File

@@ -0,0 +1,3 @@
export { default as VbenBreadcrumbView } from './breadcrumb-view.vue';
export type * from './types';

View File

@@ -0,0 +1,17 @@
import type { Component } from 'vue';
import type { BreadcrumbStyleType } from '@vben-core/typings';
export interface IBreadcrumb {
icon?: Component | string;
isHome?: boolean;
items?: IBreadcrumb[];
path?: string;
title?: string;
}
export interface BreadcrumbProps {
breadcrumbs: IBreadcrumb[];
showIcon?: boolean;
styleType?: BreadcrumbStyleType;
}

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

@@ -0,0 +1,196 @@
<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 { cn, isFunction } from '@vben-core/shared/utils';
import { objectOmit } from '@vueuse/core';
import { VbenRenderContent } from '../render-content';
import VbenButtonGroup from './button-group.vue';
import Button from './button.vue';
const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
gap: 0,
multiple: false,
showIcon: true,
size: 'middle',
allowClear: false,
maxCount: 0,
});
const emit = defineEmits(['btnClick']);
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;
}
},
);
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, immediate: 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 {
if (props.maxCount > 0 && innerValue.value.length >= props.maxCount) {
innerValue.value = innerValue.value.slice(0, props.maxCount - 1);
}
innerValue.value.push(value);
}
modelValue.value = innerValue.value;
} else {
if (props.allowClear && innerValue.value.includes(value)) {
innerValue.value = [];
modelValue.value = undefined;
emit('btnClick', undefined);
return;
} else {
innerValue.value = [value];
modelValue.value = value;
}
}
emit('btnClick', 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)"
type="button"
>
<div class="icon-wrapper" v-if="props.showIcon">
<slot
name="icon"
:loading="loadingValues.includes(btn.value)"
:checked="innerValue.includes(btn.value)"
>
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
</slot>
</div>
<slot name="option" :label="btn.label" :value="btn.value" :data="btn">
<VbenRenderContent :content="btn.label" />
</slot>
</Button>
</VbenButtonGroup>
</template>
<style lang="scss" scoped>
.vben-check-button-group {
display: flex;
flex-wrap: wrap;
&: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;
}
}
}
&.no-gap > :deep(button):nth-of-type(1) {
border-right-width: 0;
}
&.no-gap {
:deep(button + button) {
margin-right: -1px;
border-left-width: 1px;
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { ButtonVariants } from '../../ui';
import type { VbenButtonProps } from './button';
import { computed, useSlots } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { VbenTooltip } from '../tooltip';
import VbenButton from './button.vue';
interface Props extends VbenButtonProps {
class?: any;
disabled?: boolean;
onClick?: () => void;
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
onClick: () => {},
tooltipDelayDuration: 200,
tooltipSide: 'bottom',
variant: 'icon',
});
const slots = useSlots();
const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
</script>
<template>
<VbenButton
v-if="!showTooltip"
:class="cn('rounded-full', props.class)"
:disabled="disabled"
:variant="variant"
size="icon"
@click="onClick"
>
<slot></slot>
</VbenButton>
<VbenTooltip
v-else
:delay-duration="tooltipDelayDuration"
:side="tooltipSide"
>
<template #trigger>
<VbenButton
:class="cn('rounded-full', props.class)"
:disabled="disabled"
:variant="variant"
size="icon"
@click="onClick"
>
<slot></slot>
</VbenButton>
</template>
<slot v-if="slots.tooltip" name="tooltip"> </slot>
<template v-else>
{{ tooltip }}
</template>
</VbenTooltip>
</template>

View File

@@ -0,0 +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

@@ -0,0 +1 @@
export { default as VbenCheckbox } from './checkbox.vue';

View File

@@ -0,0 +1,3 @@
export { default as VbenContextMenu } from './context-menu.vue';
export type * from './interface';

View File

@@ -0,0 +1,38 @@
import type { Component } from 'vue';
interface IContextMenuItem {
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 点击事件处理
* @param data
*/
handler?: (data: any) => void;
/**
* @zh_CN 图标
*/
icon?: Component;
/**
* @zh_CN 是否显示图标
*/
inset?: boolean;
/**
* @zh_CN 唯一标识
*/
key: string;
/**
* @zh_CN 是否是分割线
*/
separator?: boolean;
/**
* @zh_CN 快捷键
*/
shortcut?: string;
/**
* @zh_CN 标题
*/
text: string;
}
export type { IContextMenuItem };

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import { computed, onMounted, ref, unref, watch, watchEffect } from 'vue';
import { isNumber } from '@vben-core/shared/utils';
import { TransitionPresets, useTransition } from '@vueuse/core';
interface Props {
autoplay?: boolean;
color?: string;
decimal?: string;
decimals?: number;
duration?: number;
endVal?: number;
prefix?: string;
separator?: string;
startVal?: number;
suffix?: string;
transition?: keyof typeof TransitionPresets;
useEasing?: boolean;
}
defineOptions({ name: 'CountToAnimator' });
const props = withDefaults(defineProps<Props>(), {
autoplay: true,
color: '',
decimal: '.',
decimals: 0,
duration: 1500,
endVal: 2021,
prefix: '',
separator: ',',
startVal: 0,
suffix: '',
transition: 'linear',
useEasing: true,
});
const emit = defineEmits<{
finished: [];
/**
* @deprecated 请使用{@link finished}事件
*/
onFinished: [];
/**
* @deprecated 请使用{@link started}事件
*/
onStarted: [];
started: [];
}>();
const source = ref(props.startVal);
const disabled = ref(false);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(unref(outputValue)));
watchEffect(() => {
source.value = props.startVal;
});
watch([() => props.startVal, () => props.endVal], () => {
if (props.autoplay) {
start();
}
});
onMounted(() => {
props.autoplay && start();
});
function start() {
run();
source.value = props.endVal;
}
function reset() {
source.value = props.startVal;
run();
}
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => {
emit('finished');
emit('onFinished');
},
onStarted: () => {
emit('started');
emit('onStarted');
},
...(props.useEasing
? { transition: TransitionPresets[props.transition] }
: {}),
});
}
function formatNumber(num: number | string) {
if (!num && num !== 0) {
return '';
}
const { decimal, decimals, prefix, separator, suffix } = props;
num = Number(num).toFixed(decimals);
num += '';
const x = num.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator && !isNumber(separator) && x1) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, `$1${separator}$2`);
}
}
return prefix + x1 + x2 + suffix;
}
defineExpose({ reset });
</script>
<template>
<span :style="{ color }">
{{ value }}
</span>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenCountToAnimator } from './count-to-animator.vue';

View File

@@ -0,0 +1,4 @@
export { default as VbenDropdownMenu } from './dropdown-menu.vue';
export { default as VbenDropdownRadioMenu } from './dropdown-radio-menu.vue';
export type * from './interface';

View File

@@ -0,0 +1,32 @@
import type { Component } from 'vue';
interface VbenDropdownMenuItem {
disabled?: boolean;
/**
* @zh_CN 点击事件处理
* @param data
*/
handler?: (data: any) => void;
/**
* @zh_CN 图标
*/
icon?: Component;
/**
* @zh_CN 标题
*/
label: string;
/**
* @zh_CN 是否是分割线
*/
separator?: boolean;
/**
* @zh_CN 唯一标识
*/
value: string;
}
interface DropdownMenuProps {
menus: VbenDropdownMenuItem[];
}
export type { DropdownMenuProps, VbenDropdownMenuItem };

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { ChevronDown } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: string;
}>();
// 控制箭头展开/收起状态
const collapsed = defineModel({ default: false });
</script>
<template>
<div
:class="cn('vben-link inline-flex items-center', props.class)"
@click="collapsed = !collapsed"
>
<slot :is-expanded="collapsed">
{{ collapsed }}
<!-- <span>{{ isExpanded ? '收起' : '展开' }}</span> -->
</slot>
<div
:class="{ 'rotate-180': !collapsed }"
class="transition-transform duration-300"
>
<slot name="icon">
<ChevronDown class="size-4" />
</slot>
</div>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenExpandableArrow } from './expandable-arrow.vue';

View File

@@ -0,0 +1 @@
export { default as VbenFullScreen } from './full-screen.vue';

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { computed } from 'vue';
import { IconDefault, IconifyIcon } from '@vben-core/icons';
import {
isFunction,
isHttpUrl,
isObject,
isString,
} from '@vben-core/shared/utils';
const props = defineProps<{
// 没有是否显示默认图标
fallback?: boolean;
icon?: Component | Function | string;
}>();
const isRemoteIcon = computed(() => {
return isString(props.icon) && isHttpUrl(props.icon);
});
const isComponent = computed(() => {
const { icon } = props;
return !isString(icon) && (isObject(icon) || isFunction(icon));
});
</script>
<template>
<component :is="icon as Component" v-if="isComponent" v-bind="$attrs" />
<img v-else-if="isRemoteIcon" :src="icon as string" v-bind="$attrs" />
<IconifyIcon v-else-if="icon" v-bind="$attrs" :icon="icon as string" />
<IconDefault v-else-if="fallback" v-bind="$attrs" />
</template>

View File

@@ -0,0 +1 @@
export { default as VbenIcon } from './icon.vue';

View File

@@ -0,0 +1,23 @@
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
export * from './button';
export * from './checkbox';
export * from './context-menu';
export * from './count-to-animator';
export * from './dropdown-menu';
export * from './expandable-arrow';
export * from './full-screen';
export * from './hover-card';
export * from './icon';
export * from './input-password';
export * from './logo';
export * from './pin-input';
export * from './popover';
export * from './render-content';
export * from './scrollbar';
export * from './segmented';
export * from './select';
export * from './spine-text';
export * from './spinner';
export * from './tooltip';

View File

@@ -0,0 +1 @@
export { default as VbenInputPassword } from './input-password.vue';

View File

@@ -0,0 +1 @@
export { default as VbenLogo } from './logo.vue';

View File

@@ -0,0 +1,3 @@
export { default as VbenPinInput } from './input.vue';
export type * from './types';

View File

@@ -0,0 +1,30 @@
interface PinInputProps {
class?: any;
/**
* 验证码长度
*/
codeLength?: number;
/**
* 发送验证码按钮文本
*/
createText?: (countdown: number) => string;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 自定义验证码发送逻辑
* @returns
*/
handleSendCode?: () => Promise<void>;
/**
* 发送验证码按钮loading
*/
loading?: boolean;
/**
* 最大重试时间
*/
maxTime?: number;
}
export type { PinInputProps };

View File

@@ -0,0 +1 @@
export { default as VbenPopover } from './popover.vue';

View File

@@ -0,0 +1 @@
export { default as VbenRenderContent } from './render-content.vue';

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import type { Component, PropType } from 'vue';
import { defineComponent, h } from 'vue';
import { isFunction, isObject, isString } from '@vben-core/shared/utils';
export default defineComponent({
name: 'RenderContent',
props: {
content: {
default: undefined as
| PropType<(() => any) | Component | string>
| undefined,
type: [Object, String, Function],
},
renderBr: {
default: false,
type: Boolean,
},
},
setup(props, { attrs, slots }) {
return () => {
if (!props.content) {
return null;
}
const isComponent =
(isObject(props.content) || isFunction(props.content)) &&
props.content !== null;
if (!isComponent) {
if (props.renderBr && isString(props.content)) {
const lines = props.content.split('\n');
const result = [];
for (const [i, line] of lines.entries()) {
result.push(h('p', { key: i }, line));
// if (i < lines.length - 1) {
// result.push(h('br'));
// }
}
return result;
} else {
return props.content;
}
}
return h(props.content as never, {
...attrs,
props: {
...props,
...attrs,
},
slots,
});
};
},
});
</script>

View File

@@ -0,0 +1 @@
export { default as VbenScrollbar } from './scrollbar.vue';

View File

@@ -0,0 +1,3 @@
export { default as VbenSegmented } from './segmented.vue';
export type * from './types';

View File

@@ -0,0 +1,6 @@
interface SegmentedItem {
label: string;
value: string;
}
export type { SegmentedItem };

View File

@@ -0,0 +1 @@
export { default as VbenSelect } from './select.vue';

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { CircleX } from '@vben-core/icons';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../ui';
interface Props {
allowClear?: boolean;
class?: any;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
allowClear: false,
});
const modelValue = defineModel<string>();
function handleClear() {
modelValue.value = undefined;
}
</script>
<template>
<Select v-model="modelValue">
<SelectTrigger :class="props.class" class="flex w-full items-center">
<SelectValue class="flex-auto text-left" :placeholder="placeholder" />
<CircleX
@pointerdown.stop
@click.stop.prevent="handleClear"
v-if="allowClear && modelValue"
data-clear-button
class="mr-1 size-4 cursor-pointer opacity-50 hover:opacity-100"
/>
</SelectTrigger>
<SelectContent>
<template v-for="item in options" :key="item.value">
<SelectItem :value="item.value"> {{ item.label }} </SelectItem>
</template>
</SelectContent>
</Select>
</template>
<style lang="scss" scoped>
button[role='combobox'][data-placeholder] {
color: hsl(var(--muted-foreground));
}
button {
--ring: var(--primary);
}
</style>

Some files were not shown because too many files have changed in this diff Show More