Procházet zdrojové kódy

feat: implement global command/search

satnaing před 1 rokem
rodič
revize
f9c09b45b7

+ 2 - 0
.prettierrc

@@ -23,6 +23,7 @@
     "^zod$",
     "^axios$",
     "^date-fns$",
+    "^js-cookie$",
     "^react-hook-form$",
     "^use-intl$",
     "^@radix-ui/(.*)$",
@@ -38,6 +39,7 @@
     "^@/lib/(.*)$",
     "^@/utils/(.*)$",
     "^@/constants/(.*)$",
+    "^@/context/(.*)$",
     "^@/hooks/(.*)$",
     "^@/components/layouts/(.*)$",
     "^@/components/ui/(.*)$",

+ 96 - 0
src/components/command-menu.tsx

@@ -0,0 +1,96 @@
+import React from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import {
+  IconArrowRightDashed,
+  IconDeviceLaptop,
+  IconMoon,
+  IconSun,
+} from '@tabler/icons-react'
+import { useSearch } from '@/context/search-context'
+import { useTheme } from '@/context/theme-context'
+import {
+  CommandDialog,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+} from '@/components/ui/command'
+import { sidebarData } from './layout/data/sidebar-data'
+import { ScrollArea } from './ui/scroll-area'
+
+export function CommandMenu() {
+  const navigate = useNavigate()
+  const { setTheme } = useTheme()
+  const { open, setOpen } = useSearch()
+
+  const runCommand = React.useCallback(
+    (command: () => unknown) => {
+      setOpen(false)
+      command()
+    },
+    [setOpen]
+  )
+
+  return (
+    <CommandDialog modal open={open} onOpenChange={setOpen}>
+      <CommandInput placeholder='Type a command or search...' />
+      <CommandList>
+        <ScrollArea type='hover' className='h-72 pr-1'>
+          <CommandEmpty>No results found.</CommandEmpty>
+          {sidebarData.navGroups.map((group) => (
+            <CommandGroup key={group.title} heading={group.title}>
+              {group.items.map((navItem, i) => {
+                if (navItem.url)
+                  return (
+                    <CommandItem
+                      key={`${navItem.url}-${i}`}
+                      value={navItem.title}
+                      onSelect={() => {
+                        runCommand(() => navigate({ to: navItem.url }))
+                      }}
+                    >
+                      <div className='mr-2 flex h-4 w-4 items-center justify-center'>
+                        <IconArrowRightDashed className='size-2 text-muted-foreground/80' />
+                      </div>
+                      {navItem.title}
+                    </CommandItem>
+                  )
+
+                return navItem.items?.map((subItem, i) => (
+                  <CommandItem
+                    key={`${subItem.url}-${i}`}
+                    value={subItem.title}
+                    onSelect={() => {
+                      runCommand(() => navigate({ to: subItem.url }))
+                    }}
+                  >
+                    <div className='mr-2 flex h-4 w-4 items-center justify-center'>
+                      <IconArrowRightDashed className='size-2 text-muted-foreground/80' />
+                    </div>
+                    {subItem.title}
+                  </CommandItem>
+                ))
+              })}
+            </CommandGroup>
+          ))}
+          <CommandSeparator />
+          <CommandGroup heading='Theme'>
+            <CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
+              <IconSun /> <span>Light</span>
+            </CommandItem>
+            <CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
+              <IconMoon className='scale-90' />
+              <span>Dark</span>
+            </CommandItem>
+            <CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
+              <IconDeviceLaptop />
+              <span>System</span>
+            </CommandItem>
+          </CommandGroup>
+        </ScrollArea>
+      </CommandList>
+    </CommandDialog>
+  )
+}

+ 4 - 188
src/components/layout/app-sidebar.tsx

