Răsfoiți Sursa

fix(frontend): resolve every axe accessibility violation in the component library

Running the stories under axe surfaced real panel defects, not just story cosmetics. FormField never associated its Form.Item label with the wrapped control, so no RHF form field in the panel had a programmatic label; it now generates an id and wires htmlFor. Unnamed controls get accessible names: the prompt and text modal inputs (from the modal title), the client traffic progress bar (used/limit values), the CPU and RAM threshold inputs in the notification groups (event label threaded through the extra renderer), and the JSON editor's contenteditable surface.

ConfigBlock's collapse header carried role=button around focusable action buttons; collapsible=header scopes the toggle to the label. Light theme gains contrast-safe tokens shared by the panel and Storybook: darker description, placeholder, error and success text, a darker primary button blue, and a readable gold tag, all meeting the WCAG AA 4.5:1 ratio. The infinity badge swaps a prohibited bare aria-label for role=img.
MHSanaei 10 ore în urmă
părinte
comite
60316c831f

+ 2 - 1
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -64,6 +64,7 @@ export default function ClientTrafficCell({
         <span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
         <Progress
           className="client-traffic-cell-bar"
+          aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
           percent={display.percent}
           showInfo={false}
           strokeColor={display.strokeColor}
@@ -72,7 +73,7 @@ export default function ClientTrafficCell({
         />
         <span className="client-traffic-cell-limit">
           {display.isUnlimited ? (
-            <span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
+            <span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
               <InfinityIcon />
             </span>
           ) : (

+ 4 - 0
frontend/src/components/clients/ConfigBlock.css

@@ -12,6 +12,10 @@
   min-width: 0;
 }
 
+body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
+  color: #874d00;
+}
+
 .config-block .ant-collapse-extra {
   display: flex;
   align-items: center;

+ 1 - 0
frontend/src/components/clients/ConfigBlock.tsx

@@ -68,6 +68,7 @@ export default function ConfigBlock({
       {messageContextHolder}
       <Collapse
         className="config-block"
+        collapsible="header"
         defaultActiveKey={defaultOpen ? ['cfg'] : []}
         items={[{
           key: 'cfg',

+ 2 - 0
frontend/src/components/feedback/PromptModal.tsx

@@ -72,6 +72,7 @@ export default function PromptModal({
       ) : type === 'textarea' ? (
         <Input.TextArea
           ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
+          aria-label={title}
           value={value}
           onChange={(e) => setValue(e.target.value)}
           autoSize={{ minRows: 10, maxRows: 20 }}
@@ -80,6 +81,7 @@ export default function PromptModal({
       ) : (
         <Input
           ref={inputRef}
+          aria-label={title}
           value={value}
           onChange={(e) => setValue(e.target.value)}
           onKeyDown={onKeydown}

+ 1 - 0
frontend/src/components/feedback/TextModal.tsx

@@ -75,6 +75,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
         <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
       ) : (
         <Input.TextArea
+          aria-label={title}
           value={activeContent}
           readOnly
           autoSize={{ minRows: 10, maxRows: 20 }}

+ 1 - 0
frontend/src/components/form/JsonEditor.tsx

@@ -120,6 +120,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
         doc: value,
         extensions: [
           basicSetup,
+          EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
           keymap.of([indentWithTab]),
           json(),
           linter(jsonParseLinter()),

+ 6 - 1
frontend/src/components/form/rhf/FormField.tsx

@@ -1,4 +1,4 @@
-import { cloneElement } from 'react';
+import { cloneElement, useId } from 'react';
 import type { CSSProperties, ReactElement, ReactNode } from 'react';
 import { Controller } from 'react-hook-form';
 import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
@@ -48,6 +48,9 @@ export function FormField<T extends FieldValues = FieldValues>({
 }: FormFieldProps<T>) {
   const { t } = useTranslation();
   const dottedName = toDotted(name) as Path<T>;
+  const generatedId = useId();
+  const explicitId = (children as ReactElement<{ id?: string }>).props.id;
+  const fieldId = explicitId ?? generatedId;
 
   return (
     <Controller
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
           ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
           : undefined;
         const childProps: Record<string, unknown> = {
+          id: fieldId,
           [valueProp]: displayValue,
           onChange: (...args: unknown[]) => {
             const raw = normalizeAntdOnChange(args, valueProp);
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
         return (
           <Form.Item
             label={label}
+            htmlFor={label ? fieldId : undefined}
             tooltip={tooltip}
             extra={extra}
             required={required}

+ 4 - 4
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
         key: 'cpu.high',
         label: 'eventCPUHigh',
         settingKey: 'smtpCpu',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
       {
         key: 'memory.high',
         label: 'eventMemoryHigh',
         settingKey: 'smtpMemory',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
     ],

+ 1 - 0
frontend/src/components/ui/notifications/NotificationGroup.tsx

@@ -51,6 +51,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
             {event.extra?.({
               value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
               onChange: (v) => updateSetting({ [event.settingKey]: v }),
+              ariaLabel: t(`pages.settings.${event.label}`),
             })}
           </NotificationEvent>
         ))}

+ 4 - 4
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
         key: 'cpu.high',
         label: 'eventCPUHigh',
         settingKey: 'tgCpu',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
       {
         key: 'memory.high',
         label: 'eventMemoryHigh',
         settingKey: 'tgMemory',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
     ],

+ 1 - 1
frontend/src/components/ui/notifications/types.ts

@@ -4,7 +4,7 @@ export interface NotificationEventConfig {
   key: string;
   label: string;
   settingKey: string;
-  extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
+  extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
 }
 
 export interface NotificationGroupConfig {

+ 15 - 0
frontend/src/hooks/useTheme.tsx

@@ -78,13 +78,28 @@ const STATISTIC_TOKENS = {
   contentFontSize: 17,
   titleFontSize: 11,
 };
+const LIGHT_CONTRAST_TOKENS = {
+  colorTextDescription: 'rgba(0, 0, 0, 0.58)',
+  colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
+  colorTextPlaceholder: '#767676',
+  colorError: '#cf1322',
+  colorErrorText: '#cf1322',
+  colorSuccessText: '#237804',
+};
+const LIGHT_BUTTON_TOKENS = {
+  colorPrimary: '#0958d9',
+  colorPrimaryHover: '#2468e5',
+  colorPrimaryActive: '#073ea8',
+};
 
 export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
   if (!isDark) {
     return {
       algorithm: antdTheme.defaultAlgorithm,
+      token: LIGHT_CONTRAST_TOKENS,
       components: {
         Statistic: STATISTIC_TOKENS,
+        Button: LIGHT_BUTTON_TOKENS,
       },
     };
   }