fix(web): UX/UI аудит — пагинация, a11y, settings и TruncatedText
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Исправить обрезку Select в пагинации DataGrid, health-mode zero-results, подтверждение импорта бэкапа и унифицировать tooltip на обрезанном тексте. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user