Files
3x-ui/frontend/src/components/form/RemarkTemplateField.tsx
T
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
2026-10-23 18:29:47 +02:00

134 lines
3.9 KiB
TypeScript

import { useRef } from 'react';
import { Button, Input, Popover, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { CodeOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import {
hasRemarkTokens,
previewRemark,
SUBSCRIPTION_METADATA_VARIABLES,
wrapToken,
} from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
interface RemarkTemplateFieldProps {
// Injected by antd Form.Item:
value?: string;
onChange?: (value: string) => void;
maxLength?: number;
placeholder?: string;
multiline?: boolean;
rows?: number;
metadataOnly?: boolean;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
}
/**
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
* (insert-at-caret) and a live, sample-based preview of the expanded result.
* Used for subscription text fields that support Remark Template variables.
*/
export default function RemarkTemplateField({
value = '',
onChange,
maxLength,
placeholder,
multiline = false,
rows,
metadataOnly = false,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: RemarkTemplateFieldProps) {
const { t } = useTranslation();
const inputRef = useRef<InputRef>(null);
const textAreaRef = useRef<TextAreaRef>(null);
const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
function getTextElement() {
if (multiline) {
return textAreaRef.current?.resizableTextArea?.textArea ?? null;
}
return inputRef.current?.input ?? null;
}
function insertToken(token: string) {
const el = getTextElement();
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const insert = wrapToken(token);
const next = value.slice(0, start) + insert + value.slice(end);
onChange?.(maxLength ? next.slice(0, maxLength) : next);
const caret = start + insert.length;
// The controlled value updates next render; restore the caret after it.
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
}
const pickerButton = (
<Popover
content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button
type="text"
size="small"
icon={<CodeOutlined aria-hidden />}
aria-label={t('pages.hosts.remarkVars.title')}
style={{ marginInlineEnd: -7 }}
/>
</Tooltip>
</Popover>
);
return (
<div>
{multiline ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea
ref={textAreaRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value}
maxLength={maxLength}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange?.(e.target.value)}
/>
{pickerButton}
</div>
) : (
<Input
ref={inputRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
suffix={pickerButton}
/>
)}
{hasRemarkTokens(value) && (
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
{t('pages.hosts.remarkVars.preview')}:{' '}
<span style={{ fontFamily: 'monospace' }}>
{previewRemark(value, variables, metadataOnly) || '—'}
</span>
</div>
)}
</div>
);
}