From 5c1c94bc57ebec3172dcd5a116ccea73a5edca4c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 12:53:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20UX/UI=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20a11y,=20settings=20=D0=B8=20TruncatedText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправить обрезку Select в пагинации DataGrid, health-mode zero-results, подтверждение импорта бэкапа и унифицировать tooltip на обрезанном тексте. Co-authored-by: Cursor --- .../components/account-filters-toolbar.tsx | 2 + .../src/components/auto-complete-input.tsx | 5 +- apps/web/src/components/data-grid-card.tsx | 74 +++--- apps/web/src/components/data-grid-cells.tsx | 13 +- apps/web/src/components/domain/charts.tsx | 8 +- .../components/domain/vps-bulk-toolbar.tsx | 1 + apps/web/src/components/form-field.tsx | 28 ++- apps/web/src/components/global-search.tsx | 9 +- apps/web/src/components/layout/app-shell.tsx | 8 +- apps/web/src/components/project-color-dot.tsx | 18 ++ .../components/reports-filters-toolbar.tsx | 1 + .../reui/data-grid/data-grid-pagination.tsx | 16 +- apps/web/src/components/section-cards.tsx | 15 +- apps/web/src/components/select-field.tsx | 12 +- apps/web/src/components/truncated-text.tsx | 36 +++ .../src/components/vps-filters-toolbar.tsx | 25 +- apps/web/src/routes/_auth/accounts.tsx | 15 +- apps/web/src/routes/_auth/audit.tsx | 18 +- apps/web/src/routes/_auth/balance.tsx | 2 +- apps/web/src/routes/_auth/dashboard.tsx | 6 +- .../src/routes/_auth/projects.$projectId.tsx | 73 +++--- apps/web/src/routes/_auth/projects.tsx | 5 +- apps/web/src/routes/_auth/renewals.tsx | 13 +- apps/web/src/routes/_auth/resources.tsx | 7 +- apps/web/src/routes/_auth/settings.tsx | 224 +++++++++++------- apps/web/src/routes/_auth/tariffs.tsx | 16 +- apps/web/src/routes/_auth/vps.$vpsId.tsx | 23 +- apps/web/src/routes/_auth/vps.tsx | 18 +- 28 files changed, 472 insertions(+), 219 deletions(-) create mode 100644 apps/web/src/components/project-color-dot.tsx create mode 100644 apps/web/src/components/truncated-text.tsx diff --git a/apps/web/src/components/account-filters-toolbar.tsx b/apps/web/src/components/account-filters-toolbar.tsx index 2d83f8b..296ba15 100644 --- a/apps/web/src/components/account-filters-toolbar.tsx +++ b/apps/web/src/components/account-filters-toolbar.tsx @@ -72,6 +72,7 @@ export function AccountFiltersToolbar({ onChange({ ...filters, providerIds: v ? [v] : [] })} options={providers.map((p) => ({ value: p.id, label: p.name }))} @@ -79,6 +80,7 @@ export function AccountFiltersToolbar({ onChange({ diff --git a/apps/web/src/components/auto-complete-input.tsx b/apps/web/src/components/auto-complete-input.tsx index 3c698fd..cd357ef 100644 --- a/apps/web/src/components/auto-complete-input.tsx +++ b/apps/web/src/components/auto-complete-input.tsx @@ -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 ? ( {item.leading} ) : null} - {item.label} + + {item.label} + {isSelected ? ( ) : null} diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index d3e582d..1b11de1 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -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 ( -
+
) @@ -170,46 +173,41 @@ function DataGridCardBody({ enableColumnVisibility: boolean }) { return ( - - + + {virtualization ? ( - <> - - - - {showPagination ? : null} - + + + ) : ( - <> - - {showPagination ? : null} - + )} - - + + {showPagination ? : null} + ) } diff --git a/apps/web/src/components/data-grid-cells.tsx b/apps/web/src/components/data-grid-cells.tsx index df705d7..8870f85 100644 --- a/apps/web/src/components/data-grid-cells.tsx +++ b/apps/web/src/components/data-grid-cells.tsx @@ -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 (
- {primary} + {typeof primary === 'string' || typeof primary === 'number' ? ( + {primary} + ) : ( + {primary} + )} {secondary ? ( - {secondary} + typeof secondary === 'string' || typeof secondary === 'number' ? ( + {secondary} + ) : ( + {secondary} + ) ) : null}
) diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index 805b99c..ae66395 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -91,7 +91,7 @@ export function MonthlyExpenseChart({ {data.length === 0 ? ( ) : ( - + @@ -155,7 +155,7 @@ export function PaymentsPieChart({ {data.length === 0 ? ( ) : ( - + ) : ( - + @@ -281,7 +281,7 @@ export function ProjectExpenseChart({ {data.length === 0 ? ( ) : ( - + diff --git a/apps/web/src/components/domain/vps-bulk-toolbar.tsx b/apps/web/src/components/domain/vps-bulk-toolbar.tsx index 79f4f86..0c5afc3 100644 --- a/apps/web/src/components/domain/vps-bulk-toolbar.tsx +++ b/apps/web/src/components/domain/vps-bulk-toolbar.tsx @@ -44,6 +44,7 @@ export function VpsBulkToolbar({
setProjectValue(v ?? '')} options={projectOptions.map((p) => ({ value: p, label: p }))} diff --git a/apps/web/src/components/form-field.tsx b/apps/web/src/components/form-field.tsx index 2251913..446cd43 100644 --- a/apps/web/src/components/form-field.tsx +++ b/apps/web/src/components/form-field.tsx @@ -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 + const props: Record = {} + 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 ( - + {label} - {children} + {withFieldA11y(children, isInvalid, htmlFor)} {description ?

{description}

: null} {error ? {error} : null}
diff --git a/apps/web/src/components/global-search.tsx b/apps/web/src/components/global-search.tsx index 5a22a07..16978ad 100644 --- a/apps/web/src/components/global-search.tsx +++ b/apps/web/src/components/global-search.tsx @@ -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 })} > - {v.ip || v.dns || v.id} + {v.ip || v.dns || v.id} {v.project ? ( {v.project} ) : null} @@ -89,7 +90,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { onSelect={() => go('/accounts')} > - {a.name} + {a.name} {providerById.get(a.providerId)?.name ? ( {providerById.get(a.providerId)?.name} @@ -109,7 +110,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { onSelect={() => go('/vps', { project: row.name })} > - {row.name} + {row.name} ) })} @@ -119,7 +120,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { {(snapshot?.providers ?? []).map((p) => ( go('/providers')}> - {p.name} + {p.name} ))} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 66bd902..4917cfb 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 }) { } tooltip="Настройки"> - + Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)} - + {stats?.staleSyncAccountCount ? ( diff --git a/apps/web/src/components/project-color-dot.tsx b/apps/web/src/components/project-color-dot.tsx new file mode 100644 index 0000000..318d6ed --- /dev/null +++ b/apps/web/src/components/project-color-dot.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/components/reports-filters-toolbar.tsx b/apps/web/src/components/reports-filters-toolbar.tsx index c288126..c22d96d 100644 --- a/apps/web/src/components/reports-filters-toolbar.tsx +++ b/apps/web/src/components/reports-filters-toolbar.tsx @@ -129,6 +129,7 @@ export function ReportsFiltersToolbar({ onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' }) diff --git a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx index c0cff54..b493d88 100644 --- a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx +++ b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx @@ -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 )} > -
+
{isLoading ? ( mergedProps?.sizesSkeleton ) : ( @@ -164,7 +174,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { - + {mergedProps?.sizes?.map((size: number) => ( {size} @@ -184,7 +194,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { {paginationInfo}
{pageCount > 1 && ( -
+