b28c0463b3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
import { type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes, useEffect } from 'react';
|
||
|
||
export function Button({
|
||
variant = 'default',
|
||
...props
|
||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'danger' | 'ghost' }) {
|
||
return <button {...props} className={`btn btn--${variant} ${props.className ?? ''}`} />;
|
||
}
|
||
|
||
export function Field(
|
||
props: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string | undefined },
|
||
) {
|
||
const { label, error, ...input } = props;
|
||
return (
|
||
<label className="field">
|
||
<span className="field__label">{label}</span>
|
||
<input {...input} className={`field__input ${error ? 'field__input--err' : ''}`} />
|
||
{error ? <span className="field__error">{error}</span> : null}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
|
||
const { label, ...textarea } = props;
|
||
return (
|
||
<label className="field">
|
||
<span className="field__label">{label}</span>
|
||
<textarea {...textarea} className="field__input field__input--area" />
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function Select<T extends string>(
|
||
props: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> & {
|
||
label: string;
|
||
value: T;
|
||
onChange: (v: T) => void;
|
||
options: ReadonlyArray<{ value: T; label: string }>;
|
||
},
|
||
) {
|
||
const { label, value, onChange, options, ...sel } = props;
|
||
return (
|
||
<label className="field">
|
||
<span className="field__label">{label}</span>
|
||
<select
|
||
{...sel}
|
||
className="field__input"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value as T)}
|
||
>
|
||
{options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function Modal({
|
||
open,
|
||
title,
|
||
onClose,
|
||
children,
|
||
footer,
|
||
}: {
|
||
open: boolean;
|
||
title: string;
|
||
onClose: () => void;
|
||
children: ReactNode;
|
||
footer?: ReactNode;
|
||
}) {
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [open, onClose]);
|
||
if (!open) return null;
|
||
return (
|
||
<div className="modal__backdrop" onClick={onClose}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<header className="modal__header">
|
||
<h3>{title}</h3>
|
||
<button className="modal__close" onClick={onClose} aria-label="Закрыть">
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="modal__body">{children}</div>
|
||
{footer ? <footer className="modal__footer">{footer}</footer> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function EmptyState({ children }: { children: ReactNode }) {
|
||
return <div className="empty">{children}</div>;
|
||
}
|
||
|
||
export function formatRub(cents: number): string {
|
||
return (cents / 100).toLocaleString('ru-RU', {
|
||
style: 'currency',
|
||
currency: 'RUB',
|
||
minimumFractionDigits: 2,
|
||
});
|
||
}
|