fix(web): UX/UI аудит — пагинация, a11y, settings и TruncatedText
Docker / build (push) Failing after 20s

Исправить обрезку Select в пагинации DataGrid, health-mode zero-results, подтверждение импорта бэкапа и унифицировать tooltip на обрезанном тексте.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 12:53:16 +07:00
co-authored by Cursor
parent 447d499a8d
commit 5c1c94bc57
28 changed files with 472 additions and 219 deletions
@@ -72,6 +72,7 @@ export function AccountFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
aria-label="Фильтр по хостеру"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
@@ -79,6 +80,7 @@ export function AccountFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
aria-label="Фильтр по режиму биллинга"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
@@ -12,6 +12,7 @@ import {
AutocompleteItem,
AutocompleteList,
} from '@/components/reui/autocomplete'
import { TruncatedText } from '@/components/truncated-text'
export interface AutoCompleteOption {
value: string
@@ -114,7 +115,9 @@ export function AutoCompleteInput({
{item.leading ? (
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
) : null}
<span className="relative z-1 min-w-0 flex-1 truncate">{item.label}</span>
<span className="relative z-1 min-w-0 flex-1">
<TruncatedText>{item.label}</TruncatedText>
</span>
{isSelected ? (
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
) : null}
+36 -38
View File
@@ -34,6 +34,9 @@ const PAGINATION_LABELS = {
info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
@@ -140,7 +143,7 @@ function DataGridSectionHeader({
function DataGridPaginationBar() {
return (
<div className="px-4 py-2.5">
<div className="border-t border-border px-4 py-2.5">
<DataGridPagination {...PAGINATION_LABELS} />
</div>
)
@@ -170,46 +173,41 @@ function DataGridCardBody<TData extends object>({
enableColumnVisibility: boolean
}) {
return (
<DataGridContainer border={false}>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGridContainer border={false}>
{virtualization ? (
<>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
{showPagination ? <DataGridPaginationBar /> : null}
</>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
) : (
<>
<DataGridTable footerContent={footerContent} />
{showPagination ? <DataGridPaginationBar /> : null}
</>
<DataGridTable footerContent={footerContent} />
)}
</DataGrid>
</DataGridContainer>
</DataGridContainer>
{showPagination ? <DataGridPaginationBar /> : null}
</DataGrid>
)
}
+11 -2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export function dataGridCellStack(
primary: ReactNode,
@@ -9,9 +10,17 @@ export function dataGridCellStack(
) {
return (
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
<span className="truncate font-medium">{primary}</span>
{typeof primary === 'string' || typeof primary === 'number' ? (
<TruncatedText className="font-medium">{primary}</TruncatedText>
) : (
<span className="truncate font-medium">{primary}</span>
)}
{secondary ? (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
typeof secondary === 'string' || typeof secondary === 'number' ? (
<TruncatedText className="max-w-[14rem] text-xs text-muted-foreground">{secondary}</TruncatedText>
) : (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
)
) : null}
</div>
)
+4 -4
View File
@@ -91,7 +91,7 @@ export function MonthlyExpenseChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full" aria-label="График расходов по VPS">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@@ -155,7 +155,7 @@ export function PaymentsPieChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных о платежах" />
) : (
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full">
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full" aria-label="График платежей по типам">
<PieChart>
<RechartsTooltip
content={
@@ -215,7 +215,7 @@ export function MonthlyTrendChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
<ChartContainer config={trendConfig} className="h-72 w-full">
<ChartContainer config={trendConfig} className="h-72 w-full" aria-label="График тренда расходов">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
@@ -281,7 +281,7 @@ export function ProjectExpenseChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={chartConfig} className="h-72 w-full">
<ChartContainer config={chartConfig} className="h-72 w-full" aria-label="График расходов по проектам">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@@ -44,6 +44,7 @@ export function VpsBulkToolbar({
<div className="flex items-center gap-1">
<SelectField
placeholder="Проект…"
aria-label="Проект для массового назначения"
value={projectValue}
onValueChange={(v) => setProjectValue(v ?? '')}
options={projectOptions.map((p) => ({ value: p, label: p }))}
+25 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
interface FormFieldProps {
@@ -10,11 +10,33 @@ interface FormFieldProps {
children: ReactNode
}
function withFieldA11y(child: ReactNode, isInvalid: boolean, htmlFor?: string): ReactNode {
if (!isValidElement(child)) return child
const childProps = child.props as Record<string, unknown>
const props: Record<string, unknown> = {}
if (isInvalid) {
props['aria-invalid'] = true
props.invalid = true
}
if (htmlFor && childProps.id == null && childProps.triggerId == null) {
if ('triggerId' in childProps) {
props.triggerId = htmlFor
} else {
props.id = htmlFor
}
}
return Object.keys(props).length > 0 ? cloneElement(child as ReactElement, props) : child
}
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
const isInvalid = invalid || Boolean(error)
return (
<Field data-invalid={invalid || Boolean(error)}>
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{withFieldA11y(children, isInvalid, htmlFor)}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{error ? <FieldError>{error}</FieldError> : null}
</Field>
+5 -4
View File
@@ -22,6 +22,7 @@ import {
} from '@cfdm/ui/components/command'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { providerByIdMap } from '@/lib/billmanager'
import { TruncatedText } from '@/components/truncated-text'
interface GlobalSearchProps {
open: boolean
@@ -73,7 +74,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
>
<ServerIcon />
<span className="truncate">{v.ip || v.dns || v.id}</span>
<TruncatedText>{v.ip || v.dns || v.id}</TruncatedText>
{v.project ? (
<span className="text-muted-foreground text-xs">{v.project}</span>
) : null}
@@ -89,7 +90,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/accounts')}
>
<WalletIcon />
<span className="truncate">{a.name}</span>
<TruncatedText>{a.name}</TruncatedText>
{providerById.get(a.providerId)?.name ? (
<span className="text-muted-foreground text-xs">
{providerById.get(a.providerId)?.name}
@@ -109,7 +110,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/vps', { project: row.name })}
>
<FolderKanbanIcon />
<span className="truncate">{row.name}</span>
<TruncatedText>{row.name}</TruncatedText>
</CommandItem>
)
})}
@@ -119,7 +120,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
{(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon />
<span className="truncate">{p.name}</span>
<TruncatedText>{p.name}</TruncatedText>
</CommandItem>
))}
</CommandGroup>
+6 -2
View File
@@ -48,6 +48,7 @@ import { ModeToggle } from '@/components/mode-toggle'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
import { TruncatedText } from '@/components/truncated-text'
interface NavItem {
to: string
@@ -200,9 +201,12 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarMenuItem>
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
<Settings />
<span className="truncate text-xs text-muted-foreground">
<TruncatedText
className="text-xs text-muted-foreground"
tooltip={`Синк: ${formatRelativeSyncTime(stats?.lastGlobalSyncAt)}`}
>
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
</span>
</TruncatedText>
{stats?.staleSyncAccountCount ? (
<Badge variant="outline" className="ml-auto text-xs">
<RefreshCwIcon className="size-3" />
@@ -0,0 +1,18 @@
import { cn } from '@cfdm/ui/lib/utils'
interface ProjectColorDotProps {
color?: string | null
className?: string
}
export function ProjectColorDot({ color, className }: ProjectColorDotProps) {
if (!color) return null
return (
<span
className={cn('inline-block size-2.5 shrink-0 rounded-full', className)}
style={{ backgroundColor: color }}
aria-hidden
/>
)
}
@@ -129,6 +129,7 @@ export function ReportsFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-44"
placeholder="Период"
aria-label="Период отчёта"
value={filters.period}
onValueChange={(v) =>
onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' })
@@ -29,6 +29,9 @@ interface DataGridPaginationProps {
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
pageLabel?: string
previousPagesLabel?: string
nextPagesLabel?: string
ellipsisText?: string
}
@@ -47,6 +50,9 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
pageLabel: "Page {page}",
previousPagesLabel: "Previous pages",
nextPagesLabel: "Next pages",
ellipsisText: "...",
}
@@ -88,6 +94,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
key={i}
size="icon-sm"
variant="ghost"
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
@@ -112,6 +120,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={mergedProps.previousPagesLabel}
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
@@ -129,6 +138,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
aria-label={mergedProps.nextPagesLabel}
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
@@ -146,7 +156,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
@@ -164,7 +174,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
<SelectContent className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
@@ -184,7 +194,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<div className="order-1 flex items-center gap-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
+12 -3
View File
@@ -1,6 +1,7 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export interface SectionCardItem {
label: ReactNode
@@ -15,7 +16,7 @@ export interface SectionCardItem {
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-amber-500/50',
warning: 'border-warning/50',
destructive: 'border-destructive/50',
}
@@ -42,13 +43,21 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
{typeof item.label === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
)}
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
{item.hint ? (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
typeof item.hint === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
)
) : null}
</div>
</div>
+11 -1
View File
@@ -23,6 +23,8 @@ interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'valu
size?: 'sm' | 'default'
value?: string | null
onValueChange?: (value: string | null) => void
invalid?: boolean
'aria-label'?: string
}
export function SelectField({
@@ -33,6 +35,8 @@ export function SelectField({
size = 'default',
value,
onValueChange,
invalid,
'aria-label': ariaLabel,
...props
}: SelectFieldProps) {
const items = React.useMemo(
@@ -42,7 +46,13 @@ export function SelectField({
return (
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
<SelectTrigger id={triggerId} size={size} className={cn('w-full', triggerClassName)}>
<SelectTrigger
id={triggerId}
size={size}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
className={cn('w-full', triggerClassName)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface TruncatedTextProps {
children: ReactNode
className?: string
as?: 'span' | 'p' | 'div'
/** Явный текст подсказки, если children — не строка. */
tooltip?: string
}
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
const tip =
tooltip ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
if (!tip) {
return <Tag className={cn('truncate', className)}>{children}</Tag>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -13,6 +13,7 @@ import {
import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import { ConfirmDialog } from '@/components/confirm-dialog'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
@@ -652,14 +653,22 @@ export function VpsFiltersToolbar({
>
{p.name}
</button>
<button
type="button"
onClick={() => deletePreset(p.name)}
aria-label="Удалить пресет"
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
<ConfirmDialog
title="Удалить пресет?"
description={`Пресет «${p.name}» будет удалён без возможности восстановления.`}
confirmLabel="Удалить"
destructive
onConfirm={() => deletePreset(p.name)}
trigger={
<button
type="button"
aria-label={`Удалить пресет ${p.name}`}
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
}
/>
</div>
))}
</div>