chore: rename @vben-core -> @core

This commit is contained in:
vben
2024-06-08 21:27:43 +08:00
parent 1d6b1f4926
commit dcc1fcd528
388 changed files with 63 additions and 113 deletions

View File

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

View File

@@ -0,0 +1,52 @@
{
"name": "@vben-core/layout-ui",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/layout-ui"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/design": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.10.0",
"vue": "3.4.27"
}
}

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 LayoutSide } from './layout-side.vue';
export { default as LayoutTabs } from './layout-tabs.vue';

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { ContentCompactType } from '@vben-core/typings';
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
interface Props {
/**
* 内容区域定宽
* @default 'wide'
*/
contentCompact?: ContentCompactType;
/**
* 定宽布局宽度
* @default 1200
*/
contentCompactWidth?: number;
/**
* padding
* @default 16
*/
padding?: number;
/**
* paddingBottom
* @default 16
*/
paddingBottom?: number;
/**
* paddingLeft
* @default 16
*/
paddingLeft?: number;
/**
* paddingRight
* @default 16
*/
paddingRight?: number;
/**
* paddingTop
* @default 16
*/
paddingTop?: number;
}
defineOptions({ name: 'LayoutContent' });
const props = withDefaults(defineProps<Props>(), {
contentCompact: 'wide',
contentCompactWidth: 1200,
padding: 16,
paddingBottom: 16,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 16,
});
const style = computed((): CSSProperties => {
const {
contentCompact,
padding,
paddingBottom,
paddingLeft,
paddingRight,
paddingTop,
} = props;
const compactStyle: CSSProperties =
contentCompact === 'compact' ? { margin: '0 auto', width: `1200px` } : {};
return {
...compactStyle,
flex: 1,
padding: `${padding}px`,
paddingBottom: `${paddingBottom}px`,
paddingLeft: `${paddingLeft}px`,
paddingRight: `${paddingRight}px`,
paddingTop: `${paddingTop}px`,
};
});
</script>
<template>
<main :style="style">
<slot></slot>
</main>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 是否固定在顶部
* @default true
*/
fixed?: boolean;
/**
* 高度
* @default 32
*/
height?: number;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 高度
* @default 100%
*/
width?: string;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutFooter' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
fixed: true,
height: 32,
show: true,
width: '100%',
zIndex: 0,
});
const { b } = useNamespace('footer');
const style = computed((): CSSProperties => {
const { backgroundColor, fixed, height, show, width, zIndex } = props;
return {
backgroundColor,
height: `${height}px`,
marginBottom: show ? '0' : `-${height}px`,
position: fixed ? 'fixed' : 'static',
width,
zIndex,
};
});
</script>
<template>
<footer
:class="b()"
class="bottom-0 w-full transition-all duration-200"
:style="style"
>
<slot></slot>
</footer>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, useSlots } from 'vue';
import { IcRoundMenu } from '@vben-core/iconify';
import { VbenIconButton } from '@vben-core/shadcn-ui';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 横屏
* @default false
*/
fullWidth?: boolean;
/**
* 高度
* @default 60
*/
height?: number;
/**
* 是否混合导航
* @default false
*/
isMixedNav?: boolean;
/**
* 是否移动端
* @default false
*/
isMobile?: boolean;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 是否显示关闭菜单按钮
* @default true
*/
showToggleBtn?: boolean;
/**
* 侧边是否显示
*/
sideHidden?: boolean;
/**
* 侧边菜单宽度
* @default 0
*/
sideWidth?: number;
/**
* 宽度
* @default 100%
*/
width?: string;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutHeader' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
// fixed: true,
height: 60,
isMixedNav: false,
show: true,
showToggleBtn: false,
sideWidth: 0,
width: '100%',
zIndex: 0,
});
const emit = defineEmits<{ openMenu: []; toggleMenu: [] }>();
const slots = useSlots();
const style = computed((): CSSProperties => {
const { backgroundColor, fullWidth, height, show } = props;
const right = !show || !fullWidth ? undefined : 0;
return {
// ...(props.isMixedNav ? { left: 0, position: `fixed` } : {}),
backgroundColor,
height: `${height}px`,
marginTop: show ? 0 : `-${height}px`,
right,
};
});
const logoStyle = computed((): CSSProperties => {
return {
minWidth: `${props.isMobile ? 40 : props.sideWidth}px`,
};
});
function handleToggleMenu() {
emit('toggleMenu');
}
function handleOpenMenu() {
emit('openMenu');
}
</script>
<template>
<header
:style="style"
class="border-border top-0 flex w-full flex-[0_0_auto] items-center border-b transition-[margin-top] duration-200"
>
<div v-if="slots.logo" :style="logoStyle">
<slot name="logo"></slot>
</div>
<VbenIconButton
v-if="showToggleBtn"
class="my-0 ml-2 mr-1 rounded"
@click="handleToggleMenu"
>
<IcRoundMenu class="size-5" />
</VbenIconButton>
<VbenIconButton
v-if="isMobile"
class="my-0 ml-2 mr-1 rounded"
@click="handleOpenMenu"
>
<IcRoundMenu class="size-5" />
</VbenIconButton>
<slot></slot>
</header>
</template>

View File

