release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type ButtonHTMLAttributes,
|
|
type CSSProperties,
|
|
type InputHTMLAttributes,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
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 (
|
|
<span
|
|
aria-hidden
|
|
className={cx(s.icon, className)}
|
|
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Logo({ size = 28 }: { size?: number }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
|
|
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
|
|
{t('app.name')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
icon?: string;
|
|
busy?: boolean;
|
|
};
|
|
|
|
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
{...rest}
|
|
disabled={disabled || busy}
|
|
aria-busy={busy || undefined}
|
|
className={cx(s.button, s[variant], className)}
|
|
>
|
|
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, '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 (
|
|
<div className={cx(s.field, className)}>
|
|
<label htmlFor={id} className={s.label}>
|
|
{label}
|
|
</label>
|
|
<div className={s.inputWrap}>
|
|
<input
|
|
id={id}
|
|
type={isPassword && shown ? 'text' : type}
|
|
value={value}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
type="button"
|
|
className={s.reveal}
|
|
onClick={() => setShown((v) => !v)}
|
|
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
|
|
>
|
|
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{note && (
|
|
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
|
|
{note}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Сегментированный переключатель. */
|
|
export function Segmented<T extends string>({
|
|
label,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: T;
|
|
options: { value: T; label: string }[];
|
|
onChange: (v: T) => void;
|
|
}) {
|
|
return (
|
|
<div className={s.segmented} role="group" aria-label={label}>
|
|
{options.map((o) => (
|
|
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FormError({ children }: { children?: ReactNode }) {
|
|
if (!children) return null;
|
|
return (
|
|
<div className={s.formError} role="alert">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
|
|
export function Dialog({
|
|
title,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
}: {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
useEffect(() => {
|
|
const prev = document.activeElement as HTMLElement | null;
|
|
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
|
(first ?? ref.current)?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'Tab') {
|
|
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
|
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
|
) ?? [])].filter((el) => el.getClientRects().length > 0);
|
|
const first = controls[0];
|
|
const last = controls[controls.length - 1];
|
|
if (e.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {
|
|
e.preventDefault(); last?.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault(); first?.focus();
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
prev?.focus();
|
|
};
|
|
}, [onClose]);
|
|
return createPortal(
|
|
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
|
<div className={s.dialogHead}>
|
|
<h2 id={titleId}>{title}</h2>
|
|
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
|
<Icon src={closeIcon} />
|
|
</button>
|
|
</div>
|
|
<div className={s.dialogBody}>{children}</div>
|
|
{footer && <div className={s.dialogFoot}>{footer}</div>}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node) && !menuRef.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);
|
|
menuRef.current?.querySelector<HTMLElement>('[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 = [...(menuRef.current?.querySelectorAll<HTMLElement>('[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();
|
|
};
|
|
const menu = (
|
|
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
|
{header}
|
|
{items.map((it) => (
|
|
<button
|
|
key={it.label}
|
|
type="button"
|
|
role="menuitem"
|
|
className={cx(s.menuItem, it.danger && s.menuDanger)}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
it.onSelect();
|
|
}}
|
|
>
|
|
{it.icon && <Icon src={it.icon} size={18} />}
|
|
{it.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
return (
|
|
<div className={s.menuWrap} ref={ref}>
|
|
<button
|
|
type="button"
|
|
className={triggerClassName ?? s.iconButton}
|
|
aria-label={label}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
{trigger}
|
|
</button>
|
|
{open && (window.matchMedia?.('(max-width: 767px)').matches
|
|
? createPortal(menu, document.body)
|
|
: menu)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
|
|
export function EmptyState({
|
|
icon,
|
|
children,
|
|
action,
|
|
}: {
|
|
icon?: string;
|
|
children: ReactNode;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={s.empty} role="status">
|
|
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
|
|
<p className={s.emptyText}>{children}</p>
|
|
{action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Skeleton({
|
|
className,
|
|
style,
|
|
round,
|
|
}: {
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
round?: boolean;
|
|
}) {
|
|
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
|
|
}
|
|
|
|
/** Каркас героя и строк списка, пока страница ещё грузится. */
|
|
export function PageSkeleton() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
|
|
<div className={s.skelHero}>
|
|
<Skeleton className={s.skelCover} />
|
|
<div className={s.skelCopy}>
|
|
<Skeleton style={{ width: 72, height: 12 }} />
|
|
<Skeleton style={{ width: '55%', height: 28 }} />
|
|
<Skeleton style={{ width: '38%', height: 14 }} />
|
|
</div>
|
|
</div>
|
|
{Array.from({ length: 6 }, (_, i) => (
|
|
<div key={i} className={s.skelRow}>
|
|
<Skeleton style={{ width: 40, height: 40 }} />
|
|
<Skeleton style={{ flex: 1, height: 14 }} />
|
|
<Skeleton style={{ width: 36, height: 12 }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
|
|
{initials || '?'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export { cx };
|