feat(web): Lists/Rules UX parity с Агентами и правило kpi-hybrid
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Hybrid row icons, row click, icon actions; DetailPanel header; ToggleGroup режима фильтра (settings-3).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 02:36:09 +07:00
co-authored by Cursor
parent 90d50c2556
commit 41e4ca1410
12 changed files with 432 additions and 171 deletions
+29
View File
@@ -0,0 +1,29 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
Импорты UI: `@evofw/ui/components/*`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
+1 -1
View File
@@ -36,7 +36,7 @@ alwaysApply: true
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` | | Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` | | Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` | | Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` (EvoBGP hybrid SoT) | | KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` | | Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` | | Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` | | Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
@@ -0,0 +1,51 @@
import { Globe, List, Network } from 'lucide-react'
import { cn } from '@evofw/ui/lib/utils'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { isManualListType } from '@evofw/shared'
type ListTypeIconProps = {
type: string
className?: string
}
/**
* KPI-style type tile for list rows.
* Preview DNA: https://reui.io/preview/base/stats-12
*/
export function ListTypeIcon({ type, className }: ListTypeIconProps) {
const kind = isManualListType(type)
? 'static'
: type === 'evobgp_community'
? 'evobgp_community'
: 'json_url'
const Icon =
kind === 'evobgp_community' ? Network : kind === 'json_url' ? Globe : List
const color =
kind === 'evobgp_community'
? 'text-info'
: kind === 'json_url'
? 'text-warning'
: 'text-muted-foreground'
return (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
color,
className,
)}
aria-label={
kind === 'evobgp_community'
? 'EvoBGP'
: kind === 'json_url'
? 'JSON URL'
: 'Ручной'
}
>
<ItemMedia variant="icon" className="size-auto">
<Icon aria-hidden />
</ItemMedia>
</Item>
)
}
@@ -1,6 +1,5 @@
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react' import { RefreshCwIcon, Trash2 } from 'lucide-react'
import type { FilterFieldConfig } from '@/components/reui/filters' import type { FilterFieldConfig } from '@/components/reui/filters'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { import {
@@ -8,6 +7,7 @@ import {
DataGridPrimaryCell, DataGridPrimaryCell,
} from '@/components/data-grid-cell' } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { ListTypeIcon } from '@/components/lists/list-type-icon'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { isManualListType, type IpList } from '@evofw/shared' import { isManualListType, type IpList } from '@evofw/shared'
@@ -58,15 +58,8 @@ export function createListColumns(opts: {
<DataGridColumnHeader column={column} title="Список" /> <DataGridColumnHeader column={column} title="Список" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<Link <div className="flex min-w-0 items-center gap-3">
to="/lists/$id" <ListTypeIcon type={row.original.type} />
params={{ id: row.original.id }}
className="flex min-w-0 items-center gap-2"
>
<ListIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<DataGridPrimaryCell <DataGridPrimaryCell
accent="primary" accent="primary"
title={row.original.name} title={row.original.name}
@@ -78,7 +71,7 @@ export function createListColumns(opts: {
: undefined : undefined
} }
/> />
</Link> </div>
), ),
meta: { headerTitle: 'Список' }, meta: { headerTitle: 'Список' },
}, },
@@ -137,15 +130,6 @@ export function createListColumns(opts: {
} }
/> />
</Button> </Button>
<Button
size="sm"
variant="outline"
render={
<Link to="/lists/$id" params={{ id: row.original.id }} />
}
>
Открыть
</Button>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
@@ -0,0 +1,69 @@
import { cn } from '@evofw/ui/lib/utils'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evofw/ui/components/toggle-group'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
type PolicyMode = 'blacklist' | 'whitelist'
type PolicyModeToggleProps = {
value: PolicyMode
onChange: (mode: PolicyMode) => void
disabled?: boolean
className?: string
}
/**
* Filter mode — settings-3 ToggleGroup pattern.
* Preview: https://reui.io/preview/base/settings-3
* Docs: https://ui.shadcn.com/docs/components/base/toggle-group
*/
export function PolicyModeToggle({
value,
onChange,
disabled,
className,
}: PolicyModeToggleProps) {
return (
<Frame dense spacing="sm" className={cn(className)}>
<FrameHeader>
<FrameTitle>Режим фильтра</FrameTitle>
<FrameDescription>
Чёрный список: блокировать deny. Белый список: пропускать только
allow, остальное (forward) DROP.
</FrameDescription>
</FrameHeader>
<FramePanel>
<ToggleGroup
multiple={false}
value={[value]}
onValueChange={(next) => {
const mode = next[0]
if (mode === 'blacklist' || mode === 'whitelist') {
onChange(mode)
}
}}
variant="outline"
size="sm"
disabled={disabled}
aria-label="Режим фильтра"
className="flex flex-wrap justify-start gap-1"
>
<ToggleGroupItem value="blacklist" aria-label="Чёрный список">
Чёрный список
</ToggleGroupItem>
<ToggleGroupItem value="whitelist" aria-label="Белый список">
Белый список
</ToggleGroupItem>
</ToggleGroup>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,30 @@
import { Shield } from 'lucide-react'
import { cn } from '@evofw/ui/lib/utils'
import { Item, ItemMedia } from '@evofw/ui/components/item'
type PolicySetIconProps = {
mode?: 'blacklist' | 'whitelist' | string | null
className?: string
}
/**
* KPI-style tile for policy set rows.
* Preview DNA: https://reui.io/preview/base/stats-12
*/
export function PolicySetIcon({ mode, className }: PolicySetIconProps) {
const isWhitelist = mode === 'whitelist'
return (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
isWhitelist ? 'text-warning' : 'text-muted-foreground',
className,
)}
aria-label={isWhitelist ? 'Whitelist' : 'Blacklist'}
>
<ItemMedia variant="icon" className="size-auto">
<Shield aria-hidden />
</ItemMedia>
</Item>
)
}
+58 -67
View File
@@ -14,7 +14,6 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { import {
DetailPanel, DetailPanel,
PageHeader,
PageShell, PageShell,
ResourcePage, ResourcePage,
} from '@/components/reui-kit' } from '@/components/reui-kit'
@@ -300,7 +299,9 @@ function ListDetailPage() {
if (listQ.isLoading) { if (listQ.isLoading) {
return ( return (
<PageShell> <PageShell>
<PageHeader title="Список" description="Загрузка…" /> <DetailPanel>
<DetailPanel.Header title="Список" description="Загрузка…" />
</DetailPanel>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" /> <Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" /> <Skeleton className="h-20 w-full" />
@@ -313,83 +314,73 @@ function ListDetailPage() {
if (!detail) { if (!detail) {
return ( return (
<PageShell> <PageShell>
<PageHeader <DetailPanel>
title="Список не найден" <DetailPanel.Header
actions={ title="Список не найден"
<Button actions={
variant="outline" <Button
size="sm" variant="outline"
render={<Link to="/lists" />} size="sm"
> render={<Link to="/lists" />}
К спискам >
</Button> К спискам
} </Button>
/> }
/>
</DetailPanel>
</PageShell> </PageShell>
) )
} }
return ( return (
<PageShell> <PageShell>
<PageHeader
title={detail.name}
description={
manual
? 'IP, CIDR, домены и вложенные списки'
: 'Записи из внешнего источника (только чтение)'
}
actions={
<>
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Обновить
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setDeleteListOpen(true)}
>
<Trash2 className="size-3.5" />
Удалить
</Button>
</>
}
/>
<DetailPanel> <DetailPanel>
<DetailPanel.Header <DetailPanel.Header
title={detail.name} title={detail.name}
description={ description={
manual manual
? 'Ручной список' ? 'IP, CIDR, домены и вложенные списки'
: detail.type === 'json_url' : 'Записи из внешнего источника (только чтение)'
? 'JSON по URL' }
: 'EvoBGP community' actions={
<>
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Обновить
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setDeleteListOpen(true)}
>
<Trash2 className="size-3.5" />
Удалить
</Button>
</>
} }
actions={<StatusBadge status={detail.type} />}
/> />
<DetailPanel.Metrics <DetailPanel.Metrics
+3 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { RefreshCwIcon } from 'lucide-react' import { Plus, RefreshCwIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
@@ -152,6 +152,7 @@ function ListsPage() {
Обновить Обновить
</Button> </Button>
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
</> </>
@@ -185,6 +186,7 @@ function ListsPage() {
description: 'Создайте первый список для политики firewall.', description: 'Создайте первый список для политики firewall.',
action: ( action: (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
), ),
+17 -49
View File
@@ -14,7 +14,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable' import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
import { Badge } from '@/components/reui/badge' import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
import { import {
agentsQueryOptions, agentsQueryOptions,
listsQueryOptions, listsQueryOptions,
@@ -56,6 +56,12 @@ import {
} from '@/components/reui/frame' } from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/rules/$setId')({ export const Route = createFileRoute('/_auth/rules/$setId')({
loader: async ({ context: { queryClient }, params }) => {
const set = await queryClient.ensureQueryData(
policySetQueryOptions(params.setId),
)
return { breadcrumb: set.name }
},
component: PolicySetDetailPage, component: PolicySetDetailPage,
}) })
@@ -286,10 +292,12 @@ function PolicySetDetailPage() {
<Switch <Switch
checked={set.enabled} checked={set.enabled}
onCheckedChange={(v) => patchSet.mutate({ enabled: v })} onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
disabled={patchSet.isPending}
aria-label={set.enabled ? 'Выключить набор' : 'Включить набор'}
/> />
<StatusBadge <span className="text-muted-foreground text-sm">
status={set.enabled ? 'enabled' : 'disabled'} {set.enabled ? 'Включён' : 'Выключен'}
/> </span>
</div> </div>
<Button variant="outline" size="sm" render={<Link to="/rules" />}> <Button variant="outline" size="sm" render={<Link to="/rules" />}>
Назад Назад
@@ -332,51 +340,11 @@ function PolicySetDetailPage() {
/> />
<DetailPanel.Section> <DetailPanel.Section>
<Frame dense spacing="sm"> <PolicyModeToggle
<FrameHeader> value={policyMode}
<FrameTitle>Режим фильтра</FrameTitle> disabled={patchSet.isPending}
<FrameDescription> onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
Blacklist: блокировать deny. Whitelist: пропускать только />
allow, остальное (forward) DROP.
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-wrap gap-2">
<Button
variant={
policyMode === 'blacklist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'blacklist' })
}
>
Чёрный список
</Button>
<Button
variant={
policyMode === 'whitelist' ? 'default' : 'outline'
}
size="sm"
onClick={() =>
patchSet.mutate({ policy_mode: 'whitelist' })
}
>
Белый список
</Button>
<Badge
variant={
policyMode === 'whitelist'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{policyMode}
</Badge>
</div>
</FramePanel>
</Frame>
</DetailPanel.Section> </DetailPanel.Section>
<DetailPanel.Section <DetailPanel.Section
+37 -32
View File
@@ -1,9 +1,9 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import type { ColumnDef } from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -11,6 +11,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { policySetsQueryOptions } from '@/queries' import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
@@ -33,7 +34,7 @@ export const Route = createFileRoute('/_auth/rules/')({
/** /**
* Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid). * Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2 * Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12
* Empty: https://reui.io/preview/base/empty-state-12 * Empty: https://reui.io/preview/base/empty-state-12
* Create sheet: https://reui.io/preview/base/sheet-8 * Create sheet: https://reui.io/preview/base/sheet-8
*/ */
@@ -119,17 +120,14 @@ function PolicySetsPage() {
<DataGridColumnHeader column={column} title="Набор" /> <DataGridColumnHeader column={column} title="Набор" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<Link <div className="flex min-w-0 items-center gap-3">
to="/rules/$setId" <PolicySetIcon mode={row.original.policy_mode} />
params={{ setId: row.original.id }}
className="min-w-0"
>
<DataGridPrimaryCell <DataGridPrimaryCell
accent="primary" accent="primary"
title={row.original.name} title={row.original.name}
subtitle={row.original.description ?? undefined} subtitle={row.original.description ?? undefined}
/> />
</Link> </div>
), ),
}, },
{ {
@@ -190,16 +188,18 @@ function PolicySetsPage() {
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button <Button
size="sm" size="icon-sm"
variant="outline" variant="ghost"
render={ aria-label="Открыть"
<Link onClick={(e) => {
to="/rules/$setId" e.stopPropagation()
params={{ setId: row.original.id }} void navigate({
/> to: '/rules/$setId',
} params: { setId: row.original.id },
})
}}
> >
Открыть <Pencil className="size-3.5" />
</Button> </Button>
{row.original.id !== 'set-shared-default' ? ( {row.original.id !== 'set-shared-default' ? (
<Button <Button
@@ -207,7 +207,10 @@ function PolicySetsPage() {
variant="ghost" variant="ghost"
className="text-destructive" className="text-destructive"
aria-label="Удалить" aria-label="Удалить"
onClick={() => setDeleteId(row.original.id)} onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
@@ -216,7 +219,14 @@ function PolicySetsPage() {
), ),
}, },
], ],
[], [navigate],
)
const addButton = (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus data-icon="inline-start" />
Новый набор
</Button>
) )
return ( return (
@@ -224,11 +234,7 @@ function PolicySetsPage() {
<PageHeader <PageHeader
title="Наборы правил" title="Наборы правил"
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS." description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
actions={ actions={addButton}
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
}
/> />
<ResourcePage <ResourcePage
@@ -254,14 +260,13 @@ function PolicySetsPage() {
isError={setsQ.isError} isError={setsQ.isError}
error={setsQ.error} error={setsQ.error}
onRetry={() => void setsQ.refetch()} onRetry={() => void setsQ.refetch()}
onRowClick={(row) =>
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
}
emptyState={{ emptyState={{
title: 'Нет наборов', title: 'Нет наборов',
description: 'Создайте первый набор правил политики.', description: 'Создайте первый набор правил политики.',
action: ( action: addButton,
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый набор
</Button>
),
}} }}
/> />
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
import { toggleVariants } from "@evofw/ui/components/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+43
View File
@@ -0,0 +1,43 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }