feat: add modal and drawer components and examples (#4229)

* feat: add modal component

* feat: add drawer component

* feat: apply new modal and drawer components to the layout

* chore: typo

* feat: add some unit tests
This commit is contained in:
Vben
2024-08-25 23:40:52 +08:00
committed by GitHub
parent edb55b1fc0
commit 20a3868594
96 changed files with 2700 additions and 743 deletions

View File

@@ -22,6 +22,7 @@
"dependencies": {
"@vben-core/layout-ui": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/tabs-ui": "workspace:*",
"@vben/constants": "workspace:*",

View File

@@ -12,17 +12,9 @@ import {
} from '@vben/icons';
import { $t } from '@vben/locales';
import { isWindowsOs } from '@vben/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@vben-core/shadcn-ui';
import { useVbenModal } from '@vben-core/popup-ui';
import { useMagicKeys, useToggle, whenever } from '@vueuse/core';
import { useMagicKeys, whenever } from '@vueuse/core';
import SearchPanel from './search-panel.vue';
@@ -38,12 +30,18 @@ const props = withDefaults(
},
);
const [open, toggleOpen] = useToggle();
const [Modal, modalApi] = useVbenModal({
onCancel() {
modalApi.close();
},
});
const open = modalApi.useStore((state) => state.isOpen);
const keyword = ref('');
const searchInputRef = ref<HTMLInputElement>();
function handleClose() {
open.value = false;
modalApi.close();
keyword.value = '';
}
@@ -51,7 +49,7 @@ const keys = useMagicKeys();
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
whenever(cmd!, () => {
if (props.enableShortcutKey) {
open.value = true;
modalApi.open();
}
});
@@ -75,6 +73,10 @@ const toggleKeydownListener = () => {
}
};
const toggleOpen = () => {
open.value ? modalApi.close() : modalApi.open();
};
watch(() => props.enableShortcutKey, toggleKeydownListener);
onMounted(() => {
@@ -88,67 +90,58 @@ onMounted(() => {
<template>
<div>
<Dialog :open="open">
<DialogTrigger as-child>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<Search
class="text-muted-foreground group-hover:text-foreground size-3 group-hover:opacity-100"
<Modal :fullscreen-button="false" class="w-[600px]" header-class="py-2">
<template #title>
<div class="flex items-center">
<Search class="text-muted-foreground mr-2 size-4" />
<input
ref="searchInputRef"
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm font-normal outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</DialogTrigger>
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[10%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogHeader>
<DialogTitle
class="border-border flex h-12 items-center gap-3 border-b px-5 font-normal"
>
<Search class="text-muted-foreground size-4" />
<input
ref="searchInputRef"
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<DialogFooter
class="text-muted-foreground border-border hidden flex-row rounded-b-2xl border-t px-4 py-2 text-xs sm:flex sm:justify-start sm:gap-x-4"
>
<div class="flex items-center">
</template>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<template #footer>
<div class="flex w-full justify-start text-xs">
<div class="mr-2 flex items-center">
<CornerDownLeft class="mr-1 size-3" />
{{ $t('widgets.search.select') }}
</div>
<div class="flex items-center">
<ArrowUp class="mr-2 size-3" />
<ArrowDown class="mr-2 size-3" />
<div class="mr-2 flex items-center">
<ArrowUp class="mr-1 size-3" />
<ArrowDown class="mr-1 size-3" />
{{ $t('widgets.search.navigate') }}
</div>
<div class="flex items-center">
<MdiKeyboardEsc class="mr-1 size-3" />
{{ $t('widgets.search.close') }}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
</Modal>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<Search
class="text-muted-foreground group-hover:text-foreground size-3 group-hover:opacity-100"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</div>
</template>

View File

@@ -217,14 +217,14 @@ onMounted(() => {
<template>
<VbenScrollbar>
<div class="!flex h-full justify-center px-4 sm:max-h-[450px]">
<div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
<!-- 无搜索结果 -->
<div
v-if="keyword && searchResults.length === 0"
class="text-muted-foreground text-center"
>
<SearchX class="mx-auto size-12" />
<p class="my-10 text-xs">
<SearchX class="mx-auto mt-4 size-12" />
<p class="mb-10 mt-6 text-xs">
{{ $t('widgets.search.noResults') }}
<span class="text-foreground text-sm font-medium">
"{{ keyword }}"

View File

@@ -1,12 +1,8 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben-core/popup-ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
VbenAvatar,
VbenButton,
VbenInputPassword,
@@ -28,28 +24,33 @@ interface RegisterEmits {
defineOptions({
name: 'LockScreenModal',
});
withDefaults(defineProps<Props>(), {
avatar: '',
text: '',
});
const emit = defineEmits<{
submit: RegisterEmits['submit'];
}>();
const [Modal] = useVbenModal({
onConfirm() {
handleSubmit();
},
});
const formState = reactive({
lockScreenPassword: '',
submitted: false,
});
const openModal = defineModel<boolean>('open');
const passwordStatus = computed(() => {
return formState.submitted && !formState.lockScreenPassword
? 'error'
: 'default';
});
function handleClose() {
openModal.value = false;
}
function handleSubmit() {
formState.submitted = true;
if (passwordStatus.value !== 'default') {
@@ -62,51 +63,40 @@ function handleSubmit() {
</script>
<template>
<div>
<Dialog :open="openModal">
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[20%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogDescription />
<DialogHeader>
<DialogTitle
class="border-border flex h-8 items-center px-5 font-normal"
>
{{ $t('widgets.lockScreen.title') }}
</DialogTitle>
</DialogHeader>
<div
class="mb-10 flex w-full flex-col items-center"
@keypress.enter.prevent="handleSubmit"
>
<div class="w-2/3">
<div class="ml-2 flex w-full flex-col items-center">
<VbenAvatar
:src="avatar"
class="size-24"
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
/>
<div class="text-foreground my-6 flex items-center font-medium">
{{ text }}
</div>
</div>
<VbenInputPassword
v-model="formState.lockScreenPassword"
:error-tip="$t('widgets.lockScreen.placeholder')"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
<VbenButton class="w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.screenButton') }}
</VbenButton>
<Modal
:footer="false"
:fullscreen-button="false"
:title="$t('widgets.lockScreen.title')"
>
<div
class="mb-10 flex w-full flex-col items-center px-10"
@keypress.enter.prevent="handleSubmit"
>
<div class="w-full">
<div class="ml-2 flex w-full flex-col items-center">
<VbenAvatar
:src="avatar"
class="size-20"
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
/>
<div class="text-foreground my-6 flex items-center font-medium">
{{ text }}
</div>
</div>
</DialogContent>
</Dialog>
</div>
<VbenInputPassword
v-model="formState.lockScreenPassword"
:error-tip="$t('widgets.lockScreen.placeholder')"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
<VbenButton class="w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.screenButton') }}
</VbenButton>
</div>
</div>
</Modal>
</template>

View File

@@ -15,7 +15,7 @@ const shortcutKeysGlobalSearch = defineModel<boolean>(
'shortcutKeysGlobalSearch',
);
const shortcutKeysLogout = defineModel<boolean>('shortcutKeysLogout');
const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
// const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
const shortcutKeysLockScreen = defineModel<boolean>('shortcutKeysLockScreen');
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
@@ -39,10 +39,10 @@ const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
{{ $t('preferences.shortcutKeys.logout') }}
<template #shortcut> {{ altView }} Q </template>
</SwitchItem>
<SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
<!-- <SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
{{ $t('preferences.shortcutKeys.preferences') }}
<template #shortcut> {{ altView }} , </template>
</SwitchItem>
</SwitchItem> -->
<SwitchItem v-model="shortcutKeysLockScreen" :disabled="!shortcutKeysEnable">
{{ $t('widgets.lockScreen.title') }}
<template #shortcut> {{ altView }} L </template>

View File

@@ -14,7 +14,7 @@ import type { SegmentedItem } from '@vben-core/shadcn-ui';
import { computed, ref } from 'vue';
import { Copy, RotateCw, Settings } from '@vben/icons';
import { Copy, RotateCw } from '@vben/icons';
import { $t, loadLocaleMessages } from '@vben/locales';
import {
clearPreferencesCache,
@@ -22,12 +22,12 @@ import {
resetPreferences,
usePreferences,
} from '@vben/preferences';
import { useVbenDrawer } from '@vben-core/popup-ui';
import {
useToast,
VbenButton,
VbenIconButton,
VbenSegmented,
VbenSheet,
} from '@vben-core/shadcn-ui';
import { useClipboard } from '@vueuse/core';
@@ -52,7 +52,6 @@ import {
Theme,
Widget,
} from './blocks';
import { useOpenPreferences } from './use-open-preferences';
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const { toast } = useToast();
@@ -134,9 +133,7 @@ const shortcutKeysGlobalSearch = defineModel<boolean>(
const shortcutKeysGlobalLogout = defineModel<boolean>(
'shortcutKeysGlobalLogout',
);
const shortcutKeysGlobalPreferences = defineModel<boolean>(
'shortcutKeysGlobalPreferences',
);
const shortcutKeysGlobalLockScreen = defineModel<boolean>(
'shortcutKeysGlobalLockScreen',
);
@@ -161,6 +158,8 @@ const {
} = usePreferences();
const { copy } = useClipboard();
const [Drawer] = useVbenDrawer();
const activeTab = ref('appearance');
const tabs = computed((): SegmentedItem[] => {
@@ -193,8 +192,6 @@ const showBreadcrumbConfig = computed(() => {
);
});
const { openPreferences } = useOpenPreferences();
async function handleCopy() {
await copy(JSON.stringify(diffPreference.value, null, 2));
@@ -225,21 +222,11 @@ async function handleReset() {
<template>
<div>
<VbenSheet
v-model:open="openPreferences"
<Drawer
:description="$t('preferences.subtitle')"
:title="$t('preferences.title')"
class="sm:max-w-sm"
>
<template #trigger>
<slot name="trigger">
<VbenButton
:title="$t('preferences.title')"
class="bg-primary flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</template>
<template #extra>
<div class="flex items-center">
<VbenIconButton
@@ -256,7 +243,7 @@ async function handleReset() {
</div>
</template>
<div class="p-4 pt-4">
<div class="p-1">
<VbenSegmented v-model="activeTab" :tabs="tabs">
<template #general>
<Block :title="$t('preferences.general')">
@@ -402,9 +389,6 @@ async function handleReset() {
v-model:shortcut-keys-global-search="shortcutKeysGlobalSearch"
v-model:shortcut-keys-lock-screen="shortcutKeysGlobalLockScreen"
v-model:shortcut-keys-logout="shortcutKeysGlobalLogout"
v-model:shortcut-keys-preferences="
shortcutKeysGlobalPreferences
"
/>
</Block>
</template>
@@ -433,6 +417,6 @@ async function handleReset() {
{{ $t('preferences.clearAndLogout') }}
</VbenButton>
</template>
</VbenSheet>
</Drawer>
</div>
</template>

View File

@@ -1,11 +1,18 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { loadLocaleMessages } from '@vben/locales';
import { Settings } from '@vben/icons';
import { $t, loadLocaleMessages } from '@vben/locales';
import { preferences, updatePreferences } from '@vben/preferences';
import { capitalizeFirstLetter } from '@vben/utils';
import { useVbenDrawer } from '@vben-core/popup-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
import PreferencesSheet from './preferences-sheet.vue';
import PreferencesDrawer from './preferences-drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: PreferencesDrawer,
});
/**
* preferences 转成 vue props
@@ -47,9 +54,18 @@ const listen = computed(() => {
});
</script>
<template>
<PreferencesSheet v-bind="attrs" v-on="listen">
<template #trigger>
<slot></slot>
</template>
</PreferencesSheet>
<div>
<Drawer v-bind="attrs" v-on="listen" />
<div @click="() => drawerApi.open()">
<slot>
<VbenButton
:title="$t('preferences.title')"
class="bg-primary flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</div>
</div>
</template>

View File

@@ -4,11 +4,12 @@ import type { AnyFunction } from '@vben/types';
import type { Component } from 'vue';
import { computed, ref } from 'vue';
import { LockKeyhole, LogOut, SwatchBook } from '@vben/icons';
import { LockKeyhole, LogOut } from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useLockStore } from '@vben/stores';
import { isWindowsOs } from '@vben/utils';
import { useVbenModal } from '@vben-core/popup-ui';
import {
Badge,
DropdownMenu,
@@ -18,7 +19,6 @@ import {
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
VbenAlertDialog,
VbenAvatar,
VbenIcon,
} from '@vben-core/shadcn-ui';
@@ -26,7 +26,6 @@ import {
import { useMagicKeys, whenever } from '@vueuse/core';
import { LockScreenModal } from '../lock-screen';
import { useOpenPreferences } from '../preferences';
interface Props {
/**
@@ -72,16 +71,18 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ logout: [] }>();
const openPopover = ref(false);
const openDialog = ref(false);
const openLock = ref(false);
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalPreferencesShortcutKey,
} = usePreferences();
const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
usePreferences();
const lockStore = useLockStore();
const { handleOpenPreference } = useOpenPreferences();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
});
const [LogoutModal, logoutModalApi] = useVbenModal({
onConfirm() {
handleSubmitLogout();
},
});
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
@@ -97,12 +98,8 @@ const enableShortcutKey = computed(() => {
return props.enableShortcutKey && preferences.shortcutKeys.enable;
});
const enablePreferencesShortcutKey = computed(() => {
return props.enableShortcutKey && globalPreferencesShortcutKey.value;
});
function handleOpenLock() {
openLock.value = true;
lockModalApi.open();
}
function handleSubmitLock({
@@ -110,18 +107,19 @@ function handleSubmitLock({
}: {
lockScreenPassword: string;
}) {
openLock.value = false;
lockModalApi.close();
lockStore.lockScreen(lockScreenPassword);
}
function handleLogout() {
// emit
openDialog.value = true;
logoutModalApi.open();
openPopover.value = false;
}
function handleSubmitLogout() {
emit('logout');
openDialog.value = false;
logoutModalApi.close();
}
if (enableShortcutKey.value) {
@@ -132,12 +130,6 @@ if (enableShortcutKey.value) {
}
});
whenever(keys['Alt+Comma']!, () => {
if (enablePreferencesShortcutKey.value) {
handleOpenPreference();
}
});
whenever(keys['Alt+KeyL']!, () => {
if (enableLockScreenShortcutKey.value) {
handleOpenLock();
@@ -147,21 +139,25 @@ if (enableShortcutKey.value) {
</script>
<template>
<LockScreenModal
<LockModal
v-if="preferences.widget.lockScreen"
v-model:open="openLock"
:avatar="avatar"
:text="text"
@submit="handleSubmitLock"
/>
<VbenAlertDialog
v-model:open="openDialog"
<LogoutModal
:cancel-text="$t('common.cancel')"
:content="$t('widgets.logoutTip')"
:submit-text="$t('common.confirm')"
:confirm-text="$t('common.confirm')"
:fullscreen-button="false"
:title="$t('common.prompt')"
@submit="handleSubmitLogout"
/>
centered
content-class="px-8 min-h-10"
footer-class="border-none mb-4 mr-4"
header-class="border-none"
>
{{ $t('widgets.logoutTip') }}
</LogoutModal>
<DropdownMenu>
<DropdownMenuTrigger>
@@ -205,17 +201,6 @@ if (enableShortcutKey.value) {
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="preferences.app.enablePreferences"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenPreference"
>
<SwatchBook class="mr-2 size-4" />
{{ $t('preferences.title') }}
<DropdownMenuShortcut v-if="enablePreferencesShortcutKey">
{{ altView }} ,
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
v-if="preferences.widget.lockScreen"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"