@@ -1,25 +1,3 @@
-import {
-  IconBarrierBlock,
-  IconBrowserCheck,
-  IconBug,
-  IconChecklist,
-  IconError404,
-  IconHelp,
-  IconLayoutDashboard,
-  IconLock,
-  IconLockAccess,
-  IconMessages,
-  IconNotification,
-  IconPackages,
-  IconPalette,
-  IconServerOff,
-  IconSettings,
-  IconTool,
-  IconUserCog,
-  IconUserOff,
-  IconUsers,
-} from '@tabler/icons-react'
-import { AudioWaveform, Command, GalleryVerticalEnd } from 'lucide-react'
 import {
   Sidebar,
   SidebarContent,
@@ -30,183 +8,21 @@ import {
 import { NavGroup } from '@/components/layout/nav-group'
 import { NavUser } from '@/components/layout/nav-user'
 import { TeamSwitcher } from '@/components/layout/team-switcher'
-import { type SidebarData } from './types'
-
-// SideNav data
-const data: SidebarData = {
-  user: {
-    name: 'satnaing',
-    email: 'satnaingdev@gmail.com',
-    avatar: '/avatars/shadcn.jpg',
-  },
-  teams: [
-    {
-      name: 'Shadcn Admin',
-      logo: Command,
-      plan: 'Vite + ShadcnUI',
-    },
-    {
-      name: 'Acme Inc',
-      logo: GalleryVerticalEnd,
-      plan: 'Enterprise',
-    },
-    {
-      name: 'Acme Corp.',
-      logo: AudioWaveform,
-      plan: 'Startup',
-    },
-  ],
-  navGroups: [
-    {
-      title: 'General',
-      items: [
-        {
-          title: 'Dashboard',
-          url: '/dashboard',
-          icon: IconLayoutDashboard,
-        },
-        {
-          title: 'Tasks',
-          url: '/tasks',
-          icon: IconChecklist,
-        },
-        {
-          title: 'Apps',
-          url: '/apps',
-          icon: IconPackages,
-        },
-        {
-          title: 'Chats',
-          url: '/chats',
-          badge: '3',
-          icon: IconMessages,
-        },
-        {
-          title: 'Users',
-          url: '/',
-          icon: IconUsers,
-        },
-      ],
-    },
-    {
-      title: 'Pages',
-      items: [
-        {
-          title: 'Auth',
-          icon: IconLockAccess,
-          items: [
-            {
-              title: 'Sign In',
-              url: '/sign-in',
-            },
-            {
-              title: 'Sign In (2 Col)',
-              url: '/sign-in-2',
-            },
-            {
-              title: 'Sign Up',
-              url: '/sign-up',
-            },
-            {
-              title: 'Forgot Password',
-              url: '/forgot-password',
-            },
-            {
-              title: 'OTP',
-              url: '/otp',
-            },
-          ],
-        },
-        {
-          title: 'Errors',
-          icon: IconBug,
-          items: [
-            {
-              title: 'Unauthorized',
-              url: '/401',
-              icon: IconLock,
-            },
-            {
-              title: 'Forbidden',
-              url: '/403',
-              icon: IconUserOff,
-            },
-            {
-              title: 'Not Found',
-              url: '/404',
-              icon: IconError404,
-            },
-            {
-              title: 'Internal Server Error',
-              url: '/500',
-              icon: IconServerOff,
-            },
-            {
-              title: 'Maintenance Error',
-              url: '/503',
-              icon: IconBarrierBlock,
-            },
-          ],
-        },
-      ],
-    },
-    {
-      title: 'Other',
-      items: [
-        {
-          title: 'Settings',
-          icon: IconSettings,
-          items: [
-            {
-              title: 'Profile',
-              url: '/settings',
-              icon: IconUserCog,
-            },
-            {
-              title: 'Account',
-              url: '/settings/account',
-              icon: IconTool,
-            },
-            {
-              title: 'Appearance',
-              url: '/settings/appearance',
-              icon: IconPalette,
-            },
-            {
-              title: 'Notifications',
-              url: '/settings/notifications',
-              icon: IconNotification,
-            },
-            {
-              title: 'Display',
-              url: '/settings/display',
-              icon: IconBrowserCheck,
-            },
-          ],
-        },
-        {
-          title: 'Help Center',
-          url: '/help-center',
-          icon: IconHelp,
-        },
-      ],
-    },
-  ],
-}
+import { sidebarData } from './data/sidebar-data'
 
 export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
   return (
     <Sidebar collapsible='icon' {...props}>
       <SidebarHeader>
-        <TeamSwitcher teams={data.teams} />
+        <TeamSwitcher teams={sidebarData.teams} />
       </SidebarHeader>
       <SidebarContent>
-        {data.navGroups.map((props) => (
+        {sidebarData.navGroups.map((props) => (
           <NavGroup key={props.title} {...props} />
         ))}
       </SidebarContent>
       <SidebarFooter>
-        <NavUser user={data.user} />
+        <NavUser user={sidebarData.user} />
       </SidebarFooter>
       <SidebarRail />
     </Sidebar>

+ 184 - 0
src/components/layout/data/sidebar-data.ts

@@ -0,0 +1,184 @@
+import {
+  IconBarrierBlock,
+  IconBrowserCheck,
+  IconBug,
+  IconChecklist,
+  IconError404,
+  IconHelp,
+  IconLayoutDashboard,
+  IconLock,
+  IconLockAccess,
+  IconMessages,
+  IconNotification,
+  IconPackages,
+  IconPalette,
+  IconServerOff,
+  IconSettings,
+  IconTool,
+  IconUserCog,
+  IconUserOff,
+  IconUsers,
+} from '@tabler/icons-react'
+import { AudioWaveform, Command, GalleryVerticalEnd } from 'lucide-react'
+import { type SidebarData } from '../types'
+
+export const sidebarData: SidebarData = {
+  user: {
+    name: 'satnaing',
+    email: 'satnaingdev@gmail.com',
+    avatar: '/avatars/shadcn.jpg',
+  },
+  teams: [
+    {
+      name: 'Shadcn Admin',
+      logo: Command,
+      plan: 'Vite + ShadcnUI',
+    },
+    {
+      name: 'Acme Inc',
+      logo: GalleryVerticalEnd,
+      plan: 'Enterprise',
+    },
+    {
+      name: 'Acme Corp.',
+      logo: AudioWaveform,
+      plan: 'Startup',
+    },
+  ],
+  navGroups: [
+    {
+      title: 'General',
+      items: [
+        {
+          title: 'Dashboard',
+          url: '/dashboard',
+          icon: IconLayoutDashboard,
+        },
+        {
+          title: 'Tasks',
+          url: '/tasks',
+          icon: IconChecklist,
+        },
+        {
+          title: 'Apps',
+          url: '/apps',
+          icon: IconPackages,
+        },
+        {
+          title: 'Chats',
+          url: '/chats',
+          badge: '3',
+          icon: IconMessages,
+        },
+        {
+          title: 'Users',
+          url: '/',
+          icon: IconUsers,
+        },
+      ],
+    },
+    {
+      title: 'Pages',
+      items: [
+        {
+          title: 'Auth',
+          icon: IconLockAccess,
+          items: [
+            {
+              title: 'Sign In',
+              url: '/sign-in',
+            },
+            {
+              title: 'Sign In (2 Col)',
+              url: '/sign-in-2',
+            },
+            {
+              title: 'Sign Up',
+              url: '/sign-up',
+            },
+            {
+              title: 'Forgot Password',
+              url: '/forgot-password',
+            },
+            {
+              title: 'OTP',
+              url: '/otp',
+            },
+          ],
+        },
+        {
+          title: 'Errors',
+          icon: IconBug,
+          items: [
+            {
+              title: 'Unauthorized',
+              url: '/401',
+              icon: IconLock,
+            },
+            {
+              title: 'Forbidden',
+              url: '/403',
+              icon: IconUserOff,
+            },
+            {
+              title: 'Not Found',
+              url: '/404',
+              icon: IconError404,
+            },
+            {
+              title: 'Internal Server Error',
+              url: '/500',
+              icon: IconServerOff,
+            },
+            {
+              title: 'Maintenance Error',
+              url: '/503',
+              icon: IconBarrierBlock,
+            },
+          ],
+        },
+      ],
+    },
+    {
+      title: 'Other',
+      items: [
+        {
+          title: 'Settings',
+          icon: IconSettings,
+          items: [
+            {
+              title: 'Profile',
+              url: '/settings',
+              icon: IconUserCog,
+            },
+            {
+              title: 'Account',
+              url: '/settings/account',
+              icon: IconTool,
+            },
+            {
+              title: 'Appearance',
+              url: '/settings/appearance',
+              icon: IconPalette,
+            },
+            {
+              title: 'Notifications',
+              url: '/settings/notifications',
+              icon: IconNotification,
+            },
+            {
+              title: 'Display',
+              url: '/settings/display',
+              icon: IconBrowserCheck,
+            },
+          ],
+        },
+        {
+          title: 'Help Center',
+          url: '/help-center',
+          icon: IconHelp,
+        },
+      ],
+    },
+  ],
+}

+ 28 - 8
src/components/search.tsx

@@ -1,13 +1,33 @@
-import { Input } from '@/components/ui/input'
+import { IconSearch } from '@tabler/icons-react'
+import { cn } from '@/lib/utils'
+import { useSearch } from '@/context/search-context'
+import { Button } from './ui/button'
 
-export function Search() {
+interface Props {
+  className?: string
+  type?: React.HTMLInputTypeAttribute
+  placeholder?: string
+}
+
+export function Search({ className = '', placeholder = 'Search' }: Props) {
+  const { setOpen } = useSearch()
   return (
-    <div>
-      <Input
-        type='search'
-        placeholder='Search'
-        className='md:w-[100px] lg:w-[300px]'
+    <Button
+      variant='outline'
+      className={cn(
+        'relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 hover:bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-56 xl:w-64',
+        className
+      )}
+      onClick={() => setOpen(true)}
+    >
+      <IconSearch
+        aria-hidden='true'
+        className='absolute left-1.5 top-1/2 -translate-y-1/2'
       />
-    </div>
+      <span className='ml-3'>{placeholder}</span>
+      <kbd className='pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex'>
+        <span className='text-xs'>⌘</span>K
+      </kbd>
+    </Button>
   )
 }

+ 13 - 1
src/components/ui/command.tsx

@@ -1,9 +1,15 @@
 import * as React from 'react'
 import { type DialogProps } from '@radix-ui/react-dialog'
+import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
 import { Command as CommandPrimitive } from 'cmdk'
 import { Search } from 'lucide-react'
 import { cn } from '@/lib/utils'
-import { Dialog, DialogContent } from '@/components/ui/dialog'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogTitle,
+} from '@/components/ui/dialog'
 
 const Command = React.forwardRef<
   React.ElementRef<typeof CommandPrimitive>,
@@ -24,6 +30,12 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
   return (
     <Dialog {...props}>
       <DialogContent className='overflow-hidden p-0'>
+        <VisuallyHidden asChild>
+          <DialogTitle />
+        </VisuallyHidden>
+        <VisuallyHidden asChild>
+          <DialogDescription />
+        </VisuallyHidden>
         <Command className='[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
           {children}
         </Command>

+ 46 - 0
src/context/search-context.tsx

@@ -0,0 +1,46 @@
+import React from 'react'
+import { CommandMenu } from '@/components/command-menu'
+
+interface SearchContextType {
+  open: boolean
+  setOpen: React.Dispatch<React.SetStateAction<boolean>>
+}
+
+const SearchContext = React.createContext<SearchContextType | null>(null)
+
+interface Props {
+  children: React.ReactNode
+}
+
+export function SearchProvider({ children }: Props) {
+  const [open, setOpen] = React.useState(false)
+
+  React.useEffect(() => {
+    const down = (e: KeyboardEvent) => {
+      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
+        e.preventDefault()
+        setOpen((open) => !open)
+      }
+    }
+    document.addEventListener('keydown', down)
+    return () => document.removeEventListener('keydown', down)
+  }, [])
+
+  return (
+    <SearchContext.Provider value={{ open, setOpen }}>
+      {children}
+      <CommandMenu />
+    </SearchContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useSearch = () => {
+  const searchContext = React.useContext(SearchContext)
+
+  if (!searchContext) {
+    throw new Error('useSearch has to be used within <SearchContext.Provider>')
+  }
+
+  return searchContext
+}

+ 20 - 17
src/routes/_authenticated/route.tsx

@@ -1,6 +1,7 @@
-import { createFileRoute, Outlet } from '@tanstack/react-router'
 import Cookies from 'js-cookie'
+import { createFileRoute, Outlet } from '@tanstack/react-router'
 import { cn } from '@/lib/utils'
+import { SearchProvider } from '@/context/search-context'
 import { SidebarProvider } from '@/components/ui/sidebar'
 import { AppSidebar } from '@/components/layout/app-sidebar'
 import SkipToMain from '@/components/skip-to-main'
@@ -12,21 +13,23 @@ export const Route = createFileRoute('/_authenticated')({
 function RouteComponent() {
   const defaultOpen = Cookies.get('sidebar:state') !== 'false'
   return (
-    <SidebarProvider defaultOpen={defaultOpen}>
-      <SkipToMain />
-      <AppSidebar />
-      <div
-        id='content'
-        className={cn(
-          'max-w-full w-full ml-auto',
-          'peer-data-[state=collapsed]:w-[calc(100%-var(--sidebar-width-icon))]',
-          'peer-data-[state=expanded]:w-[calc(100%-var(--sidebar-width))]',
-          'transition-[width] ease-linear duration-200',
-          'h-svh flex flex-col'
-        )}
-      >
-        <Outlet />
-      </div>
-    </SidebarProvider>
+    <SearchProvider>
+      <SidebarProvider defaultOpen={defaultOpen}>
+        <SkipToMain />
+        <AppSidebar />
+        <div
+          id='content'
+          className={cn(
+            'max-w-full w-full ml-auto',
+            'peer-data-[state=collapsed]:w-[calc(100%-var(--sidebar-width-icon))]',
+            'peer-data-[state=expanded]:w-[calc(100%-var(--sidebar-width))]',
+            'transition-[width] ease-linear duration-200',
+            'h-svh flex flex-col'
+          )}
+        >
+          <Outlet />
+        </div>
+      </SidebarProvider>
+    </SearchProvider>
   )
 }