feat: add menu component (#534)

pull/581/head
Ryan Wang 2022-04-12 14:23:14 +08:00 committed by GitHub
parent 4e3f11829b
commit bd446449c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 920 additions and 249 deletions

View File

@ -15,6 +15,7 @@ module.exports = {
}, },
rules: { rules: {
"vue/multi-word-component-names": 0, "vue/multi-word-component-names": 0,
"@typescript-eslint/ban-ts-comment": 0,
}, },
overrides: [ overrides: [
{ {

View File

@ -17,37 +17,39 @@
"story:build": "histoire build" "story:build": "histoire build"
}, },
"dependencies": { "dependencies": {
"pinia": "^2.0.12", "pinia": "^2.0.13",
"vue": "^3.2.31", "vue": "^3.2.31",
"vue-router": "^4.0.14" "vue-router": "^4.0.14"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/ri": "^1.1.1",
"@rushstack/eslint-patch": "^1.1.1", "@rushstack/eslint-patch": "^1.1.1",
"@types/jsdom": "^16.2.14", "@types/jsdom": "^16.2.14",
"@types/node": "^16.11.26", "@types/node": "^16.11.26",
"@vitejs/plugin-vue": "^2.2.4", "@vitejs/plugin-vue": "^2.3.1",
"@vitejs/plugin-vue-jsx": "^1.3.8", "@vitejs/plugin-vue-jsx": "^1.3.9",
"@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0", "@vue/eslint-config-typescript": "^10.0.0",
"@vue/test-utils": "^2.0.0-rc.18", "@vue/test-utils": "^2.0.0-rc.18",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.4", "autoprefixer": "^10.4.4",
"c8": "^7.11.0", "c8": "^7.11.0",
"cypress": "^9.5.2", "cypress": "^9.5.3",
"eslint": "^8.11.0", "eslint": "^8.12.0",
"eslint-plugin-cypress": "^2.12.1", "eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^8.5.0", "eslint-plugin-vue": "^8.5.0",
"histoire": "^0.2.0", "histoire": "^0.2.1",
"husky": "^7.0.4", "husky": "^7.0.4",
"jsdom": "^19.0.0", "jsdom": "^19.0.0",
"postcss": "^8.4.12", "postcss": "^8.4.12",
"prettier": "^2.6.0", "prettier": "^2.6.1",
"sass": "^1.49.9", "sass": "^1.49.10",
"start-server-and-test": "^1.14.0", "start-server-and-test": "^1.14.0",
"tailwindcss": "^3.0.23", "tailwindcss": "^3.0.23",
"tailwindcss-themeable": "^1.3.0", "tailwindcss-themeable": "^1.3.0",
"typescript": "~4.5.5", "typescript": "~4.5.5",
"vite": "^2.8.6", "unplugin-icons": "^0.14.1",
"vite": "^2.9.1",
"vitest": "^0.5.9", "vitest": "^0.5.9",
"vue-tsc": "^0.31.4" "vue-tsc": "^0.31.4"
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,74 @@
<script lang="ts" setup> <script lang="ts" setup>
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import { VMenu, VMenuItem, VMenuLabel } from "@/components/base/menu";
import { IconDashboard } from "@/core/icons";
</script> </script>
<template> <template>
<RouterView /> <div class="flex">
<div class="navbar h-screen w-72 p-4" style="background: #fff">
<VMenu :open-ids="['dashboard']">
<VMenuLabel>首页</VMenuLabel>
<VMenuItem id="dashboard" title="仪表盘">
<template #icon>
<Component :is="IconDashboard" />
</template> </template>
<VMenuItem title="子菜单">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
<VMenuItem title="子菜单">
<template #icon>
<Component :is="IconDashboard" />
</template>
<VMenuItem title="子菜单">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
</VMenuItem>
<VMenuItem title="子菜单">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
</VMenuItem>
<VMenuItem title="仪表盘">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
<VMenuItem title="仪表盘">
<template #icon>
<Component :is="IconDashboard" />
</template>
<VMenuItem title="子菜单1">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
<VMenuItem title="子菜单1">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
<VMenuItem title="子菜单1">
<template #icon>
<Component :is="IconDashboard" />
</template>
</VMenuItem>
</VMenuItem>
</VMenu>
</div>
<div class="flex-1">
<RouterView />
</div>
</div>
</template>
<style lang="scss">
body {
background: #eff4f9;
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<Story title="Menu">
<Variant title="Playground">
<template #default>
<VMenu>
<VMenuItem>
<template #icon>
<Component :is="IconDashboard" />
</template>
仪表盘
</VMenuItem>
</VMenu>
</template>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { VMenu, VMenuItem } from "./index";
import { IconDashboard } from "@/core/icons";
</script>

View File

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PropType } from "vue";
import { provide } from "vue";
const props = defineProps({
openIds: {
type: Object as PropType<string[]>,
required: false,
},
});
provide<string[] | undefined>("openIds", props.openIds);
</script>
<template>
<div class="menu-container w-full h-full">
<ul>
<slot />
</ul>
</div>
</template>

View File

@ -0,0 +1,121 @@
<script lang="ts" setup>
import { IconArrowRight } from "@/core/icons";
import { computed, inject, ref, useSlots } from "vue";
const props = defineProps({
id: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
});
const emit = defineEmits(["select"]);
const slots = useSlots();
const open = ref(false);
const openIds = inject<string[] | undefined>("openIds");
if (openIds?.includes(props.id)) {
open.value = true;
}
const hasSubmenus = computed(() => {
return slots.default;
});
function handleClick() {
if (hasSubmenus.value) {
open.value = !open.value;
return;
}
emit("select", props.id);
}
</script>
<template>
<li
class="menu-item"
:class="{ 'has-submenus': hasSubmenus }"
@click.stop="handleClick"
>
<div class="menu-item-title">
<span v-if="$slots.icon" class="menu-icon self-center mr-3">
<slot name="icon" />
</span>
<span class="menu-title self-center flex-1">
{{ title }}
</span>
<span
v-if="$slots.default"
:class="{ open }"
class="menu-icon-collapse self-center transition-all"
>
<IconArrowRight />
</span>
</div>
<Transition name="submenus-show">
<ul v-show="$slots.default && open" class="sub-menu-items transition-all">
<slot />
</ul>
</Transition>
</li>
</template>
<style lang="scss">
.menu-item {
@apply cursor-pointer;
}
.menu-item-title {
@apply transition-all;
@apply text-base;
@apply flex;
@apply select-none;
@apply relative;
border-radius: 4px;
padding: 8px 10px;
&:hover,
&.active {
background: #f4f5f7;
@apply font-medium;
}
&.active::after {
@apply absolute;
top: calc(50% - 13px);
left: -8px;
width: 3px;
height: 26px;
content: "";
background: #242e41;
border-radius: 6px;
}
}
.menu-icon-collapse.open {
transform: rotate(90deg);
}
.submenus-show-enter-active {
transition: all 0.1s ease-out;
}
.submenus-show-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}
.submenus-show-enter-from,
.submenus-show-enter-to {
transform: translateY(-10px);
opacity: 0;
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<li class="menu-label flex flex-col">
<slot />
</li>
</template>
<style lang="scss">
.menu-label {
padding-top: 12px;
padding-bottom: 13px;
color: #847e7e;
font-size: 14px;
font-weight: 400;
}
</style>

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import { VMenu, VMenuItem } from "../index";
describe("Menu", () => {
it("should render", () => {
expect(VMenu).toBeDefined();
expect(VMenuItem).toBeDefined();
expect(mount(VMenu).html()).toMatchSnapshot();
expect(mount(VMenuItem).html()).toMatchSnapshot();
});
it("should work with sub menus", async () => {
const wrapper = await mount({
setup() {
return () => (
<VMenu openIds={["3"]}>
<VMenuItem id="1" title="Menu Item 1" />
<VMenuItem id="2" title="Menu Item 2" />
<VMenuItem id="3" title="Menu Item 3">
<VMenuItem key="4" title="Menu Item 4" />
</VMenuItem>
</VMenu>
);
},
});
expect(wrapper.html()).toMatchSnapshot();
// toggling sub menu
expect(
wrapper.find(".has-submenus .sub-menu-items").attributes().style
).toBeUndefined(); // visible
await wrapper.find(".has-submenus").trigger("click");
expect(
wrapper.find(".has-submenus .sub-menu-items").attributes().style
).toBe("display: none;");
await wrapper.find(".has-submenus").trigger("click");
expect(
wrapper.find(".has-submenus .sub-menu-items").attributes().style
).toBeUndefined(); // visible
});
it("should work with openIds prop", function () {
const wrapper = mount({
setup() {
return () => (
<VMenu openIds={["3"]}>
<VMenuItem id="1" title="Menu Item 1" />
<VMenuItem id="2" title="Menu Item 2" />
<VMenuItem id="3" title="Menu Item 3">
<VMenuItem key="4" title="Menu Item 4" />
</VMenuItem>
</VMenu>
);
},
});
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".sub-menu-items .menu-title").text()).contain(
"Menu Item 4"
);
});
it("should work with select emit", async () => {
const wrapper = mount({
setup() {
return () => (
<VMenu openIds={["3"]}>
<VMenuItem id="1" title="Menu Item 1" />
<VMenuItem id="2" title="Menu Item 2" />
<VMenuItem id="3" title="Menu Item 3">
<VMenuItem id="4" title="Menu Item 4" />
</VMenuItem>
</VMenu>
);
},
});
wrapper.findAllComponents(VMenuItem).forEach((item) => {
// has not sub menu
if (item.props().id === "1") {
item.trigger("click");
expect(item.emitted().select[0]).toEqual(["1"]);
}
// has sub menu
if (item.props().id === "3") {
item.trigger("click");
expect(item.emitted().select).toBeUndefined();
expect(item.vm.open).toBe(false);
item.trigger("click");
expect(item.vm.open).toBe(true);
}
});
});
});

View File

@ -0,0 +1,105 @@
// Vitest Snapshot v1
exports[`Menu > should render 1`] = `
"<div class=\\"menu-container w-full h-full\\">
<ul></ul>
</div>"
`;
exports[`Menu > should render 2`] = `
"<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\"></span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>"
`;
exports[`Menu > should work with openIds prop 1`] = `
"<div class=\\"menu-container w-full h-full\\">
<ul>
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 1</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 2</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
<li class=\\"menu-item has-submenus\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 3</span><span class=\\"open menu-icon-collapse self-center transition-all\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\"><path fill=\\"currentColor\\" d=\\"m13.172 12l-4.95-4.95l1.414-1.414L16 12l-6.364 6.364l-1.414-1.414z\\"></path></svg></span>
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\">
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 4</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
</ul>
</transition-stub>
</li>
</ul>
</div>"
`;
exports[`Menu > should work with sub menus 1`] = `
"<div class=\\"menu-container w-full h-full\\">
<ul>
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 1</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 2</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
<li class=\\"menu-item has-submenus\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 3</span><span class=\\"open menu-icon-collapse self-center transition-all\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\"><path fill=\\"currentColor\\" d=\\"m13.172 12l-4.95-4.95l1.414-1.414L16 12l-6.364 6.364l-1.414-1.414z\\"></path></svg></span>
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\">
<li class=\\"menu-item\\">
<div class=\\"menu-item-title\\">
<!--v-if--><span class=\\"menu-title self-center flex-1\\">Menu Item 4</span>
<!--v-if-->
</div>
<transition-stub>
<ul class=\\"sub-menu-items transition-all\\" style=\\"display: none;\\"></ul>
</transition-stub>
</li>
</ul>
</transition-stub>
</li>
</ul>
</div>"
`;

View File

@ -0,0 +1,3 @@
export { default as VMenu } from "./Menu.vue";
export { default as VMenuItem } from "./MenuItem.vue";
export { default as VMenuLabel } from "./MenuLabel.vue";

6
src/core/icons.ts Normal file
View File

@ -0,0 +1,6 @@
// @ts-ignore
import IconDashboard from "~icons/ri/dashboard-3-line";
// @ts-ignore
import IconArrowRight from "~icons/ri/arrow-right-s-line";
export { IconDashboard, IconArrowRight };

View File

@ -1,20 +1,10 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue"; import routesConfig from "@/router/routes.config";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: routesConfig,
{ scrollBehavior: () => ({ left: 0, top: 0 }),
path: "/",
name: "home",
component: HomeView,
},
{
path: "/about",
name: "about",
component: () => import("../views/AboutView.vue"),
},
],
}); });
export default router; export default router;

