CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 39s
CI / web (push) Successful in 55s
CI / go (push) Successful in 2m40s
CI / bird2 (push) Successful in 16s
CI / commitlint (push) Skipped
CI / release (push) Successful in 18s
Выровнять KPI на IconTile, вынести settings из settings-7, Sheet/AlertDialog и skip-link в общем chrome. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
4.7 KiB
TypeScript
160 lines
4.7 KiB
TypeScript
import type { KeyboardEvent, ReactNode } from 'react'
|
|
import { Link } from '@tanstack/react-router'
|
|
import {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from '@/components/reui/frame'
|
|
import { Badge } from '@/components/reui/badge'
|
|
import { IconTile } from '@/components/reui/icon-tile'
|
|
import { cn } from '@evobgp/ui/lib/utils'
|
|
import { kpiCols } from './kpi-cols'
|
|
|
|
type QuickActionBase = {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
icon?: ReactNode
|
|
iconClassName?: string
|
|
/** Override badge label (default: «Перейти» for links, «Выполнить» for onClick). */
|
|
badge?: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
export type QuickActionItem = QuickActionBase &
|
|
(
|
|
| { to: string; search?: Record<string, unknown>; onClick?: never }
|
|
| { onClick: () => void; to?: never; search?: never }
|
|
)
|
|
|
|
interface QuickActionGridProps {
|
|
actions: QuickActionItem[]
|
|
title?: string
|
|
description?: string
|
|
className?: string
|
|
}
|
|
|
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
|
|
|
function resolveBadge(action: QuickActionItem): string {
|
|
if (action.badge) return action.badge
|
|
return action.onClick ? 'Выполнить' : 'Перейти'
|
|
}
|
|
|
|
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
onActivate()
|
|
}
|
|
}
|
|
|
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|
return (
|
|
<div className="relative z-10 flex h-full items-start gap-3">
|
|
{action.icon ? (
|
|
<IconTile
|
|
variant="elevated"
|
|
aria-hidden="true"
|
|
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
|
>
|
|
{action.icon}
|
|
</IconTile>
|
|
) : null}
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
|
<Badge variant="outline" size="sm" className="shrink-0">
|
|
{resolveBadge(action)}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
|
{action.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function panelClassName(disabled?: boolean) {
|
|
return cn(
|
|
'relative isolate flex h-full flex-col transition-colors',
|
|
disabled
|
|
? 'cursor-not-allowed opacity-60'
|
|
: 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2',
|
|
)
|
|
}
|
|
|
|
/**
|
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
|
* Preview: https://reui.io/preview/base/stats-12
|
|
*/
|
|
export function QuickActionGrid({
|
|
actions,
|
|
title = 'Быстрые действия',
|
|
description,
|
|
className,
|
|
}: QuickActionGridProps) {
|
|
if (actions.length === 0) return null
|
|
|
|
return (
|
|
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
|
{(title || description) && (
|
|
<FrameHeader>
|
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
|
</FrameHeader>
|
|
)}
|
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
|
{actions.map((action) => {
|
|
const label = `${action.title}: ${action.description}`
|
|
|
|
if ('to' in action && action.to) {
|
|
return (
|
|
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
|
|
{action.disabled ? (
|
|
<div aria-disabled aria-label={label}>
|
|
<QuickActionBody action={action} />
|
|
</div>
|
|
) : (
|
|
<Link
|
|
to={action.to}
|
|
search={action.search}
|
|
className="focus-visible:outline-none"
|
|
aria-label={label}
|
|
>
|
|
<QuickActionBody action={action} />
|
|
</Link>
|
|
)}
|
|
</FramePanel>
|
|
)
|
|
}
|
|
|
|
const onClick = action.onClick
|
|
const onActivate = () => {
|
|
if (action.disabled || !onClick) return
|
|
onClick()
|
|
}
|
|
|
|
return (
|
|
<FramePanel
|
|
key={action.id}
|
|
className={panelClassName(action.disabled)}
|
|
role="button"
|
|
tabIndex={action.disabled ? -1 : 0}
|
|
aria-disabled={action.disabled || undefined}
|
|
aria-label={label}
|
|
onClick={onActivate}
|
|
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
|
|
>
|
|
<QuickActionBody action={action} />
|
|
</FramePanel>
|
|
)
|
|
})}
|
|
</div>
|
|
</Frame>
|
|
)
|
|
}
|