@@ -0,0 +1,377 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
// import { onClickOutside } from '@vueuse/core';
import { computed, ref, shallowRef, useSlots, watchEffect } from 'vue';
import { ScrollArea } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
import { SideCollapseButton, SidePinButton } from './widgets';
interface Props {
/**
* 背景颜色
*/
backgroundColor: string;
/**
* 折叠区域高度
* @default 32
*/
collapseHeight?: number;
/**
* 折叠宽度
* @default 48
*/
collapseWidth?: number;
/**
* 隐藏的dom是否可见
* @default true
*/
domVisible?: boolean;
/**
* 扩展区域背景颜色
*/
extraBackgroundColor: string;
/**
* 扩展区域宽度
* @default 180
*/
extraWidth?: number;
/**
* 固定扩展区域
* @default false
*/
fixedExtra?: boolean;
/**
* 头部高度
*/
headerHeight: number;
/**
* 是否侧边混合模式
* @default false
*/
isSideMixed?: boolean;
/**
* 混合菜单宽度
* @default 80
*/
mixedWidth?: number;
/**
* 顶部padding
* @default 60
*/
paddingTop?: number;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 显示折叠按钮
* @default false
*/
showCollapseButton?: boolean;
/**
* 主题
*/
theme?: string;
/**
* 宽度
* @default 180
*/
width?: number;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutSide' });
const props = withDefaults(defineProps<Props>(), {
collapseHeight: 42,
collapseWidth: 48,
domVisible: true,
extraWidth: 180,
fixedExtra: false,
isSideMixed: false,
mixedWidth: 80,
paddingTop: 60,
show: true,
showCollapseButton: true,
theme: 'dark',
width: 180,
zIndex: 0,
});
const emit = defineEmits<{ leave: [] }>();
const collapse = defineModel<boolean>('collapse');
const extraCollapse = defineModel<boolean>('extraCollapse');
const expandOnHovering = defineModel<boolean>('expandOnHovering');
const expandOnHover = defineModel<boolean>('expandOnHover');
const extraVisible = defineModel<boolean>('extraVisible');
const { b, e, is } = useNamespace('side');
const slots = useSlots();
const asideRef = shallowRef<HTMLDivElement | null>();
const scrolled = ref(false);
const hiddenSideStyle = computed((): CSSProperties => {
return calcMenuWidthStyle(true);
});
const style = computed((): CSSProperties => {
const { isSideMixed, paddingTop, zIndex } = props;
return {
...calcMenuWidthStyle(false),
paddingTop: `${paddingTop}px`,
zIndex,
...(isSideMixed && extraVisible.value ? { transition: 'none' } : {}),
};
});
const extraStyle = computed((): CSSProperties => {
const { extraBackgroundColor, extraWidth, show, width, zIndex } = props;
return {
backgroundColor: extraBackgroundColor,
left: `${width}px`,
width: extraVisible.value && show ? `${extraWidth}px` : 0,
zIndex,
};
});
const extraTitleStyle = computed((): CSSProperties => {
const { headerHeight } = props;
return {
height: `${headerHeight - 1}px`,
};
});
const contentWidthStyle = computed((): CSSProperties => {
const { collapseWidth, fixedExtra, isSideMixed, mixedWidth } = props;
if (isSideMixed && fixedExtra) {
return { width: `${collapse.value ? collapseWidth : mixedWidth}px` };
}
return {};
});
const contentStyle = computed((): CSSProperties => {
const { collapseHeight, headerHeight } = props;
return {
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
paddingTop: '8px',
...contentWidthStyle.value,
};
});
const headerStyle = computed((): CSSProperties => {
const { headerHeight, isSideMixed } = props;
return {
...(isSideMixed ? { display: 'flex', justifyContent: 'center' } : {}),
height: `${headerHeight}px`,
...contentWidthStyle.value,
};
});
const extraContentStyle = computed((): CSSProperties => {
const { collapseHeight, headerHeight } = props;
return {
color: 'red',
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
};
});
const collapseStyle = computed((): CSSProperties => {
const { collapseHeight } = props;
return {
height: `${collapseHeight}px`,
};
});
watchEffect(() => {
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
});
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
const { backgroundColor, extraWidth, fixedExtra, isSideMixed, show, width } =
props;
let widthValue = `${width + (isSideMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
const { collapseWidth } = props;
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
widthValue = `${collapseWidth}px`;
}
return {
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
backgroundColor,
flex: `0 0 ${widthValue}`,
marginLeft: show ? 0 : `-${widthValue}`,
maxWidth: widthValue,
minWidth: widthValue,
width: widthValue,
};
}
function handleMouseenter() {
// 未开启和未折叠状态不生效
if (expandOnHover.value) {
return;
}
if (!expandOnHovering.value) {
collapse.value = false;
}
expandOnHovering.value = true;
}
function handleMouseleave() {
emit('leave');
if (expandOnHover.value) {
return;
}
expandOnHovering.value = false;
collapse.value = true;
extraVisible.value = false;
}
function handleScroll(event: Event) {
const target = event.target as HTMLElement;
scrolled.value = (target?.scrollTop ?? 0) > 0;
}
</script>
<template>
<div v-if="domVisible" :class="e('hide')" :style="hiddenSideStyle"></div>
<aside
:class="[b(), is(theme, true)]"
:style="style"
@mouseenter="handleMouseenter"
@mouseleave="handleMouseleave"
>
<SidePinButton
v-if="!collapse && !isSideMixed"
v-model:expand-on-hover="expandOnHover"
:theme="theme"
/>
<div v-if="slots.logo" :style="headerStyle">
<slot name="logo"></slot>
</div>
<ScrollArea :style="contentStyle" :on-scroll="handleScroll">
<div :class="[e('shadow'), { scrolled }]"></div>
<slot></slot>
</ScrollArea>
<div :style="collapseStyle"></div>
<SideCollapseButton
v-if="showCollapseButton && !isSideMixed"
v-model:collapse="collapse"
:theme="theme"
/>
<div
v-if="isSideMixed"
ref="asideRef"
:class="e('extra')"
class="transition-[width] duration-200"
:style="extraStyle"
>
<SideCollapseButton
v-if="isSideMixed && expandOnHover"
v-model:collapse="extraCollapse"
:theme="theme"
/>
<SidePinButton
v-if="!extraCollapse"
v-model:expand-on-hover="expandOnHover"
:theme="theme"
/>
<div v-if="!extraCollapse" :style="extraTitleStyle">
<slot name="extra-title"></slot>
</div>
<ScrollArea
:style="extraContentStyle"
:class="e('extra-content')"
:on-scroll="handleScroll"
>
<div :class="[e('shadow'), { scrolled }]"></div>
<slot name="extra"></slot>
</ScrollArea>
</div>
</aside>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side') {
--color-surface: var(--color-menu);
position: fixed;
top: 0;
left: 0;
height: 100%;
transition: all 0.2s ease 0s;
@include is('dark') {
--color-surface: var(--color-menu-dark);
}
@include e('shadow') {
position: absolute;
top: 0;
z-index: 1;
inline-size: 100%;
block-size: 40px;
height: 50px;
pointer-events: none;
background: linear-gradient(
to bottom,
hsl(var(--color-surface)),
transparent
);
opacity: 0;
transition: opacity 0.15s ease-in-out;
will-change: opacity;
&.scrolled {
opacity: 1;
}
}
@include is('dark') {
.#{$namespace}-side__extra {
&-content {
border-color: hsl(var(--color-dark-border)) !important;
}
}
}
@include e('hide') {
height: 100%;
transition: all 0.2s ease 0s;
}
@include e('extra') {
position: fixed;
top: 0;
height: 100%;
overflow: hidden;
transition: all 0.2s ease 0s;
&-content {
padding: 4px 0;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 高度
* @default 30
*/
height?: number;
}
defineOptions({ name: 'LayoutTabs' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
fixed: true,
height: 30,
});
const hiddenStyle = computed((): CSSProperties => {
const { height } = props;
return {
height: `${height}px`,
};
});
const style = computed((): CSSProperties => {
const { backgroundColor } = props;
return {
...hiddenStyle.value,
backgroundColor,
display: 'flex',
};
});
</script>
<template>
<section :style="style" class="border-border flex w-full border-b">
<slot></slot>
<div class="flex items-center">
<slot name="toolbar"></slot>
</div>
</section>
</template>

View File

@@ -0,0 +1,2 @@
export { default as SideCollapseButton } from './side-collapse-button.vue';
export { default as SidePinButton } from './side-pin-button.vue';

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { MdiMenuClose, MdiMenuOpen } from '@vben-core/iconify';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
theme: string;
}
defineOptions({ name: 'SideCollapseButton' });
withDefaults(defineProps<Props>(), {});
const collapse = defineModel<boolean>('collapse');
const { b, is } = useNamespace('side-collapse');
function handleCollapse() {
collapse.value = !collapse.value;
}
</script>
<template>
<div :class="[b(), is(theme, true)]" @click.stop="handleCollapse">
<MdiMenuClose v-if="collapse" />
<MdiMenuOpen v-else />
</div>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side-collapse') {
position: absolute;
bottom: 6px;
left: 10px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
color: hsl(var(--color-foreground) / 60%);
cursor: pointer;
background: hsl(var(--color-accent)) !important;
border-radius: 4px;
opacity: 1;
transition: all 0.3s ease;
@include is('dark') {
color: hsl(var(--color-dark-foreground) / 60%) !important;
background: hsl(var(--color-dark-accent)) !important;
&:hover {
color: hsl(var(--color-dark-foreground)) !important;
background: hsl(var(--color-dark-accent-hover)) !important;
}
}
&:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-accent-hover));
}
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { MdiPin, MdiPinOff } from '@vben-core/iconify';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
theme: string;
}
defineOptions({ name: 'SidePinButton' });
withDefaults(defineProps<Props>(), {});
const expandOnHover = defineModel<boolean>('expandOnHover');
const { b, is } = useNamespace('side-pin');
function togglePined() {
expandOnHover.value = !expandOnHover.value;
}
</script>
<template>
<div :class="[b(), is(theme, true)]" @click="togglePined">
<MdiPinOff v-if="!expandOnHover" />
<MdiPin v-else />
</div>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side-pin') {
position: absolute;
right: 10px;
bottom: 6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
color: hsl(var(--color-foreground) / 60%);
cursor: pointer;
background: hsl(var(--color-accent)) !important;
border-radius: 4px;
opacity: 1;
transition: all 0.3s ease;
@include is('dark') {
color: hsl(var(--color-dark-foreground) / 60%) !important;
background: unset;
background: hsl(var(--color-dark-accent)) !important;
&:hover {
color: hsl(var(--color-dark-foreground)) !important;
background: hsl(var(--color-dark-accent-hover)) !important;
}
}
&:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-accent-hover));
}
}
</style>

View File

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

View File

@@ -0,0 +1,181 @@
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 #fff
*/
footerBackgroundColor?: string;
/**
* footer 是否固定
* @default true
*/
footerFixed?: boolean;
/**
* footer 高度
* @default 32
*/
footerHeight?: number;
/**
* footer 是否可见
* @default false
*/
footerVisible?: boolean;
/**
* 背景颜色
* @default #fff
*/
headerBackgroundColor?: string;
/**
* header高度
* @default 48
*/
headerHeight?: number;
/**
* header高度增加高度
* 在顶部存在导航时额外加高header高度
* @default 10
*/
headerHeightOffset?: number;
/**
* 顶栏是否隐藏
* @default false
*/
headerHidden?: boolean;
/**
* header 显示模式
* @default 'fixed'
*/
headerMode?: LayoutHeaderModeType;
/**
* header是否显示
* @default true
*/
headerVisible?: boolean;
/**
* 是否移动端显示
* @default false
*/
isMobile?: boolean;
/**
* 布局方式
* side-nav 侧边菜单布局
* header-nav 顶部菜单布局
* mixed-nav 侧边&顶部菜单布局
* side-mixed-nav 侧边混合菜单布局
* full-content 全屏内容布局
* @default side-nav
*/
layout?: LayoutType;
/**
* 侧边菜单折叠状态
* @default false
*/
sideCollapse?: boolean;
/**
* 侧边菜单是否折叠时是否显示title
* @default true
*/
sideCollapseShowTitle?: boolean;
/**
* 侧边菜单折叠宽度
* @default 48
*/
sideCollapseWidth?: number;
/**
* 侧边栏是否隐藏
* @default false
*/
sideHidden?: boolean;
/**
* 混合侧边扩展区域是否可见
* @default false
*/
sideMixedExtraVisible?: boolean;
/**
* 混合侧边栏宽度
* @default 80
*/
sideMixedWidth?: number;
/**
* 侧边栏是否半深色
* @default false
*/
sideSemiDark?: boolean;
/**
* 侧边栏
* @default dark
*/
sideTheme?: ThemeModeType;
/**
* 侧边栏是否可见
* @default true
*/
sideVisible?: boolean;
/**
* 侧边栏宽度
* @default 210
*/
sideWidth?: number;
/**
* footer背景颜色
* @default #fff
*/
tabsBackgroundColor?: string;
/**
* tab高度
* @default 30
*/
tabsHeight?: number;
/**
* tab是否可见
* @default true
*/
tabsVisible?: boolean;
/**
* zIndex
* @default 100
*/
zIndex?: number;
}
export type { VbenLayoutProps };

View File

@@ -0,0 +1,574 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, ref, watch } from 'vue';
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
import {
LayoutContent,
LayoutFooter,
LayoutHeader,
LayoutSide,
LayoutTabs,
} from './components';
import { VbenLayoutProps } from './vben-layout';
interface Props extends VbenLayoutProps {}
defineOptions({
name: 'VbenLayout',
});
const props = withDefaults(defineProps<Props>(), {
contentCompact: 'wide',
contentPadding: 0,
contentPaddingBottom: 0,
contentPaddingLeft: 0,
contentPaddingRight: 0,
contentPaddingTop: 0,
// footerBackgroundColor: '#fff',
footerFixed: true,
footerHeight: 32,
footerVisible: false,
// headerBackgroundColor: 'hsl(var(--color-background))',
headerHeight: 50,
headerHeightOffset: 10,
headerHidden: false,
headerMode: 'fixed',
headerVisible: true,
isMobile: false,
layout: 'side-nav',
sideCollapseShowTitle: false,
// sideCollapse: false,
sideCollapseWidth: 60,
sideHidden: false,
sideMixedWidth: 80,
sideSemiDark: true,
sideTheme: 'dark',
sideWidth: 180,
// tabsBackgroundColor: 'hsl(var(--color-background))',
tabsHeight: 36,
tabsVisible: true,
zIndex: 200,
});
const emit = defineEmits<{ sideMouseLeave: [] }>();
const sideCollapse = defineModel<boolean>('sideCollapse');
const sideExtraVisible = defineModel<boolean>('sideExtraVisible');
const sideExtraCollapse = defineModel<boolean>('sideExtraCollapse');
const sideExpandOnHover = defineModel<boolean>('sideExpandOnHover');
const sideVisible = defineModel<boolean>('sideVisible', { default: true });
const {
arrivedState,
directions,
isScrolling,
y: scrollY,
} = useScroll(document);
const { y: mouseY } = useMouse({ type: 'client' });
// side是否处于hover状态展开菜单中
const sideExpandOnHovering = ref(false);
// const sideHidden = ref(false);
const headerIsHidden = ref(false);
const realLayout = computed(() => {
return props.isMobile ? 'side-nav' : props.layout;
});
/**
* 是否全屏显示content不需要侧边、底部、顶部、tab区域
*/
const fullContent = computed(() => realLayout.value === 'full-content');
/**
* 是否侧边混合模式
*/
const isSideMixedNav = computed(() => realLayout.value === 'side-mixed-nav');
/**
* 是否为头部导航模式
*/
const isHeaderNav = computed(() => realLayout.value === 'header-nav');
/**
* 是否为混合导航模式
*/
const isMixedNav = computed(() => realLayout.value === 'mixed-nav');
/**
* 顶栏是否自动隐藏
*/
const isHeaderAuto = computed(() => props.headerMode === 'auto');
/**
* header区域高度
*/
const getHeaderHeight = computed(() => {
const { headerHeight, headerHeightOffset } = props;
// if (!headerVisible) {
// return 0;
// }
// 顶部存在导航时增加10
const offset = isMixedNav.value || isHeaderNav.value ? headerHeightOffset : 0;
return headerHeight + offset;
});
const headerWrapperHeight = computed(() => {
let height = 0;
if (props.headerVisible && !props.headerHidden) {
height += getHeaderHeight.value;
}
if (props.tabsVisible) {
height += props.tabsHeight;
}
return height;
});
const getSideCollapseWidth = computed(() => {
const { sideCollapseShowTitle, sideCollapseWidth, sideMixedWidth } = props;
return sideCollapseShowTitle || isSideMixedNav
? sideMixedWidth
: sideCollapseWidth;
});
/**
* 动态获取侧边区域是否可见
*/
const sideVisibleState = computed(() => {
return !isHeaderNav.value && sideVisible.value;
});
/**
* 侧边区域离顶部高度
*/
const sidePaddingTop = computed(() => {
const { isMobile } = props;
return isMixedNav.value && !isMobile ? getHeaderHeight.value : 0;
});
/**
* 动态获取侧边宽度
*/
const getSideWidth = computed(() => {
const { isMobile, sideHidden, sideMixedWidth, sideWidth } = props;
let width = 0;
if (sideHidden) {
return width;
}
if (
!sideVisibleState.value ||
(sideHidden && !isSideMixedNav.value && !isMixedNav.value)
) {
return width;
}
if (isSideMixedNav.value && !isMobile) {
width = sideMixedWidth;
} else if (sideCollapse.value) {
width = isMobile ? 0 : getSideCollapseWidth.value;
} else {
width = sideWidth;
}
return width;
});
/**
* 获取扩展区域宽度
*/
const getExtraWidth = computed(() => {
const { sideWidth } = props;
return sideExtraCollapse.value ? getSideCollapseWidth.value : sideWidth;
});
/**
* 是否侧边栏模式,包含混合侧边
*/
const isSideMode = computed(() =>
['mixed-nav', 'side-mixed-nav', 'side-nav'].includes(realLayout.value),
);
const showSide = computed(() => {
// if (isMixedNav.value && !props.sideHidden) {
// return false;
// }
return isSideMode.value && sideVisible.value;
});
const sideFace = computed(() => {
const { sideSemiDark, sideTheme } = props;
const isDark = sideTheme === 'dark' || sideSemiDark;
let backgroundColor = '';
let extraBackgroundColor = '';
if (isDark) {
backgroundColor = isSideMixedNav.value
? 'hsl(var(--color-menu-dark-darken))'
: 'hsl(var(--color-menu-dark))';
} else {
backgroundColor = isSideMixedNav.value
? 'hsl(var(--color-menu-darken))'
: 'hsl(var(--color-menu))';
}
extraBackgroundColor = isDark
? 'hsl(var(--color-menu-dark))'
: 'hsl(var(--color-menu))';
return {
backgroundColor,
extraBackgroundColor,
theme: isDark ? 'dark' : 'light',
};
});
/**
* 遮罩可见性
*/
const maskVisible = computed(() => !sideCollapse.value && props.isMobile);
/**
* header fixed值
*/
const headerFixed = computed(() => {
return (
isMixedNav.value ||
['auto', 'auto-scroll', 'fixed'].includes(props.headerMode)
);
});
const mainStyle = computed(() => {
let width = '100%';
let sidebarWidth = 'unset';
if (
headerFixed.value &&
!['header-nav', 'mixed-nav'].includes(realLayout.value) &&
showSide.value &&
!props.isMobile
) {
// pin模式下生效
const isSideNavEffective =
isSideMixedNav.value && sideExpandOnHover.value && sideExtraVisible.value;
if (isSideNavEffective) {
const sideCollapseWidth = sideCollapse.value
? getSideCollapseWidth.value
: props.sideMixedWidth;
const sideWidth = sideExtraCollapse.value
? getSideCollapseWidth.value
: props.sideWidth;
// 100% - 侧边菜单混合宽度 - 菜单宽度
sidebarWidth = `${sideCollapseWidth + sideWidth}px`;
width = `calc(100% - ${sidebarWidth})`;
} else {
sidebarWidth =
sideExpandOnHovering.value && !sideExpandOnHover.value
? `${getSideCollapseWidth.value}px`
: `${getSideWidth.value}px`;
width = `calc(100% - ${sidebarWidth})`;
}
}
return {
sidebarWidth,
width,
};
});
const tabsStyle = computed((): CSSProperties => {
let width = '';
let marginLeft = 0;
if (!isMixedNav.value) {
width = '100%';
} else if (sideVisible.value) {
marginLeft = sideCollapse.value
? getSideCollapseWidth.value
: props.sideWidth;
width = `calc(100% - ${getSideWidth.value}px)`;
} else {
width = '100%';
}
return {
marginLeft: `${marginLeft}px`,
width,
};
});
const footerWidth = computed(() => {
if (!props.footerFixed) {
return '100%';
}
return mainStyle.value.width;
});
const contentStyle = computed((): CSSProperties => {
const fixed = headerFixed.value;
return {
marginTop:
fixed &&
!fullContent.value &&
!headerIsHidden.value &&
(!isHeaderAuto.value || scrollY.value < headerWrapperHeight.value)
? `${headerWrapperHeight.value}px`
: 0,
paddingBottom: `${props.footerVisible ? props.footerHeight : 0}px`,
};
});
const headerZIndex = computed(() => {
const { zIndex } = props;
const offset = isMixedNav.value ? 1 : 0;
return zIndex + offset;
});
const headerWrapperStyle = computed((): CSSProperties => {
const fixed = headerFixed.value;
return {
height: fullContent.value ? '0' : `${headerWrapperHeight.value}px`,
left: isMixedNav.value ? 0 : mainStyle.value.sidebarWidth,
position: fixed ? 'fixed' : 'static',
top:
headerIsHidden.value || fullContent.value
? `-${headerWrapperHeight.value}px`
: 0,
width: mainStyle.value.width,
'z-index': headerZIndex.value,
};
});
/**
* 侧边栏z-index
*/
const sideZIndex = computed(() => {
const { isMobile, zIndex } = props;
const offset = isMobile || isSideMode.value ? 1 : -1;
return zIndex + offset;
});
const maskStyle = computed((): CSSProperties => {
return {
zIndex: props.zIndex,
};
});
const showHeaderToggleButton = computed(() => {
return (
isSideMode.value &&
!isSideMixedNav.value &&
!isMixedNav.value &&
!props.isMobile
);
// return false;
});
const showHeaderLogo = computed(() => {
return !isSideMode.value || isMixedNav.value || props.isMobile;
});
watch(
() => props.isMobile,
(val) => {
sideCollapse.value = val;
},
);
{
const mouseMove = () => {
mouseY.value > headerWrapperHeight.value
? (headerIsHidden.value = true)
: (headerIsHidden.value = false);
};
watch(
[() => props.headerMode, () => mouseY.value],
() => {
if (!isHeaderAuto.value || isMixedNav.value || fullContent.value) {
return;
}
headerIsHidden.value = true;
mouseMove();
},
{
immediate: true,
},
);
}
{
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
if (scrollY.value < headerWrapperHeight.value) {
headerIsHidden.value = false;
return;
}
if (topArrived) {
headerIsHidden.value = false;
return;
}
if (top) {
headerIsHidden.value = false;
} else if (bottom) {
headerIsHidden.value = true;
}
}, 300);
watch(
() => scrollY.value,
() => {
if (
props.headerMode !== 'auto-scroll' ||
isMixedNav.value ||
fullContent.value
) {
return;
}
if (isScrolling.value) {
checkHeaderIsHidden(
directions.top,
directions.bottom,
arrivedState.top,
);
}
},
);
}
function handleClickMask() {
sideCollapse.value = true;
}
function handleToggleMenu() {
// sideVisible.value = !sideVisible.value;
// sideHidden.value = !sideHidden.value;
}
function handleOpenMenu() {
sideCollapse.value = false;
}
</script>
<template>
<div class="relative flex min-h-full w-full">
<slot name="preferences"></slot>
<slot name="floating-button-group"></slot>
<LayoutSide
v-if="sideVisibleState"
v-model:collapse="sideCollapse"
v-model:extra-collapse="sideExtraCollapse"
v-model:expand-on-hovering="sideExpandOnHovering"
v-model:expand-on-hover="sideExpandOnHover"
v-model:extra-visible="sideExtraVisible"
:dom-visible="!isMobile"
:fixed-extra="sideExpandOnHover"
:mixed-width="sideMixedWidth"
:header-height="isMixedNav ? 0 : getHeaderHeight"
:collapse-width="getSideCollapseWidth"
:is-side-mixed="isSideMixedNav"
:padding-top="sidePaddingTop"
:show="showSide"
:extra-width="getExtraWidth"
:width="getSideWidth"
:z-index="sideZIndex"
v-bind="sideFace"
@leave="() => emit('sideMouseLeave')"
>
<template v-if="isSideMode && !isMixedNav" #logo>
<slot name="logo"></slot>
</template>
<template v-if="isSideMixedNav">
<slot name="mixed-menu"></slot>
</template>
<template v-else>
<slot name="menu"></slot>
</template>
<template #extra>
<slot name="side-extra"></slot>
</template>
<template #extra-title>
<slot name="side-extra-title"></slot>
</template>
</LayoutSide>
<div
class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
>
<div
:style="headerWrapperStyle"
class="overflow-hidden transition-all duration-200"
>
<LayoutHeader
v-if="headerVisible"
:full-width="!isSideMode"
:height="getHeaderHeight"
:show="!fullContent && !headerHidden"
:side-hidden="sideHidden"
:show-toggle-btn="showHeaderToggleButton"
:width="mainStyle.width"
:is-mixed-nav="isMixedNav"
:is-mobile="isMobile"
:z-index="headerZIndex"
:side-width="sideWidth"
@toggle-menu="handleToggleMenu"
@open-menu="handleOpenMenu"
>
<template v-if="showHeaderLogo" #logo>
<slot name="logo"></slot>
</template>
<slot name="header"></slot>
</LayoutHeader>
<LayoutTabs v-if="tabsVisible" :height="tabsHeight" :style="tabsStyle">
<slot name="tabs"></slot>
<template #toolbar>
<slot name="tabs-toolbar"></slot>
</template>
</LayoutTabs>
</div>
<!-- </div> -->
<LayoutContent
class="transition-[margin-top] duration-200"
:style="contentStyle"
:content-compact="contentCompact"
:content-compact-width="contentCompactWidth"
:padding="contentPadding"
:padding-bottom="contentPaddingBottom"
:padding-left="contentPaddingLeft"
:padding-right="contentPaddingRight"
:padding-top="contentPaddingTop"
>
<slot name="content"></slot>
</LayoutContent>
<LayoutFooter
v-if="footerVisible"
:fixed="footerFixed"
:height="footerHeight"
:show="!fullContent"
:width="footerWidth"
:z-index="zIndex"
>
<slot name="footer"></slot>
</LayoutFooter>
</div>
<div
v-if="maskVisible"
class="fixed left-0 top-0 h-full w-full bg-[rgb(0_0_0_/_40%)] transition-[background-color] duration-200"
:style="maskStyle"
@click="handleClickMask"
></div>
</div>
</template>

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,3 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();

View File

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

View File

@@ -0,0 +1,52 @@
{
"name": "@vben-core/menu-ui",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/menu-ui"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/design": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.10.0",
"vue": "3.4.27"
}
}

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,3 @@
export { default as Menu } from './menu.vue';
export { default as MenuItem } from './menu-item.vue';
export { default as SubMenu } from './sub-menu.vue';

View File

@@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { MenuItemProps, MenuItemRegistered } from '../interface';
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
import { VbenIcon, VbenMenuBadge, VbenTooltip } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
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 isTopLevelMenuItem = computed(
() => parentMenu.value?.type.name === 'Menu',
);
const getCollapseShowTitle = 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="[
b(),
is('active', active),
is('disabled', disabled),
is('collapse-show-title', getCollapseShowTitle),
]"
role="menuitem"
@click.stop="handleClick"
>
<VbenTooltip v-if="showTooltip" side="right">
<template #trigger>
<div :class="[nsMenu.be('tooltip', 'trigger')]">
<VbenIcon :class="nsMenu.e('icon')" :icon="icon" fallback />
<slot></slot>
<span v-if="getCollapseShowTitle" :class="nsMenu.e('name')">
<slot name="title"></slot>
</span>
</div>
</template>
<slot name="title"></slot>
</VbenTooltip>
<div v-show="!showTooltip" :class="[e('content')]">
<VbenMenuBadge v-bind="props" />
<VbenIcon :class="nsMenu.e('icon')" :icon="icon" fallback />
<slot></slot>
<slot name="title"></slot>
</div>
</li>
</template>

View File

@@ -0,0 +1,350 @@
<script lang="ts" setup>
import type {
MenuItemClicked,
MenuItemRegistered,
MenuProps,
MenuProvider,
} from '../interface';
import {
type VNodeArrayChildren,
computed,
nextTick,
reactive,
ref,
toRef,
useSlots,
watch,
watchEffect,
} from 'vue';
import { IcRoundMoreHoriz } from '@vben-core/iconify';
import { isHttpUrl, useNamespace } from '@vben-core/toolkit';
import { UseResizeObserverReturn, useResizeObserver } from '@vueuse/core';
import {
createMenuContext,
createSubMenuContext,
useMenuStyle,
} from '../hooks';
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',
});
const emit = defineEmits<{
close: [string, string[]];
open: [string, string[]];
select: [string, string[]];
}>();
const { b, is } = useNamespace('menu');
const menuStyle = useMenuStyle();
const 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 defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
return (
props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
);
});
const getSlot = computed(() => {
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,
openMenu,
openedMenus,
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 timmer: ReturnType<typeof setTimeout> | null;
return () => {
timmer && clearTimeout(timmer);
timmer = 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;
}
function getActivePaths() {
const activeItem = activePath.value && items.value[activePath.value];
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
return [];
}
return activeItem.parentPaths;
}
// 默认展开菜单
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;
}
if (!isHttpUrl(path)) {
activePath.value = path;
}
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);
}
</script>
<template>
<ul
ref="menu"
:class="[
b(),
is(mode, true),
is(theme, true),
is('rounded', rounded),
is('collapse', collapse),
]"
: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 path="sub-menu-more" is-sub-menu-more>
<template #title>
<IcRoundMoreHoriz />
</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>

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,167 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import type { NormalMenuProps } from './normal-menu';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
interface Props extends NormalMenuProps {}
defineOptions({
name: 'NormalMenu',
});
withDefaults(defineProps<Props>(), {
activePath: '',
collapse: false,
menus: () => [],
theme: 'dark',
});
const emit = defineEmits<{
enter: [MenuRecordRaw];
select: [MenuRecordRaw];
}>();
const { b, e, is } = useNamespace('normal-menu');
function handleClick(menu: MenuRecordRaw) {
emit('select', menu);
}
function handleMouseenter(menu: MenuRecordRaw) {
emit('enter', menu);
}
</script>
<template>
<ul
:class="[
b(),
is('collapse', collapse),
is(theme, true),
is('rounded', rounded),
]"
>
<template v-for="menu in menus" :key="menu.path">
<li
:class="[e('item'), is('active', activePath === menu.path)]"
@click="handleClick(menu)"
@mouseenter="handleMouseenter(menu)"
>
<VbenIcon :class="e('icon')" :icon="menu.icon" fallback />
<span :class="e('name')"> {{ menu.name }}</span>
</li>
</template>
</ul>
</template>
<style lang="scss" scoped>
@import '@vben-core/design/global';
@include b('normal-menu') {
--menu-item-margin-y: 4px;
--menu-item-margin-x: 0px;
--menu-item-padding-y: 11px;
--menu-item-padding-x: 0px;
--menu-item-radius: 0px;
--menu-dark-background: 0deg 0% 100% / 10%;
// --menu-light-background: 240deg 5% 96%;
position: relative;
height: calc(100% - 4px);
@include is('rounded') {
--menu-item-radius: 6px;
--menu-item-margin-x: 8px;
}
@include is('dark') {
.#{$namespace}-normal-menu__item {
color: hsl(var(--color-dark-foreground) / 80%);
}
}
@include e('item') {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
// max-width: 64px;
// max-height: 64px;
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
margin: var(--menu-item-margin-y) var(--menu-item-margin-x);
color: hsl(var(--color-foreground) / 90%);
cursor: pointer;
border-radius: var(--menu-item-radius);
transition:
background 0.15s ease,
// color 0.15s ease,
padding 0.15s ease,
border-color 0.15s ease;
@include is('active') {
font-weight: 700;
color: hsl(var(--color-primary-foreground));
background-color: hsl(var(--color-primary));
.#{$namespace}-normal-menu__name {
color: hsl(var(--color-primary-foreground));
}
.#{$namespace}-normal-menu__icon {
color: hsl(var(--color-primary-foreground));
}
}
&:not(.is-active):hover {
color: hsl(var(--color-foreground));
background-color: hsl(var(--menu-dark-background));
}
&:hover {
.#{$namespace}-normal-menu__icon {
transform: scale(1.3);
}
}
}
@include is('dark') {
.#{$namespace}-normal-menu__item {
&:not(.is-active):hover {
color: hsl(var(--color-primary-foreground));
background-color: hsl(var(--menu-dark-background));
}
}
}
@include is('collapse') {
.#{$namespace}-normal-menu__name {
width: 0;
height: 0;
margin-top: 0;
overflow: hidden;
opacity: 0;
}
.#{$namespace}-normal-menu__icon {
font-size: 20px;
}
}
@include e('icon') {
max-height: 20px;
font-size: 20px;
transition: all 0.25s ease;
}
@include e('name') {
margin-top: 8px;
margin-bottom: 0;
font-size: 12px;
font-weight: 400;
transition: all 0.25s ease;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { MenuItemProps } from '../interface';
import { computed } from 'vue';
import {
IcRoundChevronRight,
IcRoundKeyboardArrowDown,
} from '@vben-core/iconify';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
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)
? IcRoundChevronRight
: IcRoundKeyboardArrowDown;
});
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"
/>
</div>
</template>

View File

@@ -0,0 +1,271 @@
<script lang="ts" setup>
import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
import type {
MenuItemRegistered,
MenuProvider,
SubMenuProps,
} from '../interface';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { VbenHoverCard } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
import {
createSubMenuContext,
useMenu,
useMenuContext,
useMenuStyle,
useSubMenuContext,
} from '../hooks';
import CollapseTransition from './collapse-transition.vue';
import SubMenuContent from './sub-menu-content.vue';
interface Props extends SubMenuProps {
isSubMenuMore?: boolean;
}
defineOptions({ name: 'SubMenu' });
const props = withDefaults(defineProps<Props>(), {
disabled: false,
isSubMenuMore: false,
});
const { parentMenu, parentPaths } = useMenu();
const { b, is } = useNamespace('sub-menu');
const nsMenu = useNamespace('menu');
const rootMenu = useMenuContext();
const subMenu = useSubMenuContext();
const subMenuStyle = useMenuStyle(subMenu);
const mouseInChild = ref(false);
const items = ref<MenuProvider['items']>({});
const subMenus = ref<MenuProvider['subMenus']>({});
const timer = ref<ReturnType<typeof setTimeout> | null>(null);
createSubMenuContext({
addSubMenu,
handleMouseleave,
level: (subMenu?.level ?? 0) + 1,
mouseInChild,
removeSubMenu,
});
const opened = computed(() => {
return rootMenu?.openedMenus.includes(props.path);
});
const isTopLevelMenuSubmenu = computed(
() => parentMenu.value?.type.name === 'Menu',
);
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
const rounded = computed(() => rootMenu?.props.rounded);
const currentLevel = computed(() => subMenu?.level ?? 0);
const isFirstLevel = computed(() => {
return currentLevel.value === 1;
});
const contentProps = computed((): HoverCardContentProps => {
const side =
mode.value === 'horizontal' && isFirstLevel.value ? 'bottom' : 'right';
return {
side,
sideOffset: isFirstLevel.value ? 5 : 10,
};
});
const active = computed(() => {
let isActive = false;
Object.values(items.value).forEach((item) => {
if (item.active) {
isActive = true;
}
});
Object.values(subMenus.value).forEach((subItem) => {
if (subItem.active) {
isActive = true;
}
});
return isActive;
});
function addSubMenu(subMenu: MenuItemRegistered) {
subMenus.value[subMenu.path] = subMenu;
}
function removeSubMenu(subMenu: MenuItemRegistered) {
Reflect.deleteProperty(subMenus.value, subMenu.path);
}
/**
* 点击submenu展开/关闭
*/
function handleClick() {
const mode = rootMenu?.props.mode;
if (
// 当前菜单禁用时,不展开
props.disabled ||
(rootMenu?.props.collapse && mode === 'vertical') ||
// 水平模式下不展开
mode === 'horizontal'
) {
return;
}
rootMenu?.handleSubMenuClick({
active: active.value,
parentPaths: parentPaths.value,
path: props.path,
});
}
function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
if (event.type === 'focus') {
return;
}
if (
(!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
props.disabled
) {
if (subMenu) {
subMenu.mouseInChild.value = true;
}
return;
}
if (subMenu) {
subMenu.mouseInChild.value = true;
}
timer.value && window.clearTimeout(timer.value);
timer.value = setTimeout(() => {
rootMenu?.openMenu(props.path, parentPaths.value);
}, showTimeout);
parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
}
function handleMouseleave(deepDispatch = false) {
if (
!rootMenu?.props.collapse &&
rootMenu?.props.mode === 'vertical' &&
subMenu
) {
subMenu.mouseInChild.value = false;
return;
}
timer.value && window.clearTimeout(timer.value);
if (subMenu) {
subMenu.mouseInChild.value = false;
}
timer.value = setTimeout(() => {
!mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
}, 300);
if (deepDispatch) {
subMenu?.handleMouseleave?.(true);
}
}
const item = reactive({
active,
parentPaths,
path: props.path,
});
onMounted(() => {
subMenu?.addSubMenu?.(item);
rootMenu?.addSubMenu?.(item);
});
onBeforeUnmount(() => {
subMenu?.removeSubMenu?.(item);
rootMenu?.removeSubMenu?.(item);
});
</script>
<template>
<li
:class="[
b(),
is('opened', opened),
is('active', active),
is('disabled', disabled),
]"
@focus="handleMouseenter"
@mouseenter="handleMouseenter"
@mouseleave="() => handleMouseleave()"
>
<template v-if="rootMenu.isMenuPopup">
<VbenHoverCard
:content-class="[
nsMenu.e('popup-container'),
is(rootMenu.theme, true),
opened ? '' : 'hidden',
]"
:content-props="contentProps"
:open="true"
:open-delay="0"
>
<template #trigger>
<SubMenuContent
:class="is('active', active)"
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
:level="currentLevel"
:path="path"
:icon="icon"
:is-menu-more="isSubMenuMore"
@click.stop="handleClick"
>
<template #title>
<slot name="title"></slot>
</template>
</SubMenuContent>
</template>
<div
:class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
@focus="(e) => handleMouseenter(e, 100)"
@mouseenter="(e) => handleMouseenter(e, 100)"
@mouseleave="() => handleMouseleave(true)"
>
<ul
:class="[nsMenu.b(), is('rounded', rounded)]"
:style="subMenuStyle"
>
<slot></slot>
</ul>
</div>
</VbenHoverCard>
</template>
<template v-else>
<SubMenuContent
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
:level="currentLevel"
:path="path"
:icon="icon"
:is-menu-more="isSubMenuMore"
:class="is('active', active)"
@click.stop="handleClick"
>
<slot name="content"></slot>
<template #title>
<slot name="title"></slot>
</template>
</SubMenuContent>
<CollapseTransition>
<ul
v-show="opened"
:class="[nsMenu.b(), is('rounded', rounded)]"
:style="subMenuStyle"
>
<slot></slot>
</ul>
</CollapseTransition>
</template>
</li>
</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 '../interface';
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,47 @@
import { computed, getCurrentInstance } from 'vue';
import { SubMenuProvider } from '../interface';
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,5 @@
import './styles/index.scss';
export * from './components/normal-menu';
export type * from './interface';
export { default as Menu } from './menu.vue';

View File

@@ -0,0 +1,135 @@
import type { MenuRecordBadgeRaw, ThemeModeType } from '@vben-core/typings';
import type { Ref } from 'vue';
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 dark
*/
theme?: ThemeModeType;
}
interface SubMenuProps extends MenuRecordBadgeRaw {
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 图标
*/
icon?: string;
/**
* @zh_CN submenu 名称
*/
path: string;
}
interface MenuItemProps extends MenuRecordBadgeRaw {
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 图标
*/
icon?: string;
/**
* @zh_CN menuitem 名称
*/
path: string;
}
interface MenuItemRegistered {
active: boolean;
parentPaths: string[];
path: string;
}
// export interface MenuItemClicked {
// name: 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>;
openMenu: (path: string, parentLinks: string[]) => void;
openedMenus: string[];
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,37 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { useForwardProps } from '@vben-core/shadcn-ui';
import { Menu } from './components';
import { MenuProps } from './interface';
import SubMenu from './sub-menu.vue';
interface Props extends MenuProps {
menus: MenuRecordRaw[];
}
defineOptions({
name: 'MenuView',
});
const props = withDefaults(defineProps<Props>(), {
collapse: false,
// theme: 'dark',
});
const forward = useForwardProps(props);
// const emit = defineEmits<{
// 'update:openKeys': [key: Key[]];
// 'update:selectedKeys': [key: Key[]];
// }>();
</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,508 @@
@import '@vben-core/design/global';
.#{$namespace}-menu__popup-container,
.#{$namespace}-menu {
--menu-title-width: 140px;
--menu-item-icon-width: 20px;
--menu-item-height: 38px;
--menu-item-padding-y: 26px;
--menu-item-padding-x: 12px;
--menu-item-popup-padding-y: 22px;
--menu-item-popup-padding-x: 12px;
--menu-item-margin-y: 4px;
--menu-item-margin-x: 0px;
--menu-item-collapse-padding-y: 25px;
--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;
--menu-dark-background: 0deg 0% 100% / 10%;
--menu-light-background: 192deg 1% 93%;
&.is-dark {
--menu-background-color: hsl(var(--color-menu-dark));
// --menu-submenu-opened-background-color: hsl(var(--color-menu-opened-dark));
--menu-item-background-color: var(--menu-background-color);
--menu-item-color: hsl(var(--color-dark-foreground) / 80%);
--menu-item-hover-color: hsl(var(--color-primary-foreground));
--menu-item-hover-background-color: hsl(var(--menu-dark-background));
--menu-item-active-color: hsl(var(--color-primary-foreground));
--menu-item-active-background-color: hsl(var(--color-primary));
--menu-submenu-hover-color: hsl(var(--color-dark-foreground));
--menu-submenu-hover-background-color: hsl(var(--menu-dark-background));
--menu-submenu-active-color: hsl(var(--color-dark-foreground));
--menu-submenu-active-background-color: transparent;
--menu-submenu-background-color: var(--menu-background-color);
}
&.is-light {
--menu-background-color: hsl(var(--color-menu));
// --menu-submenu-opened-background-color: hsl(var(--color-menu-opened));
--menu-item-background-color: var(--menu-background-color);
--menu-item-color: hsl(var(--color-foreground));
--menu-item-hover-color: var(--menu-item-color);
--menu-item-hover-background-color: hsl(var(--menu-light-background));
--menu-item-active-color: hsl(var(--color-primary-foreground));
--menu-item-active-background-color: hsl(var(--color-primary));
--menu-submenu-hover-color: hsl(var(--color-primary));
--menu-submenu-hover-background-color: hsl(var(--menu-light-background));
--menu-submenu-active-color: hsl(var(--color-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: 6px;
}
&.is-horizontal:not(.is-rounded) {
--menu-item-height: 60px;
--menu-item-radius: 0px;
}
&.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: var(--color-foreground);
--menu-item-hover-background-color: hsl(var(--menu-dark-background));
--menu-item-active-color: hsl(var(--color-foreground));
--menu-item-active-background-color: hsl(var(--menu-dark-background));
--menu-submenu-active-color: hsl(var(--color-foreground));
--menu-submenu-active-background-color: hsl(var(--menu-dark-background));
--menu-submenu-hover-color: hsl(var(--color-foreground));
--menu-submenu-hover-background-color: hsl(var(--menu-dark-background));
}
&.is-light {
--menu-item-active-color: hsl(var(--color-foreground));
--menu-item-active-background-color: hsl(var(--menu-light-background));
--menu-item-hover-background-color: hsl(var(--menu-light-background));
--menu-item-hover-color: hsl(var(--color-primary));
--menu-submenu-hover-color: hsl(var(--color-primary));
--menu-submenu-hover-background-color: hsl(var(--menu-light-background));
--menu-submenu-active-color: hsl(var(--color-foreground));
--menu-submenu-active-background-color: hsl(var(--menu-light-background));
}
}
}
@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);
text-decoration: none;
white-space: nowrap;
list-style: none;
cursor: pointer;
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;
@include 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.3);
}
}
&: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;
}
@include b('menu') {
position: relative;
box-sizing: border-box;
padding-left: 0;
margin: 0;
list-style: none;
background: hsl(var(--menu-background-color));
// 垂直菜单
@include 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 {
// background: var(--menu-submenu-opened-background-color);
// .#{$namespace}-sub-menu,
// .#{$namespace}-menu-item:not(.is-active),
// .#{$namespace}-sub-menu-content:not(.is-active) {
// background: var(--menu-submenu-opened-background-color);
// }
// }
& > .#{$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);
}
}
}
}
@include 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);
// }
// }
}
// 折叠菜单
@include 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(--color-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;
}
}
}
}
@include e('popup-container') {
max-width: 240px;
height: unset;
padding: 0;
background: var(--menu-background-color);
}
@include e('popup') {
padding: 4px 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);
}
}
@include e('icon') {
flex-shrink: 0;
// width: var(--menu-item-icon-width);
max-height: var(--menu-item-icon-width);
margin-right: 12px;
font-size: 20px;
text-align: center;
vertical-align: middle;
}
}
@include b('menu-item') {
fill: var(--menu-item-color);
stroke: var(--menu-item-color);
@include menu-item;
@include is(active) {
fill: var(--menu-item-active-color);
stroke: var(--menu-item-active-color);
@include menu-item-active;
}
@include e('content') {
display: inline-flex;
align-items: center;
width: 100%;
height: var(--menu-item-height);
}
@include 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);
}
}
@include b('sub-menu') {
padding-left: 0;
margin: 0;
list-style: none;
background: var(--menu-submenu-background-color);
fill: var(--menu-item-color);
stroke: var(--menu-item-color);
@include is('active') {
div[data-state='open'] > .#{$namespace}-sub-menu-content,
> .#{$namespace}-sub-menu-content {
color: var(--menu-submenu-active-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-submenu-active-background-color);
fill: var(--menu-submenu-active-color);
stroke: var(--menu-submenu-active-color);
}
}
}
@include b('sub-menu-content') {
height: var(--menu-item-height);
@include menu-item;
@include e('icon-arrow') {
position: absolute;
top: 50%;
right: 6px;
width: inherit;
margin-top: -8px;
margin-right: 0;
font-size: 16px;
font-weight: normal;
opacity: 1;
transition: transform 0.25s ease;
}
@include e('title') {
@include menu-title;
}
@include 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 {
color: var(--menu-submenu-hover-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-submenu-hover-background-color);
svg {
fill: var(--menu-submenu-hover-color);
}
}
}

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { computed } from 'vue';
import { VbenMenuBadge } from '@vben-core/shadcn-ui';
import { 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"
:path="menu.path"
:icon="menu.icon"
:badge="menu.badge"
:badge-type="menu.badgeType"
:badge-variants="menu.badgeVariants"
>
<template #title>{{ menu.name }}</template>
</MenuItem>
<SubMenuComp
v-else
:key="`${menu.path}_sub`"
:path="menu.path"
:icon="menu.icon"
>
<template #content>
<VbenMenuBadge
:badge="menu.badge"
:badge-type="menu.badgeType"
:badge-variants="menu.badgeVariants"
/>
</template>
<template #title>{{ menu.name }}</template>
<template v-for="childItem in menu.children || []" :key="childItem.path">
<SubMenu :menu="childItem" />
</template>
</SubMenuComp>
</template>

View File

@@ -0,0 +1,51 @@
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,3 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();

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": "#/components",
"utils": "#/lib/utils"
}
}

View File

@@ -0,0 +1,61 @@
{
"name": "@vben-core/shadcn-ui",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/shadcn-ui"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./*": {
"types": "./src/*.ts",
"development": "./src/*.ts",
"default": "./dist/*.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@radix-icons/vue": "^1.0.0",
"@vben-core/iconify": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.10.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"radix-vue": "^1.8.3",
"tailwind-merge": "^2.3.0",
"vue": "3.4.27",
"vue-sonner": "^1.1.2"
}
}

View File

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

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialog as AlertDialogRoot,
AlertDialogTitle,
} from '#/components/ui/alert-dialog';
interface Props {
cancelText?: string;
content?: string;
submitText?: string;
title?: string;
}
withDefaults(defineProps<Props>(), {
cancelText: '取消',
submitText: '确认',
});
const emits = defineEmits<{
cancel: [];
submit: [];
}>();
const openModal = defineModel<boolean>('open');
function handleSubmit() {
emits('submit');
openModal.value = false;
}
function handleCancel() {
emits('cancel');
openModal.value = false;
}
</script>
<template>
<AlertDialogRoot v-model:open="openModal">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
<AlertDialogDescription>
{{ content }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="handleCancel">
{{ cancelText }}
</AlertDialogCancel>
<AlertDialogAction @click="handleSubmit">
{{ submitText }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenAlertDialog } from './alert-dialog.vue';

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type {
AvatarFallbackProps,
AvatarImageProps,
AvatarRootProps,
} from 'radix-vue';
import type { HTMLAttributes } from 'vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar';
interface Props extends AvatarRootProps, AvatarFallbackProps, AvatarImageProps {
alt?: string;
class?: HTMLAttributes['class'];
dot?: boolean;
dotClass?: HTMLAttributes['class'];
}
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
alt: 'avatar',
as: 'button',
dot: false,
dotClass: 'bg-green-500',
});
const text = computed(() => {
return props.alt.slice(0, 2).toUpperCase();
});
</script>
<template>
<div :class="props.class" class="relative flex-shrink-0">
<Avatar :class="props.class" class="size-full">
<AvatarImage :alt="alt" :src="src" />
<AvatarFallback>{{ text }}</AvatarFallback>
</Avatar>
<span
v-if="dot"
class="border-background absolute bottom-0 right-0 size-3 rounded-full border-2"
:class="dotClass"
>
</span>
</div>
</template>

View File

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

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { BacktopProps } from './backtop';
import { computed } from 'vue';
import { IcRoundArrowUpward } from '@vben-core/iconify';
import { VbenButton } from '../button';
import { useBackTop } from './use-backtop';
interface Props extends BacktopProps {}
defineOptions({ name: 'BackTop' });
const props = withDefaults(defineProps<Props>(), {
bottom: 40,
isGroup: false,
right: 40,
target: '',
visibilityHeight: 200,
});
const backTopStyle = computed(() => ({
bottom: `${props.bottom}px`,
right: `${props.right}px`,
}));
const { handleClick, visible } = useBackTop(props);
</script>
<template>
<transition name="fade-down">
<VbenButton
v-if="visible"
:style="backTopStyle"
class="bg-accent data fixed bottom-10 right-5 h-10 w-10 rounded-full"
size="icon"
variant="icon"
@click="handleClick"
>
<IcRoundArrowUpward />
</VbenButton>
</transition>
</template>

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,111 @@
<script lang="ts" setup>
import type { IBreadcrumb } from './interface';
import { VbenIcon } from '../icon';
interface Props {
breadcrumbs: IBreadcrumb[];
showIcon?: boolean;
}
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>
<ul class="flex">
<TransitionGroup name="breadcrumb-transition">
<template
v-for="(item, index) in breadcrumbs"
:key="`${item.path}-${item.title}-${index}`"
>
<li>
<a href="javascript:void 0" @click.stop="handleClick(item.path)">
<span class="flex-center h-full">
<VbenIcon
v-if="item.icon && showIcon"
class="mr-1 size-5 flex-shrink-0"
:icon="item.icon"
/>
<span
:class="{
'text-foreground font-normal':
index === breadcrumbs.length - 1,
}"
>{{ item.title }}
</span>
</span>
</a>
</li>
</template>
</TransitionGroup>
</ul>
</template>
<style scoped>
li {
@apply h-7;
}
li a {
@apply text-muted-foreground bg-accent relative mr-9 flex h-7 items-center py-0 pl-[5px] pr-2 text-[13px];
}
li a > span {
@apply -ml-3;
}
li:first-child a > span {
@apply -ml-1;
}
li:first-child a {
@apply rounded-[4px_0_0_4px] pl-[15px];
}
li:first-child a::before {
@apply border-none;
}
li:last-child a {
@apply rounded-[0_4px_4px_0] pr-[15px];
}
li:last-child a::after {
@apply border-none;
}
li a::before,
li a::after {
@apply border-accent absolute top-0 h-0 w-0 border-[14px] border-solid content-[''];
}
li a::before {
@apply -left-7 z-10 border-l-transparent;
}
li a::after {
@apply border-l-accent left-full border-transparent;
}
li:not(:last-child) a:hover {
@apply bg-accent-hover;
}
li:not(:last-child) a:hover::before {
@apply border-accent-hover border-l-transparent;
}
li:not(:last-child) a:hover::after {
@apply border-l-accent-hover;
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { IBreadcrumb } from './interface';
import { IcRoundKeyboardArrowDown } from '@vben-core/iconify';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '#/components/ui/breadcrumb';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu';
import { VbenIcon } from '../';
interface Props {
breadcrumbs: IBreadcrumb[];
showIcon?: boolean;
}
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="item.icon && showIcon"
class="size-5"
:icon="item.icon"
/>
{{ item.title }}
<IcRoundKeyboardArrowDown class="size-5" />
</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="item.icon && showIcon"
class="mr-1 size-4"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
/>
{{ item.title }}
</div>
</BreadcrumbLink>
<BreadcrumbPage v-else>
<div class="flex-center">
<VbenIcon
v-if="item.icon && showIcon"
class="mr-1 size-4"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
/>
{{ item.title }}
</div>
</BreadcrumbPage>
<BreadcrumbSeparator
v-if="index < breadcrumbs.length - 1 && !item.isHome"
/>
</BreadcrumbItem>
</template>
</TransitionGroup>
</BreadcrumbList>
</Breadcrumb>
</template>

View File

@@ -0,0 +1,4 @@
export { default as VbenBreadcrumb } from './breadcrumb.vue';
export { default as VbenBackgroundBreadcrumb } from './breadcrumb-background.vue';
export type * from './interface';

View File

@@ -0,0 +1,9 @@
interface IBreadcrumb {
icon?: string;
isHome?: boolean;
items?: IBreadcrumb[];
path?: string;
title?: string;
}
export type { IBreadcrumb };

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { computed } from 'vue';
import { MdiLoading } from '@vben-core/iconify';
import { Primitive, type PrimitiveProps } from 'radix-vue';
import { type ButtonVariants, buttonVariants } from '#/components/ui/button';
import { cn } from '#/lib/utils';
interface Props extends PrimitiveProps {
class?: HTMLAttributes['class'];
disabled?: boolean;
loading?: boolean;
size?: ButtonVariants['size'];
variant?: ButtonVariants['variant'];
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
class: '',
disabled: false,
loading: false,
size: 'default',
variant: 'default',
});
const isDisabled = computed(() => {
return props.disabled || props.loading;
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
:disabled="isDisabled"
>
<MdiLoading
v-if="loading"
class="text-md mr-2 flex-shrink-0 animate-spin"
/>
<slot></slot>
</Primitive>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { type HTMLAttributes, computed, useSlots } from 'vue';
import { type PrimitiveProps } from 'radix-vue';
import { VbenTooltip } from '#/components/tooltip';
import { ButtonVariants } from '#/components/ui/button';
import { cn } from '#/lib/utils';
import VbenButton from './button.vue';
interface Props extends PrimitiveProps {
class?: HTMLAttributes['class'];
disabled?: boolean;
onClick?: () => void;
tooltip?: string;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants['variant'];
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
onClick: () => {},
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 :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,2 @@
export { default as VbenButton } from './button.vue';
export { default as VbenIconButton } from './icon-button.vue';

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue';
import { useForwardPropsEmits } from 'radix-vue';
import { Checkbox } from '#/components/ui/checkbox';
const props = defineProps<
{
name: string;
} & CheckboxRootProps
>();
const emits = defineEmits<CheckboxRootEmits>();
const checked = defineModel<boolean>('checked');
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<Checkbox v-bind="forwarded" :id="name" v-model:checked="checked" />
<label :for="name" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
</template>

View File

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

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import type {
ContextMenuContentProps,
ContextMenuRootEmits,
ContextMenuRootProps,
} from 'radix-vue';
import type { IContextMenuItem } from './interface';
import type { HTMLAttributes } from 'vue';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '#/components/ui/context-menu';
const props = defineProps<
{
class?: HTMLAttributes['class'];
contentClass?: HTMLAttributes['class'];
contentProps?: ContextMenuContentProps;
handlerData?: Record<string, any>;
itemClass?: HTMLAttributes['class'];
menus: (data: any) => IContextMenuItem[];
} & ContextMenuRootProps
>();
const emits = defineEmits<ContextMenuRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
itemClass: _iCls,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const menusView = computed(() => {
return props.menus?.(props.handlerData);
});
function handleClick(menu: IContextMenuItem) {
if (menu.disabled) {
return;
}
menu?.handler?.(props.handlerData);
}
</script>
<template>
<ContextMenu v-bind="forwarded">
<ContextMenuTrigger as-child>
<slot></slot>
</ContextMenuTrigger>
<ContextMenuContent
:class="contentClass"
v-bind="contentProps"
class="side-content z-[1000]"
>
<template v-for="menu in menusView" :key="menu.key">
<ContextMenuItem
:inset="menu.inset || !menu.icon"
:disabled="menu.disabled"
class="cursor-pointer"
:class="itemClass"
@click="handleClick(menu)"
>
<component
:is="menu.icon"
v-if="menu.icon"
class="mr-1 w-6 text-lg"
/>
{{ menu.text }}
<ContextMenuShortcut v-if="menu.shortcut">
{{ menu.shortcut }}
</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator v-if="menu.separator" />
</template>
</ContextMenuContent>
</ContextMenu>
</template>

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,49 @@
<script lang="ts" setup>
import type {
DropdownMenuProps,
VbenDropdownMenuItem as IDropdownMenuItem,
} from './interface';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownMenu' });
const props = withDefaults(defineProps<Props>(), {});
function handleItemClick(menu: IDropdownMenuItem) {
if (menu.disabled) {
return;
}
menu?.handler?.(props);
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger class="flex h-full items-center gap-1">
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.key">
<DropdownMenuItem
:disabled="menu.disabled"
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
@click="handleItemClick(menu)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
</template>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import type { DropdownMenuProps } from './interface';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownRadioMenu' });
withDefaults(defineProps<Props>(), {});
const modelValue = defineModel<string>();
function handleItemClick(value: string) {
modelValue.value = value;
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger class="flex items-center gap-1" as-child>
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.key">
<DropdownMenuItem
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
:class="
menu.key === modelValue ? 'bg-accent text-accent-foreground' : ''
"
@click="handleItemClick(menu.key)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
<span
v-if="!menu.icon"
class="mr-2 size-1.5 rounded-full"
:class="menu.key === modelValue ? 'bg-foreground' : ''"
></span>
{{ menu.text }}
</DropdownMenuItem>
</template>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

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 唯一标识
*/
key: string;
/**
* @zh_CN 是否是分割线
*/
separator?: boolean;
/**
* @zh_CN 标题
*/
text: string;
}
interface DropdownMenuProps {
menus: VbenDropdownMenuItem[];
}
export type { DropdownMenuProps, VbenDropdownMenuItem };

View File

@@ -0,0 +1,46 @@
<script setup>
import { ref } from 'vue';
const isMenuOpen = ref(false);
const menuItems = ref(['1', '2', '3', '4']);
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
const handleMenuItemClick = (_item) => {
// console.log(111, item);
};
</script>
<template>
<div class="fixed bottom-5 right-5 flex flex-col-reverse items-center gap-2">
<button
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-xl text-white transition-transform duration-300"
:class="{ 'rotate-45': isMenuOpen }"
@click="toggleMenu"
>
</button>
<div
class="absolute bottom-16 right-0 flex flex-col-reverse gap-2 transition-all duration-300"
:class="{
'visible translate-y-0 opacity-100': isMenuOpen,
'invisible translate-y-2 opacity-0': !isMenuOpen,
}"
>
<button
v-for="(item, index) in menuItems"
:key="index"
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-xl text-white"
@click="handleMenuItemClick(item)"
>
{{ item }}
</button>
</div>
</div>
</template>
<style scoped>
/* 可以在这里添加任何需要的额外样式 */
</style>

View File

@@ -0,0 +1 @@
export { default as VbenFloatingButtonGroup } from './floating-button-group.vue';

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { IcRoundFullscreen, IcRoundFullscreenExit } from '@vben-core/iconify';
import { useFullscreen } from '@vueuse/core';
import { VbenIconButton } from '../button';
defineOptions({ name: 'FullScreen' });
const { isFullscreen, toggle } = useFullscreen();
// 重新检查全屏状态
isFullscreen.value = !!(
document.fullscreenElement ||
// @ts-ignore
document.webkitFullscreenElement ||
// @ts-ignore
document.mozFullScreenElement ||
// @ts-ignore
document.msFullscreenElement
);
</script>
<template>
<VbenIconButton @click="toggle">
<IcRoundFullscreenExit v-if="isFullscreen" class="size-6" />
<IcRoundFullscreen v-else class="size-6" />
</VbenIconButton>
</template>

View File

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

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { HoverCardRootEmits, HoverCardRootProps } from 'radix-vue';
import { HTMLAttributes, computed } from 'vue';
import { HoverCardContentProps, useForwardPropsEmits } from 'radix-vue';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '#/components/ui/hover-card';
const props = defineProps<
{
class?: HTMLAttributes['class'];
contentClass?: HTMLAttributes['class'];
contentProps?: HoverCardContentProps;
} & HoverCardRootProps
>();
const emits = defineEmits<HoverCardRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<HoverCard v-bind="forwarded">
<HoverCardTrigger as-child class="h-full">
<div class="h-full cursor-pointer">
<slot name="trigger"></slot>
</div>
</HoverCardTrigger>
<HoverCardContent
:class="contentClass"
v-bind="contentProps"
class="side-content z-[1000]"
>
<slot></slot>
</HoverCardContent>
</HoverCard>
</template>

View File

@@ -0,0 +1,2 @@
export { default as VbenHoverCard } from './hover-card.vue';
export type { HoverCardContentProps } from 'radix-vue';

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { type Component, computed } from 'vue';
import { Icon, IconDefault } from '@vben-core/iconify';
import { isHttpUrl, isObject, isString } from '@vben-core/toolkit';
const props = defineProps<{
// 没有是否显示默认图标
fallback?: boolean;
icon?: Component | string;
}>();
const isRemoteIcon = computed(() => {
return isString(props.icon) && isHttpUrl(props.icon);
});
const isComponent = computed(() => {
return !isString(props.icon) && isObject(props.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" />
<Icon 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,42 @@
// 修改过的button
export * from './alert-dialog';
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
export * from './button';
export * from './checkbox';
export * from './context-menu';
export * from './dropdown-menu';
export * from './floating-button-group';
export * from './full-screen';
export * from './hover-card';
export * from './icon';
export * from './input';
export * from './input-password';
export * from './logo';
export * from './menu-badge';
export * from './pin-input';
export * from './popover';
export * from './segmented';
export * from './sheet';
export * from './tooltip';
export * from './ui/alert-dialog';
export * from './ui/avatar';
export * from './ui/badge';
export * from './ui/breadcrumb';
export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/dialog';
export * from './ui/dropdown-menu';
export * from './ui/hover-card';
export * from './ui/pin-input';
export * from './ui/popover';
export * from './ui/scroll-area';
export * from './ui/select';
export * from './ui/sheet';
export * from './ui/sonner';
export * from './ui/switch';
export * from './ui/tabs';
export * from './ui/toggle';
export * from './ui/toggle-group';
export * from './ui/tooltip';

View File

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

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref, useSlots } from 'vue';
import {
IcOutlineVisibility,
IcOutlineVisibilityOff,
} from '@vben-core/iconify';
import { useForwardProps } from 'radix-vue';
import { type InputProps, VbenInput } from '#/components/input/index';
import PasswordStrength from './password-strength.vue';
interface Props extends InputProps {}
defineOptions({
inheritAttrs: false,
});
const props = defineProps<Props>();
const modelValue = defineModel<string>();
const slots = useSlots();
const forward = useForwardProps(props);
const show = ref(false);
</script>
<template>
<form class="relative">
<VbenInput
v-model="modelValue"
v-bind="{ ...forward, ...$attrs }"
:type="show ? 'text' : 'password'"
>
<template v-if="passwordStrength">
<PasswordStrength :password="modelValue" />
<p
v-if="slots.strengthText"
class="text-muted-foreground mt-1.5 text-xs"
>
<slot name="strengthText"> </slot>
</p>
</template>
</VbenInput>
<div
class="hover:text-foreground text-foreground/60 absolute inset-y-0 right-0 top-3 flex cursor-pointer pr-3 text-lg leading-5"
@click="show = !show"
>
<IcOutlineVisibility v-if="show" />
<IcOutlineVisibilityOff v-else />
</div>
</form>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ password?: string }>(), {
password: '',
});
const strengthList: string[] = [
'',
'#e74242',
'#ED6F6F',
'#EFBD47',
'#55D18780',
'#55D187',
];
const currentStrength = computed(() => {
return checkPasswordStrength(props.password);
});
const currentColor = computed(() => {
return strengthList[currentStrength.value];
});
/**
* Check the strength of a password
*/
function checkPasswordStrength(password: string) {
let strength = 0;
// Check length
if (password.length >= 8) strength++;
// Check for lowercase letters
if (/[a-z]/.test(password)) strength++;
// Check for uppercase letters
if (/[A-Z]/.test(password)) strength++;
// Check for numbers
if (/\d/.test(password)) strength++;
// Check for special characters
if (/[^\da-z]/i.test(password)) strength++;
return strength;
}
</script>
<template>
<div class="relative mt-2 flex items-center justify-between">
<template v-for="index in 5" :key="index">
<div
class="dark:bg-input-background bg-heavy relative mr-1 h-1.5 w-1/5 rounded-sm last:mr-0"
>
<span
class="absolute left-0 h-full w-0 rounded-sm transition-all duration-500"
:style="{
backgroundColor: currentColor,
width: currentStrength >= index ? '100%' : '',
}"
></span>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as VbenInput } from './input.vue';
export type * from './interface';

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { InputProps } from './interface';
import { computed } from 'vue';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<InputProps>();
const modelValue = defineModel<number | string>();
const inputClass = computed(() => {
if (props.status === 'error') {
return 'border-destructive';
}
return '';
});
</script>
<template>
<div class="relative mb-6">
<label
v-if="!label"
:for="name"
class="mb-2 block text-sm font-medium dark:text-white"
>
{{ label }}
</label>
<input
:id="name"
v-model="modelValue"
:class="[props.class, inputClass]"
class="border-input bg-input-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring focus:border-primary flex h-10 w-full rounded-md border p-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
autocomplete="off"
required
type="text"
v-bind="$attrs"
/>
<slot></slot>
<Transition name="slide-up">
<p
v-if="status === 'error'"
class="text-destructive bottom-130 absolute mt-1 text-xs"
>
{{ errorTip }}
</p>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,27 @@
import type { HTMLAttributes } from 'vue';
interface InputProps {
class?: HTMLAttributes['class'];
/**
* 错误提示信息
*/
errorTip?: string;
/**
* 输入框的 label
*/
label?: string;
/**
* 输入框的 name
*/
name?: string;
/**
* 是否显示密码强度
*/
passwordStrength?: boolean;
/**
* 输入框的校验状态
*/
status?: 'default' | 'error';
}
export type { InputProps };

View File

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

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/**
* Logo 图标 alt
*/
alt?: string;
/**
* 是否收起文本
*/
collapse?: boolean;
/**
* Logo 跳转地址
*/
href?: string;
/**
* Logo 图片大小
*/
logoSize?: number;
/**
* Logo 图标
*/
src?: string;
/**
* Logo 文本
*/
text?: string;
/**
* Logo 主题
*/
theme?: string;
}
defineOptions({
name: 'Logo',
});
const props = withDefaults(defineProps<Props>(), {
alt: 'Vben',
collapse: false,
href: 'javascript:void 0',
logoSize: 36,
src: '',
text: '',
theme: 'light',
});
const logoClass = computed(() => {
return [props.theme, props.collapse ? 'collapsed' : ''];
});
</script>
<template>
<div class="group flex h-full items-center text-lg" :class="logoClass">
<a
:href="href"
class="flex h-full items-center gap-2 overflow-hidden px-3 font-semibold leading-normal transition-all duration-500"
:class="$attrs.class"
>
<img
v-if="src"
:src="src"
:alt="alt"
:width="logoSize"
class="relative rounded-none bg-transparent"
/>
<span
v-if="!collapse"
class="text-primary truncate text-nowrap group-[.dark]:text-[hsl(var(--color-dark-foreground))]"
>
{{ text }}
<!-- <span class="text-primary ml-1 align-super text-[smaller]">Pro</span> -->
</span>
</a>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenMenuBadge } from './menu-badge.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="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
:class="dotClass"
:style="dotStyle"
>
</span>
<span
class="relative inline-flex size-1.5 rounded-full"
:class="dotClass"
:style="dotStyle"
></span>
</span>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { MenuRecordBadgeRaw } from '@vben-core/typings';
import { computed } from 'vue';
import { isValidColor } from '@vben-core/toolkit';
import BadgeDot from './menu-badge-dot.vue';
interface Props extends MenuRecordBadgeRaw {
hasChildren?: boolean;
}
const props = withDefaults(defineProps<Props>(), {});
const variantsMap: Record<string, string> = {
default: 'bg-green-500',
destructive: ' bg-destructive',
primary: 'bg-primary',
success: ' bg-green-500',
warning: ' bg-yellow-500',
};
const isDot = computed(() => props.badgeType === 'dot');
const badgeClass = computed(() => {
const { badgeVariants } = props;
if (!badgeVariants) {
return variantsMap.default;
}
return variantsMap[badgeVariants] || badgeVariants;
});
const badgeStyle = computed(() => {
if (badgeClass.value && isValidColor(badgeClass.value)) {
return {
backgroundColor: badgeClass.value,
};
}
return {};
});
</script>
<template>
<span v-if="isDot || badge" class="absolute right-5" :class="$attrs.class">
<BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" />
<div
v-else
:class="badgeClass"
:style="badgeStyle"
class="rounded-md px-1.5 py-0.5 text-xs"
>
{{ badge }}
</div>
</span>
</template>

View File

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

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { PinInputProps } from './interface';
import { computed, ref, watch } from 'vue';
import { VbenButton } from '#/components/button';
import {
PinInput,
PinInputGroup,
PinInputInput,
} from '#/components/ui/pin-input';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<PinInputProps>(), {
btnLoading: false,
codeLength: 6,
handleSendCode: async () => {},
});
const emit = defineEmits<{
complete: [];
}>();
const modelValue = defineModel<string>();
const inputValue = ref<string[]>([]);
const inputClass = computed(() => {
if (props.status === 'error') {
return 'border-destructive';
}
return '';
});
watch(
() => modelValue.value,
() => {
inputValue.value = modelValue.value?.split('') ?? [];
},
);
function handleComplete(e: string[]) {
modelValue.value = e.join('');
emit('complete');
}
</script>
<template>
<div class="relative mb-6">
<label :for="name" class="mb-2 block text-sm font-medium">
{{ label }}
</label>
<PinInput
:id="name"
v-model="inputValue"
otp
placeholder="○"
class="flex justify-between"
:class="inputClass"
type="number"
@complete="handleComplete"
>
<PinInputGroup>
<PinInputInput
v-for="(id, index) in codeLength"
:key="id"
:index="index"
/>
</PinInputGroup>
<VbenButton
class="w-[300px] xl:w-full"
size="lg"
variant="outline"
:loading="btnLoading"
@click="handleSendCode"
>
{{ btnText }}
</VbenButton>
</PinInput>
<p
v-if="status === 'error'"
class="text-destructive bottom-130 absolute mt-1 text-xs"
>
{{ errorTip }}
</p>
</div>
</template>

View File

@@ -0,0 +1,40 @@
import type { HTMLAttributes } from 'vue';
interface PinInputProps {
/**
* 发送验证码按钮loading
*/
btnLoading?: boolean;
/**
* 发送验证码按钮文本
*/
btnText?: string;
class?: HTMLAttributes['class'];
/**
* 验证码长度
*/
codeLength?: number;
/**
* 错误提示信息
*/
errorTip?: string;
/**
* 自定义验证码发送逻辑
* @returns
*/
handleSendCode?: () => Promise<void>;
/**
* 输入框的 label
*/
label: string;
/**
* 输入框的 name
*/
name: string;
/**
* 输入框的校验状态
*/
status?: 'default' | 'error';
}
export type { PinInputProps };

View File

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

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type {
PopoverContentProps,
PopoverRootEmits,
PopoverRootProps,
} from 'radix-vue';
import { HTMLAttributes, computed } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
import {
PopoverContent,
Popover as PopoverRoot,
PopoverTrigger,
} from '#/components/ui/popover';
const props = withDefaults(
defineProps<
{
class?: HTMLAttributes['class'];
contentClass?: HTMLAttributes['class'];
contentProps?: PopoverContentProps;
} & PopoverRootProps
>(),
{},
);
const emits = defineEmits<PopoverRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<PopoverRoot v-bind="forwarded">
<PopoverTrigger>
<slot name="trigger"></slot>
<PopoverContent
:class="contentClass"
class="side-content z-[1000]"
v-bind="contentProps"
>
<slot></slot>
</PopoverContent>
</PopoverTrigger>
</PopoverRoot>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { SegmentedItem } from './interface';
import { computed } from 'vue';
import { TabsTrigger } from 'radix-vue';
import { Tabs, TabsContent, TabsList } from '#/components/ui/tabs';
import TabsIndicator from './tabs-indicator.vue';
interface Props {
defaultValue?: string;
tabs: SegmentedItem[];
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: '',
tabs: () => [],
});
const getDefaultValue = computed(() => {
return props.defaultValue || props.tabs[0]?.value;
});
const tabsStyle = computed(() => {
return {
'grid-template-columns': `repeat(${props.tabs.length}, minmax(0, 1fr))`,
};
});
const tabsIndicatorStyle = computed(() => {
return {
width: `${(100 / props.tabs.length).toFixed(0)}%`,
};
});
</script>
<template>
<Tabs :default-value="getDefaultValue">
<TabsList :style="tabsStyle" class="bg-accent relative grid w-full">
<TabsIndicator :style="tabsIndicatorStyle" />
<template v-for="tab in tabs" :key="tab.value">
<TabsTrigger
:value="tab.value"
class="z-20 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium disabled:pointer-events-none disabled:opacity-50"
>
{{ tab.label }}
</TabsTrigger>
</template>
</TabsList>
<template v-for="tab in tabs" :key="tab.value">
<TabsContent :value="tab.value">
<slot :name="tab.value"></slot>
</TabsContent>
</template>
</Tabs>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue';
import {
TabsIndicator,
type TabsIndicatorProps,
useForwardProps,
} from 'radix-vue';
import { cn } from '#/lib/utils';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & TabsIndicatorProps
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<TabsIndicator
v-bind="forwardedProps"
:class="
cn(
'absolute bottom-0 left-0 z-10 h-full w-1/2 translate-x-[--radix-tabs-indicator-position] rounded-full px-0 py-1 pr-1 transition-[width,transform] duration-300',
props.class,
)
"
>
<div
class="bg-background text-foreground inline-flex h-full w-full items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<slot></slot>
</div>
</TabsIndicator>
</template>

View File

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

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { Cross2Icon } from '@radix-icons/vue';
import { VbenButton, VbenIconButton } from '#/components/button';
import { ScrollArea } from '#/components/ui/scroll-area';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '#/components/ui/sheet';
interface Props {
cancelText?: string;
description?: string;
showFooter?: boolean;
submitText?: string;
title?: string;
width?: number;
}
const props = withDefaults(defineProps<Props>(), {
cancelText: '关闭',
description: '',
showFooter: false,
submitText: '确认',
title: '',
width: 400,
});
const emits = defineEmits<{
cancel: [];
submit: [];
}>();
const openModal = defineModel<boolean>('open');
const slots = useSlots();
const contentStyle = computed(() => {
return {
width: `${props.width}px`,
};
});
function handlerSubmit() {
emits('submit');
openModal.value = false;
}
// function handleCancel() {
// emits('cancel');
// openModal.value = false;
// }
</script>
<template>
<Sheet v-model:open="openModal">
<SheetTrigger>
<slot name="trigger"></slot>
</SheetTrigger>
<SheetContent :style="contentStyle" class="!w-full pb-12 sm:rounded-l-lg">
<SheetHeader
:class="description ? 'h-16' : 'h-12'"
class="border-border flex flex-row items-center justify-between border-b pl-5 pr-3"
>
<div class="flex w-full items-center justify-between">
<div>
<SheetTitle class="text-left text-lg">{{ title }}</SheetTitle>
<SheetDescription class="text-muted-foreground text-xs">
{{ description }}
</SheetDescription>
</div>
<slot v-if="slots.extra" name="extra"></slot>
</div>
<SheetClose
as-child
class="data-[state=open]:bg-secondary cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<VbenIconButton>
<Cross2Icon class="size-4" />
</VbenIconButton>
</SheetClose>
</SheetHeader>
<div class="h-full pb-16">
<ScrollArea class="h-full">
<slot></slot>
</ScrollArea>
</div>
<SheetFooter v-if="showFooter || slots.footer" as-child>
<div
class="border-border absolute bottom-0 flex h-12 w-full items-center justify-end border-t"
>
<slot v-if="slots.footer" name="footer"></slot>
<template v-else>
<SheetClose as-child>
<VbenButton class="mr-2" variant="outline">
{{ cancelText }}
</VbenButton>
</SheetClose>
<VbenButton @click="handlerSubmit">{{ submitText }}</VbenButton>
</template>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</template>

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