global-search.vue 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. <script setup lang="ts">
  2. import type { MenuRecordRaw } from '@vben/types';
  3. import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
  4. import {
  5. ArrowDown,
  6. ArrowUp,
  7. CornerDownLeft,
  8. MdiKeyboardEsc,
  9. Search,
  10. } from '@vben/icons';
  11. import { $t } from '@vben/locales';
  12. import { isWindowsOs } from '@vben/utils';
  13. import {
  14. Dialog,
  15. DialogContent,
  16. DialogDescription,
  17. DialogFooter,
  18. DialogHeader,
  19. DialogTitle,
  20. DialogTrigger,
  21. } from '@vben-core/shadcn-ui';
  22. import { useMagicKeys, useToggle, whenever } from '@vueuse/core';
  23. import SearchPanel from './search-panel.vue';
  24. defineOptions({
  25. name: 'GlobalSearch',
  26. });
  27. const props = withDefaults(
  28. defineProps<{ enableShortcutKey?: boolean; menus: MenuRecordRaw[] }>(),
  29. {
  30. enableShortcutKey: true,
  31. menus: () => [],
  32. },
  33. );
  34. const [open, toggleOpen] = useToggle();
  35. const keyword = ref('');
  36. const searchInputRef = ref<HTMLInputElement>();
  37. function handleClose() {
  38. open.value = false;
  39. keyword.value = '';
  40. }
  41. const keys = useMagicKeys();
  42. const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
  43. whenever(cmd!, () => {
  44. if (props.enableShortcutKey) {
  45. open.value = true;
  46. }
  47. });
  48. whenever(open, () => {
  49. nextTick(() => {
  50. searchInputRef.value?.focus();
  51. });
  52. });
  53. const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
  54. if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
  55. event.preventDefault();
  56. }
  57. };
  58. const toggleKeydownListener = () => {
  59. if (props.enableShortcutKey) {
  60. window.addEventListener('keydown', preventDefaultBrowserSearchHotKey);
  61. } else {
  62. window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
  63. }
  64. };
  65. watch(() => props.enableShortcutKey, toggleKeydownListener);
  66. onMounted(() => {
  67. toggleKeydownListener();
  68. onUnmounted(() => {
  69. window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
  70. });
  71. });
  72. </script>
  73. <template>
  74. <div>
  75. <Dialog :open="open">
  76. <DialogTrigger as-child>
  77. <div
  78. 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"
  79. @click="toggleOpen()"
  80. >
  81. <Search
  82. class="text-muted-foreground group-hover:text-foreground size-3 group-hover:opacity-100"
  83. />
  84. <span
  85. class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
  86. >
  87. {{ $t('widgets.search.title') }}
  88. </span>
  89. <span
  90. v-if="enableShortcutKey"
  91. 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"
  92. >
  93. {{ isWindowsOs() ? 'Ctrl' : '⌘' }}
  94. <kbd>K</kbd>
  95. </span>
  96. <span v-else></span>
  97. </div>
  98. </DialogTrigger>
  99. <DialogContent
  100. 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"
  101. @close="handleClose"
  102. >
  103. <DialogHeader>
  104. <DialogTitle
  105. class="border-border flex h-12 items-center gap-3 border-b px-5 font-normal"
  106. >
  107. <Search class="text-muted-foreground size-4" />
  108. <input
  109. ref="searchInputRef"
  110. v-model="keyword"
  111. :placeholder="$t('widgets.search.searchNavigate')"
  112. 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"
  113. />
  114. </DialogTitle>
  115. <DialogDescription />
  116. </DialogHeader>
  117. <SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
  118. <DialogFooter
  119. 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"
  120. >
  121. <div class="flex items-center">
  122. <CornerDownLeft class="mr-1 size-3" />
  123. {{ $t('widgets.search.select') }}
  124. </div>
  125. <div class="flex items-center">
  126. <ArrowUp class="mr-2 size-3" />
  127. <ArrowDown class="mr-2 size-3" />
  128. {{ $t('widgets.search.navigate') }}
  129. </div>
  130. <div class="flex items-center">
  131. <MdiKeyboardEsc class="mr-1 size-3" />
  132. {{ $t('widgets.search.close') }}
  133. </div>
  134. </DialogFooter>
  135. </DialogContent>
  136. </Dialog>
  137. </div>
  138. </template>