107
src/router/menus.config.ts Normal file
View File

@ -0,0 +1,107 @@
import { IconDashboard } from "@/core/icons";
declare interface MenuGroupType {
name?: string;
items: MenuItemType[];
}
declare interface MenuItemType {
name: string;
path: string;
icon?: string;
meta?: Record<string, unknown>;
children?: MenuItemType[];
}
export const menus: MenuGroupType[] = [
{
items: [
{
name: "仪表盘",
path: "/dashboard",
icon: IconDashboard,
},
],
},
{
name: "内容",
items: [
{
name: "文章",
path: "/posts",
icon: IconDashboard,
children: [
{
name: "分类",
path: "/categories",
icon: IconDashboard,
},
{
name: "标签",
path: "/tags",
icon: IconDashboard,
},
],
},
{
name: "页面",
path: "/sheets",
icon: IconDashboard,
},
{
name: "评论",
path: "/comment",
icon: IconDashboard,
},
{
name: "附件",
path: "/attachment",
icon: IconDashboard,
},
],
},
{
name: "外观",
items: [
{
name: "主题",
path: "/themes",
icon: IconDashboard,
},
{
name: "菜单",
path: "/menus",
icon: IconDashboard,
},
{
name: "可视化",
path: "/visual",
icon: IconDashboard,
},
],
},
{
name: "系统",
items: [
{
name: "插件",
path: "/plugins",
icon: IconDashboard,
},
{
name: "用户",
path: "/users",
icon: IconDashboard,
},
{
name: "设置",
path: "/settings",
icon: IconDashboard,
},
],
},
];
export type { MenuItemType, MenuGroupType };
export default menus;

View File

@ -0,0 +1,18 @@
import type { RouteRecordRaw } from "vue-router";
export const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: () => import("../views/HomeView.vue"),
children: [
{
path: "/about",
name: "about",
component: () => import("../views/AboutView.vue"),
},
],
},
];
export default routes;

View File

@ -3,10 +3,11 @@ import { fileURLToPath, URL } from "url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx"; import vueJsx from "@vitejs/plugin-vue-jsx";
import icons from "unplugin-icons/vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), vueJsx()], plugins: [vue(), vueJsx(), icons()],
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),