fix(ui): завершить доступность формы пользователей

This commit is contained in:
Denozordec committed 2026-10-01 10:35:34 +07:00
1 parent 9320c11d58
commit 1e0fc4cd2d
19 files changed
+139 -60

No files matched your search

+11 -9
View File
@@ -31,9 +31,9 @@ import {
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
<div aria-hidden="true"
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
"size-8 rounded-full flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
!active && "opacity-50",
)}
>
@@ -74,11 +74,13 @@ function UsersDataGrid({
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
</Button>
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
@@ -137,7 +139,7 @@ function UsersDataGrid({
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
<CableIcon aria-hidden="true" className="size-3.5" />
{row.original.bindings.length}
</span>
),
@@ -182,14 +184,14 @@ function UsersDataGrid({
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
type="button"
disabled={actionsDisabled}
aria-label={"Редактировать пользователя " + u.name}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
<PencilIcon aria-hidden="true" className="size-3.5" />
</Button>
<Button
size="sm"
@@ -201,7 +203,7 @@ function UsersDataGrid({
aria-label={"Удалить пользователя " + u.name}
title="Удалить"
>
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
</Button>
</div>
)
@@ -228,7 +230,7 @@ function UsersDataGrid({
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
icon={<UsersIcon aria-hidden="true" className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
+63 -41
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -79,28 +79,31 @@ function pickKey(p: CatalogPick): string {
}
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
"Управление": <WrenchIcon className="size-3" />,
"Инструменты": <ShieldIcon className="size-3" />,
"Система": <ServerIcon className="size-3" />,
"Обзор": <LayoutDashboardIcon aria-hidden="true" className="size-3" />,
"Данные": <EyeIcon aria-hidden="true" className="size-3" />,
"Управление": <WrenchIcon aria-hidden="true" className="size-3" />,
"Инструменты": <ShieldIcon aria-hidden="true" className="size-3" />,
"Система": <ServerIcon aria-hidden="true" className="size-3" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
function PermPills({ value, onChange, disabled, label }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean; label: string }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
<div role="group" aria-label={"Права: " + label} className={cn("flex shrink-0 rounded border border-input overflow-hidden min-h-8", disabled && "opacity-40")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
disabled={disabled}
aria-label={label + ": " + o.label}
aria-pressed={value === o.v}
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
"px-2 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
? o.v === "write" ? "bg-success/15 text-success"
: o.v === "read" ? "bg-info/15 text-info"
: "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
@@ -131,10 +134,13 @@ function toForm(user: AppUser): AppUserForm {
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
const input = document.createElement("input")
input.type = "email"; input.value = email
return email.length > 0 && input.checkValidity()
}
interface UserSheetProps {
returnFocus?: RefObject<HTMLElement | null>
open: boolean
user: AppUser | null
users: AppUser[]
@@ -151,8 +157,10 @@ interface UserSheetProps {
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose,
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose, returnFocus,
}: UserSheetProps) {
const saveErrorRef = useRef<HTMLDivElement>(null)
useEffect(() => { if (saveError) saveErrorRef.current?.scrollIntoView({ block: "nearest" }) }, [saveError])
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
@@ -203,23 +211,23 @@ function UserSheet({
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
sections: [...f.sections.filter((s) => s.section !== section), { section, level }],
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
servers: [...f.servers.filter((s) => s.serverId !== serverId), { serverId, level }],
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
setForm((f) => ({ ...f, sections: SECTION_GROUP_DEFS.flatMap((group) => group.items.map((section) => ({ section, level }))) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
setForm((f) => ({ ...f, servers: servers.map((server) => ({ serverId: server.id, level })) }))
}
const submit = () => {
@@ -230,6 +238,7 @@ function UserSheet({
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
requestAnimationFrame(() => document.getElementById(e.name ? "user-full-name" : "user-email")?.focus())
return
}
onSave({ ...form, email, login: email })
@@ -303,7 +312,7 @@ function UserSheet({
return (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetContent finalFocus={returnFocus} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
@@ -311,14 +320,13 @@ function UserSheet({
</SheetDescription>
</SheetHeader>
{saveError && <div role="alert" aria-label="Сохранение пользователя" className="px-6 py-3 text-sm text-destructive">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
<fieldset disabled={saving} className="flex-1 min-h-0 flex flex-col">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<fieldset disabled={saving} className="flex-1 min-h-0 min-w-0 flex flex-col">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 min-w-0 px-4 sm:px-6 py-5">
<StepperNav className="mb-3">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
<StepperTitle className="sr-only sm:not-sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
@@ -343,21 +351,30 @@ function UserSheet({
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<p className="text-sm font-medium mb-3">{["Профиль", "Разделы", "Серверы", "Интерфейсы"][sheetStep - 1]} · {sheetStep}/4</p>
<StepperPanel className="flex-1 min-h-0 overflow-y-auto [overflow-wrap:anywhere]">
{saveError && <div role="alert" aria-label="Сохранение пользователя" ref={saveErrorRef} className="py-3 text-sm text-destructive [overflow-wrap:anywhere]">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Полное имя" required error={errors.name}>
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
<Input id="user-full-name" aria-label="Полное имя" aria-invalid={!!errors.name} aria-describedby={errors.name ? "user-name-error" : undefined} value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
{errors.name && <span id="user-name-error" className="sr-only">{errors.name}</span>}
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
id="user-email"
aria-label="Email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "user-email-error" : undefined}
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="i.ivanov@company.io"
type="email"
/>
{errors.email && <span id="user-email-error" className="sr-only">{errors.email}</span>}
</FormField>
<FormField label="Роль" required>
<SegmentedControl
className="w-full flex-col items-stretch sm:flex-row [&>button]:whitespace-nowrap [&>button]:min-h-9"
value={form.role}
onChange={setRole}
options={[
@@ -368,8 +385,8 @@ function UserSheet({
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
<span className="text-sm font-medium">Активный профиль</span>
<FormToggle ariaLabel="Активный профиль" checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
@@ -393,7 +410,7 @@ function UserSheet({
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
<ShieldIcon aria-hidden="true" className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
@@ -410,9 +427,9 @@ function UserSheet({
const perm = form.sections.find((s) => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
<div key={section} className="flex flex-wrap items-center justify-between gap-2 py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
<PermPills label={section} value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
@@ -445,16 +462,16 @@ function UserSheet({
const perm = form.servers.find((s) => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div key={srv.id} className="flex flex-wrap items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex flex-1 items-center gap-2.5 min-w-0">
{srv.status ? <StatusDot status={srv.status} /> : null}
{srv.country ? <Flag code={srv.country} size={14} /> : null}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
<p className="text-[11px] font-mono text-muted-foreground truncate" title={srv.host}>{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
<PermPills label={srv.name} value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
@@ -509,7 +526,7 @@ function UserSheet({
<Item
size="xs"
variant={open ? "muted" : "default"}
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
render={<button type="button" aria-expanded={open} onClick={() => setExpandedWg(open ? null : iface.name)} />}
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
>
<ItemMedia>
@@ -527,7 +544,7 @@ function UserSheet({
{legacyMine ? (
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
) : null}
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
<ChevronDownIcon aria-hidden="true" className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
</ItemActions>
</Item>
{open ? (
@@ -558,6 +575,7 @@ function UserSheet({
<button
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => togglePick(pick)}
/>
}
@@ -569,7 +587,7 @@ function UserSheet({
>
<ItemMedia>
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
<KeyRoundIcon />
<KeyRoundIcon aria-hidden="true" />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
@@ -612,6 +630,7 @@ function UserSheet({
<button
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => togglePick(pick)}
/>
}
@@ -652,12 +671,13 @@ function UserSheet({
</Frame>
<Input
className="h-8 text-xs"
aria-label="Комментарий привязки"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
<PlusIcon aria-hidden="true" className="size-3.5" />Привязать
</Button>
</div>
@@ -678,9 +698,11 @@ function UserSheet({
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
type="button"
aria-label={"Удалить привязку " + bindingTitle(b) + " · " + b.serverName}
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
</Button>
</div>
))}
@@ -689,7 +711,7 @@ function UserSheet({
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>