Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
import { useEffect, useId, useMemo, useState } from 'react'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import { Search } from 'lucide-react'
|
|
|
|
import { Button } from '@evobgp/ui/components/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@evobgp/ui/components/dialog'
|
|
import { Input } from '@evobgp/ui/components/input'
|
|
import { Kbd } from '@evobgp/ui/components/kbd'
|
|
import {
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
} from '@evobgp/ui/components/sidebar'
|
|
|
|
export type CommandPaletteItem = {
|
|
id: string
|
|
label: string
|
|
description?: string
|
|
to: string
|
|
search?: Record<string, string>
|
|
keywords?: string[]
|
|
}
|
|
|
|
interface CommandPaletteProps {
|
|
items: CommandPaletteItem[]
|
|
className?: string
|
|
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
|
hotkeyOnly?: boolean
|
|
}
|
|
|
|
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const searchInputId = useId()
|
|
const navigate = useNavigate()
|
|
|
|
useEffect(() => {
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKeyDown)
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
}, [])
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
if (!q) return items
|
|
return items.filter((item) => {
|
|
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
return haystack.includes(q)
|
|
})
|
|
}, [items, query])
|
|
|
|
function go(item: CommandPaletteItem) {
|
|
setOpen(false)
|
|
setQuery('')
|
|
void navigate({ to: item.to, search: item.search })
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{!hotkeyOnly ? (
|
|
<SidebarGroup className="p-0">
|
|
<SidebarGroupContent className="relative">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Поиск…
|
|
</Button>
|
|
<Search
|
|
aria-hidden
|
|
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
|
/>
|
|
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
|
⌘K
|
|
</Kbd>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
) : null}
|
|
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next)
|
|
if (!next) setQuery('')
|
|
}}
|
|
>
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>Быстрый переход</DialogTitle>
|
|
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
|
<div className="flex items-center gap-3 border-b px-4 py-3">
|
|
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
|
<Input
|
|
id={searchInputId}
|
|
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
|
autoFocus
|
|
placeholder="Раздел или действие…"
|
|
aria-label="Поиск разделов"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
|
}}
|
|
/>
|
|
</div>
|
|
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
|
{filtered.length === 0 ? (
|
|
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
|
) : (
|
|
filtered.map((item) => (
|
|
<li key={item.id}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
onClick={() => go(item)}
|
|
>
|
|
<span className="font-medium">{item.label}</span>
|
|
{item.description ? (
|
|
<span className="text-muted-foreground text-xs">{item.description}</span>
|
|
) : null}
|
|
</button>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|