570 lines
23 KiB
TypeScript
570 lines
23 KiB
TypeScript
import {
|
|
useMemo,
|
|
useState,
|
|
type ComponentType,
|
|
} from 'react'
|
|
import {
|
|
Link,
|
|
useNavigate,
|
|
useRouterState,
|
|
} from '@tanstack/react-router'
|
|
import {
|
|
ActivityIcon,
|
|
ArrowRightIcon,
|
|
ChevronDownIcon,
|
|
ChevronsUpDownIcon,
|
|
FingerprintIcon,
|
|
InfoIcon,
|
|
LogOutIcon,
|
|
SearchIcon,
|
|
StarIcon,
|
|
MoonIcon,
|
|
} from 'lucide-react'
|
|
import {
|
|
useTheme,
|
|
} from 'next-themes'
|
|
import {
|
|
Badge,
|
|
} from '@/components/reui/badge'
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
} from '@authportal/ui/components/breadcrumb'
|
|
import {
|
|
Button,
|
|
} from '@authportal/ui/components/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@authportal/ui/components/dialog'
|
|
import {
|
|
Kbd,
|
|
} from '@authportal/ui/components/kbd'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@authportal/ui/components/popover'
|
|
import {
|
|
Separator,
|
|
} from '@authportal/ui/components/separator'
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuAction,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarSeparator,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from '@authportal/ui/components/sidebar'
|
|
import {
|
|
cn,
|
|
} from '@authportal/ui/lib/utils'
|
|
import {
|
|
Segmented,
|
|
} from './bits'
|
|
import {
|
|
useChromeConfig,
|
|
useUiStore,
|
|
type NavItem,
|
|
} from './chrome-store'
|
|
|
|
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
|
|
|
function PortalNavUser() {
|
|
const { isMobile } = useSidebar()
|
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
|
const { density, setDensity, contrast, setContrast } = useUiStore()
|
|
const { user, securityTo, onLogout } = useChromeConfig()
|
|
const navigate = useNavigate()
|
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
|
|
|
return (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<SidebarMenuButton
|
|
size="lg"
|
|
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
|
className="bg-muted/40 aria-expanded:bg-muted"
|
|
/>
|
|
}
|
|
>
|
|
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
|
{user.initials}
|
|
</div>
|
|
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
|
<span className="truncate text-sm font-semibold">{user.name}</span>
|
|
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
|
</div>
|
|
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
|
</PopoverTrigger>
|
|
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
|
{user.initials}
|
|
</div>
|
|
<div className="grid leading-tight">
|
|
<span className="text-sm font-semibold">{user.name}</span>
|
|
<span className="text-muted-foreground text-xs">{user.email}</span>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<button
|
|
type="button"
|
|
onClick={() => void navigate({ to: securityTo })}
|
|
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
|
>
|
|
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
|
Безопасность
|
|
</button>
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className={LABEL_CLASS}>Тема</span>
|
|
<Segmented
|
|
label="Тема"
|
|
options={['Тёмная', 'Светлая']}
|
|
value={current === 'light' ? 1 : 0}
|
|
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
|
/>
|
|
<span className={LABEL_CLASS}>Плотность</span>
|
|
<Segmented
|
|
label="Плотность"
|
|
options={['Обычная', 'Компактная']}
|
|
value={density === 'compact' ? 1 : 0}
|
|
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
|
/>
|
|
<span className={LABEL_CLASS}>Контраст</span>
|
|
<Segmented
|
|
label="Контраст"
|
|
options={['Стандартный', 'Высокий']}
|
|
value={contrast === 'high' ? 1 : 0}
|
|
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
|
/>
|
|
</div>
|
|
<Separator />
|
|
<button
|
|
type="button"
|
|
onClick={onLogout}
|
|
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
|
>
|
|
<LogOutIcon className="size-4" aria-hidden />
|
|
Выйти
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
)
|
|
}
|
|
|
|
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
|
|
|
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
|
const Icon = item.icon
|
|
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
|
return (
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
|
isActive={active}
|
|
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
|
render={<Link to={item.to} />}
|
|
>
|
|
<Icon className="size-4" />
|
|
<span>{item.label}</span>
|
|
{item.problem ? (
|
|
<span
|
|
role="img"
|
|
aria-label={item.problem.label}
|
|
className={cn(
|
|
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
|
DOT_CLASS[item.problem.tone],
|
|
)}
|
|
/>
|
|
) : null}
|
|
</SidebarMenuButton>
|
|
<SidebarMenuAction
|
|
showOnHover={!pinned}
|
|
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
|
aria-pressed={pinned}
|
|
onClick={onTogglePin}
|
|
>
|
|
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
|
</SidebarMenuAction>
|
|
</SidebarMenuItem>
|
|
)
|
|
}
|
|
|
|
export function PortalSidebar() {
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
const { pins, togglePin } = useUiStore()
|
|
const { navItems, navGroups, brand } = useChromeConfig()
|
|
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
|
const pinned = navItems.filter((i) => pins.includes(i.id))
|
|
return (
|
|
<Sidebar collapsible="icon">
|
|
<SidebarHeader className="pb-1.5">
|
|
{brand}
|
|
</SidebarHeader>
|
|
<SidebarContent>
|
|
{pinned.length > 0 ? (
|
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{pinned.map((item) => (
|
|
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
) : null}
|
|
{navGroups.map((group, gi) => (
|
|
<div key={group} className="flex flex-col">
|
|
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
|
<SidebarGroup className="py-0">
|
|
<SidebarGroupLabel
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-expanded={!closed[group]}
|
|
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
|
/>
|
|
}
|
|
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
|
>
|
|
{group}
|
|
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
|
</SidebarGroupLabel>
|
|
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
|
<SidebarMenu className="gap-px">
|
|
{navItems.filter((i) => i.group === group).map((item) => (
|
|
<NavEntry
|
|
key={item.id}
|
|
item={item}
|
|
pathname={pathname}
|
|
pinned={pins.includes(item.id)}
|
|
onTogglePin={() => togglePin(item.id)}
|
|
/>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</div>
|
|
))}
|
|
</SidebarContent>
|
|
<SidebarFooter>
|
|
<PortalNavUser />
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
)
|
|
}
|
|
|
|
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
|
function MonitorPopover() {
|
|
const { monitor } = useChromeConfig()
|
|
const MONITOR_ITEMS = monitor.items
|
|
const MONITOR_METRICS = monitor.metrics
|
|
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
|
const errors = bad.filter((m) => m.tone === 'er').length
|
|
const warnings = bad.length - errors
|
|
const ok = MONITOR_ITEMS.length - bad.length
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
type="button"
|
|
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
|
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
|
/>
|
|
}
|
|
>
|
|
<ActivityIcon className="text-warning size-4" aria-hidden />
|
|
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
|
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
|
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
|
{errors}
|
|
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
|
{warnings}
|
|
</span>
|
|
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
|
{bad.length}
|
|
</span>
|
|
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
|
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
|
<div className="grid leading-tight">
|
|
<b className="text-sm font-semibold">Состояние системы</b>
|
|
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
|
</div>
|
|
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{[
|
|
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
|
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
|
{ n: ok, l: 'в норме', c: 'text-success' },
|
|
].map((x) => (
|
|
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
|
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
|
{x.l}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<ul className="flex flex-col gap-0.5">
|
|
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
|
<li key={m.id}>
|
|
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
|
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
|
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
|
</span>
|
|
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{MONITOR_METRICS.map((m) => (
|
|
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
|
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
|
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
|
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
|
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
|
{monitor.footerLabel}
|
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
|
</Link>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function FreshnessPopover() {
|
|
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
|
const { fresh: freshFor } = useChromeConfig()
|
|
const fresh = freshFor(pathname)
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
|
}
|
|
>
|
|
<InfoIcon aria-hidden />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
|
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
|
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export function PortalHeader() {
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
const { setPaletteOpen } = useUiStore()
|
|
const { navItems, crumbFor } = useChromeConfig()
|
|
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
|
|
|
return (
|
|
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
|
<SidebarTrigger className="-ml-1" />
|
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
<Breadcrumb className="min-w-0">
|
|
<BreadcrumbList className="flex-nowrap">
|
|
<BreadcrumbItem className="min-w-0">
|
|
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
<FreshnessPopover />
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
aria-label="Командная палитра (⌘K)"
|
|
className="md:hidden"
|
|
onClick={() => setPaletteOpen(true)}
|
|
>
|
|
<SearchIcon aria-hidden />
|
|
</Button>
|
|
<MonitorPopover />
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
type PaletteEntry = {
|
|
id: string
|
|
group: string
|
|
label: string
|
|
hint: string
|
|
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
|
dot?: 'ok' | 'er' | 'mu'
|
|
run: () => void
|
|
}
|
|
|
|
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
|
|
|
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
|
export function CommandPalette() {
|
|
const { paletteOpen, setPaletteOpen } = useUiStore()
|
|
const { navItems, palette } = useChromeConfig()
|
|
const navigate = useNavigate()
|
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
|
const [query, setQuery] = useState('')
|
|
const [active, setActive] = useState(0)
|
|
|
|
const shown = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
|
const go = (to: string) => () => void navigate({ to })
|
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
|
const list: PaletteEntry[] = [
|
|
...[
|
|
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
|
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
|
]
|
|
.filter((e) => match(e.label))
|
|
.map((e) => ({ ...e, group: 'Действия' })),
|
|
...navItems.filter((i) => match(i.label, i.group))
|
|
.slice(0, 7)
|
|
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
|
...Object.values(
|
|
palette.objects
|
|
.filter((o) => match(o.label, o.hint))
|
|
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
|
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
|
]
|
|
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
|
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
|
return [
|
|
{
|
|
id: 'ai',
|
|
group: 'Ассистент · запрос на естественном языке',
|
|
label: `Понял так: «${query.trim()}»`,
|
|
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
|
run: () => void navigate({ to: palette.assistantTo }),
|
|
} as PaletteEntry,
|
|
...list,
|
|
]
|
|
}
|
|
return list
|
|
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
|
|
|
const groups = useMemo(() => {
|
|
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
|
shown.forEach((e, index) => {
|
|
const list = map.get(e.group) ?? []
|
|
list.push({ ...e, index })
|
|
map.set(e.group, list)
|
|
})
|
|
return [...map.entries()]
|
|
}, [shown])
|
|
|
|
function close() {
|
|
setPaletteOpen(false)
|
|
setQuery('')
|
|
setActive(0)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
|
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
|
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
|
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
|
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
|
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
aria-label="Поиск и команды"
|
|
placeholder="Раздел, пользователь, приложение или действие…"
|
|
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setActive(0)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setActive((a) => Math.min(a + 1, shown.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setActive((a) => Math.max(a - 1, 0))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
shown[active]?.run()
|
|
close()
|
|
}
|
|
}}
|
|
/>
|
|
<Kbd>Esc</Kbd>
|
|
</div>
|
|
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
|
{groups.length === 0 ? (
|
|
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
|
) : (
|
|
groups.map(([group, list]) => (
|
|
<div key={group} className="flex flex-col">
|
|
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
|
{list.map((e) => {
|
|
const Icon = e.icon
|
|
return (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={e.index === active}
|
|
onMouseEnter={() => setActive(e.index)}
|
|
onClick={() => {
|
|
e.run()
|
|
close()
|
|
}}
|
|
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
|
>
|
|
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
|
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
|
<span className="min-w-0 truncate">{e.label}</span>
|
|
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
|
<span>↑↓ выбор</span>
|
|
<span>↵ открыть</span>
|
|
<span>Esc закрыть</span>
|
|
<span className="ml-auto">{palette.example}</span>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|