import { useEffect, useId, useRef, useState, type ButtonHTMLAttributes, type InputHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from 'react'; import { useTranslation } from 'react-i18next'; import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons'; import s from './ui.module.css'; const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' '); /** Иконка-маска: цвет берётся из currentColor. */ export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) { return ( ); } export function Logo({ size = 28 }: { size?: number }) { const { t } = useTranslation(); return ( {t('app.name')} ); } type ButtonProps = ButtonHTMLAttributes & { variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; icon?: string; busy?: boolean; }; export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) { return ( ); } type FieldProps = Omit, 'onChange'> & { label: string; value: string; onChange: (v: string) => void; error?: string; hint?: string; mono?: boolean; }; /** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */ export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) { const { t } = useTranslation(); const id = useId(); const [shown, setShown] = useState(false); const isPassword = type === 'password'; const note = error ?? hint; return (
onChange(e.target.value)} aria-invalid={error ? true : undefined} aria-describedby={note ? `${id}-note` : undefined} className={cx(s.input, mono && s.mono, error && s.invalid)} {...rest} /> {isPassword && ( )}
{note && (
{note}
)}
); } /** Сегментированный переключатель. */ export function Segmented({ label, value, options, onChange, }: { label: string; value: T; options: { value: T; label: string }[]; onChange: (v: T) => void; }) { return (
{options.map((o) => ( ))}
); } export function FormError({ children }: { children?: ReactNode }) { if (!children) return null; return (
{children}
); } /** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */ export function Dialog({ title, onClose, children, footer, }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; }) { const { t } = useTranslation(); const ref = useRef(null); const titleId = useId(); useEffect(() => { const prev = document.activeElement as HTMLElement | null; const first = ref.current?.querySelector('input, select, button:not([data-close])'); (first ?? ref.current)?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => { document.removeEventListener('keydown', onKey); prev?.focus(); }; }, [onClose]); return (
e.target === e.currentTarget && onClose()}>

{title}

{children}
{footer &&
{footer}
}
); } export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void }; /** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */ export function Menu({ label, trigger, items, header, align = 'end', triggerClassName, }: { label: string; trigger: ReactNode; items: MenuItem[]; header?: ReactNode; align?: 'start' | 'end'; triggerClassName?: string; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); ref.current?.querySelector('[role="menuitem"]')?.focus(); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open]); const onMenuKey = (e: ReactKeyboardEvent) => { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); const els = [...(ref.current?.querySelectorAll('[role="menuitem"]') ?? [])]; const i = els.indexOf(document.activeElement as HTMLElement); const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length; els[next]?.focus(); }; return (
{open && (
{header} {items.map((it) => ( ))}
)}
); } export function Avatar({ name, size = 32 }: { name: string; size?: number }) { const initials = name .split(/[\s._-]+/) .filter(Boolean) .slice(0, 2) .map((p) => p[0]!.toUpperCase()) .join(''); return ( {initials || '?'} ); } export { cx };