feat: примитивы Checkbox, Spinner, IconButton, Dialog, Link (0.10.0)
Набор B перекраски форм: брендовые адаптивные примитивы + иконки arrow-up/arrow-down/swap/refresh + токен $amber-50. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
min-height: 24px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Нативный input скрыт, но остаётся фокусируемым/доступным.
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid $gray-400;
|
||||
border-radius: 4px;
|
||||
background: $white;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
|
||||
// Выбран: navy-заливка + белая галка (контраст ~9:1).
|
||||
.input:checked + .box {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
}
|
||||
|
||||
.input:checked + .box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid $white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// Промежуточное (после :checked, чтобы перебить галку на чёрточку).
|
||||
.input:indeterminate + .box {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
}
|
||||
|
||||
.input:indeterminate + .box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 7px;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
border: 0;
|
||||
border-radius: 1px;
|
||||
background: $white;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// Фокус с клавиатуры — золотое кольцо (бренд).
|
||||
.input:focus-visible + .box {
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
border-color: $gold-500;
|
||||
}
|
||||
|
||||
// Ховер (когда доступен).
|
||||
.wrap:hover .input:not(:disabled) + .box {
|
||||
border-color: $navy-600;
|
||||
}
|
||||
|
||||
// Недоступен / только чтение.
|
||||
.input:disabled + .box {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.input:disabled ~ .text {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.wrap:has(.input:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// На узких экранах увеличиваем тач-таргет.
|
||||
@include below($bp-md) {
|
||||
.wrap { min-height: 32px; gap: 10px; }
|
||||
.box { width: 20px; height: 20px; }
|
||||
.input:checked + .box::after { left: 6px; top: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from './Checkbox';
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: 'Поля/Checkbox',
|
||||
component: Checkbox,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof Checkbox> = {
|
||||
render: () => {
|
||||
const [c, setC] = useState(false);
|
||||
return <Checkbox checked={c} onChange={setC} label="Согласен" />;
|
||||
},
|
||||
};
|
||||
|
||||
export const States: StoryObj<typeof Checkbox> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox checked={false} onChange={() => undefined} label="Не выбран" />
|
||||
<Checkbox checked onChange={() => undefined} label="Выбран" />
|
||||
<Checkbox checked={false} indeterminate onChange={() => undefined} label="Промежуточный (выбрать все)" />
|
||||
<Checkbox checked disabled onChange={() => undefined} label="Выбран, недоступен" />
|
||||
<Checkbox checked={false} disabled onChange={() => undefined} label="Недоступен" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** В ячейках таблиц — без видимой подписи, с ariaLabel для доступности. */
|
||||
export const InTableCell: StoryObj<typeof Checkbox> = {
|
||||
render: () => {
|
||||
const [c, setC] = useState(true);
|
||||
return <Checkbox checked={c} onChange={setC} ariaLabel="Выбрать строку" />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Checkbox.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
export interface ICheckboxProps {
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Промежуточное состояние (шапка «выбрать все» при частичном выборе). */
|
||||
indeterminate?: boolean;
|
||||
/** Недоступен (заблокирован). */
|
||||
disabled?: boolean;
|
||||
/** Только чтение (режим согласующего). По умолчанию берётся из FormMode-контекста. */
|
||||
readOnly?: boolean;
|
||||
/** Подпись справа от флажка. */
|
||||
label?: string;
|
||||
/** a11y-подпись для флажков без видимого label (в ячейках таблиц). */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Флажок (SharePoint Boolean / выбор строк). Управляемый: checked внутрь, onChange наружу. */
|
||||
export const Checkbox: React.FC<ICheckboxProps> = ({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
indeterminate,
|
||||
disabled,
|
||||
readOnly,
|
||||
label,
|
||||
ariaLabel
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveDisabled = disabled || (readOnly ?? contextReadOnly);
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.indeterminate = !!indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
return (
|
||||
<label className={styles.wrap}>
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.box} aria-hidden="true" />
|
||||
{label && <span className={styles.text}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 27, 48, .45);
|
||||
animation: tnsu-dlg-fade .15s ease-out;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $white;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-modal;
|
||||
overflow: hidden;
|
||||
animation: tnsu-dlg-pop .16s ease-out;
|
||||
}
|
||||
|
||||
.sm { max-width: 380px; }
|
||||
.lg { max-width: 680px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.titles { flex: 1; min-width: 0; }
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
|
||||
&:hover { background: $gray-100; color: $navy-800; }
|
||||
&:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(212, 160, 30, .25); }
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
font-size: $fs-body;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid $gray-200;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@keyframes tnsu-dlg-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes tnsu-dlg-pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
|
||||
// На телефоне — почти на весь экран, футер-кнопки на всю ширину.
|
||||
@include below($bp-sm) {
|
||||
.overlay { padding: 0; align-items: flex-end; }
|
||||
.panel { max-width: 100%; max-height: 92vh; border-radius: $r-lg $r-lg 0 0; }
|
||||
.footer > * { flex: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay, .panel { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
import { Button } from '../Button/Button';
|
||||
import { TextArea } from '../TextArea/TextArea';
|
||||
import { IconSprite } from '../Icon/IconSprite';
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: 'Оверлеи/Dialog',
|
||||
component: Dialog,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<IconSprite />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const DecisionForm: StoryObj<typeof Dialog> = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Открыть диалог</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Отклонить СТ"
|
||||
subText="Укажите причину отклонения (обязательно)."
|
||||
onDismiss={() => setOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button onClick={() => setOpen(false)}>Отклонить</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea value={comment} onChange={setComment} placeholder="Причина…" />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Dialog.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface IDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
/** Пояснение под заголовком. */
|
||||
subText?: string;
|
||||
onDismiss: () => void;
|
||||
/** Кнопки внизу (обычно Button-ы). */
|
||||
footer?: React.ReactNode;
|
||||
/** Ширина панели. */
|
||||
width?: 'sm' | 'md' | 'lg';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const widthClass: Record<NonNullable<IDialogProps['width']>, string> = {
|
||||
sm: styles.sm,
|
||||
md: '',
|
||||
lg: styles.lg
|
||||
};
|
||||
|
||||
/**
|
||||
* Модальное окно с телом и произвольным футером (форма решения/промпт).
|
||||
* Управляемое: open/onDismiss. Закрытие по Esc и клику по подложке.
|
||||
* Для простых да/нет — используй confirmModal.
|
||||
*/
|
||||
export const Dialog: React.FC<IDialogProps> = ({ open, title, subText, onDismiss, footer, width = 'md', children }) => {
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onDismiss]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onDismiss} role="presentation">
|
||||
<div
|
||||
className={[styles.panel, widthClass[width]].filter(Boolean).join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titles}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
{subText && <p className={styles.sub}>{subText}</p>}
|
||||
</div>
|
||||
<button type="button" className={styles.close} title="Закрыть" aria-label="Закрыть" onClick={onDismiss}>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
{children && <div className={styles.body}>{children}</div>}
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -122,6 +122,20 @@ export const IconSprite: React.FC = () => (
|
||||
<symbol id="i-arrow-left" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5m0 0 5-5m-5 5 5 5" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24">
|
||||
<path d="M12 19V5m0 0-5 5m5-5 5 5" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14m0 0 5-5m-5 5-5-5" />
|
||||
</symbol>
|
||||
<symbol id="i-swap" viewBox="0 0 24 24">
|
||||
<path d="M7 4 3 8l4 4" />
|
||||
<path d="M3 8h13a4 4 0 0 1 4 4M17 20l4-4-4-4" />
|
||||
<path d="M21 16H8a4 4 0 0 1-4-4" />
|
||||
</symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v4h-4" />
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
|
||||
@@ -28,6 +28,10 @@ export const ICON_NAMES = [
|
||||
'chevron-down',
|
||||
'chevron-left',
|
||||
'arrow-left',
|
||||
'arrow-up',
|
||||
'arrow-down',
|
||||
'swap',
|
||||
'refresh',
|
||||
'search',
|
||||
'menu',
|
||||
'warning',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
color: $navy-800;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $gray-400;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.sm {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
// Акцент удаления.
|
||||
.danger:hover {
|
||||
background: $red-50;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
// Тач-таргет на узких экранах.
|
||||
@include below($bp-md) {
|
||||
.btn { width: 36px; height: 36px; }
|
||||
.sm { width: 32px; height: 32px; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconSprite } from '../Icon/IconSprite';
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Кнопки/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<IconSprite />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Actions: StoryObj<typeof IconButton> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<IconButton icon="arrow-up" title="Выше" onClick={() => undefined} />
|
||||
<IconButton icon="arrow-down" title="Ниже" disabled onClick={() => undefined} />
|
||||
<IconButton icon="swap" title="Заменить" onClick={() => undefined} />
|
||||
<IconButton icon="trash" title="Удалить" danger onClick={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import styles from './IconButton.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
export interface IIconButtonProps {
|
||||
icon: IconName;
|
||||
/** Подсказка (tooltip) и a11y-подпись — обязательна: кнопка без текста. */
|
||||
title: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
/** Акцент удаления (красный ховер). */
|
||||
danger?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Кнопка-иконка (удалить/переместить/заменить). Иконка — из общего спрайта. */
|
||||
export const IconButton: React.FC<IIconButtonProps> = ({
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
disabled,
|
||||
danger,
|
||||
size = 'md',
|
||||
className
|
||||
}) => {
|
||||
const classes = [styles.btn, size === 'sm' ? styles.sm : '', danger ? styles.danger : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<button type="button" className={classes} title={title} aria-label={title} disabled={disabled} onClick={onClick}>
|
||||
<Icon name={icon} size={size === 'sm' ? 'sm' : 'md'} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.link {
|
||||
color: $navy-700;
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
background-size: 0 1.5px;
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
transition: background-size .18s, color .15s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
background-size: 100% 1.5px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Link } from './Link';
|
||||
|
||||
const meta: Meta<typeof Link> = {
|
||||
title: 'Навигация/Link',
|
||||
component: Link,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof Link> = {
|
||||
render: () => (
|
||||
<p style={{ fontSize: 14 }}>
|
||||
Файл: <Link href="#" download>скачать КП поставщика.pdf</Link>
|
||||
</p>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Link.module.scss';
|
||||
|
||||
export interface ILinkProps {
|
||||
href?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
target?: '_blank' | '_self';
|
||||
/** Скачивание файла (атрибут download). */
|
||||
download?: boolean | string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Текстовая ссылка (`<a>`) в бренд-стиле: navy + золотое подчёркивание на ховере. */
|
||||
export const Link: React.FC<ILinkProps> = ({ href, onClick, target, download, className, children }) => {
|
||||
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
|
||||
return (
|
||||
<a
|
||||
className={[styles.link, className].filter(Boolean).join(' ')}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target={target}
|
||||
rel={rel}
|
||||
download={download}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.block {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid $navy-100;
|
||||
border-top-color: $gold-500;
|
||||
border-radius: 50%;
|
||||
animation: tnsu-spin .7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xSmall { width: 12px; height: 12px; border-width: 2px; }
|
||||
.small { width: 16px; height: 16px; border-width: 2px; }
|
||||
.large { width: 32px; height: 32px; border-width: 3px; }
|
||||
|
||||
.label {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
@keyframes tnsu-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Уважение к prefers-reduced-motion: замедляем, не убираем совсем (индикатор занятости).
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring { animation-duration: 1.6s; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
const meta: Meta<typeof Spinner> = {
|
||||
title: 'Состояния/Spinner',
|
||||
component: Spinner,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Sizes: StoryObj<typeof Spinner> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||
<Spinner size="xSmall" />
|
||||
<Spinner size="small" />
|
||||
<Spinner size="medium" />
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabel: StoryObj<typeof Spinner> = {
|
||||
render: () => <Spinner size="small" label="Генерация комментария…" />,
|
||||
};
|
||||
|
||||
export const Block: StoryObj<typeof Spinner> = {
|
||||
render: () => <Spinner size="large" label="Загрузка данных…" block />,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Spinner.module.scss';
|
||||
|
||||
export type SpinnerSize = 'xSmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
export interface ISpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
/** Подпись справа (или под спиннером при block). */
|
||||
label?: string;
|
||||
/** Вертикальная раскладка (спиннер над подписью, по центру). */
|
||||
block?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClass: Record<SpinnerSize, string> = {
|
||||
xSmall: styles.xSmall,
|
||||
small: styles.small,
|
||||
medium: '',
|
||||
large: styles.large
|
||||
};
|
||||
|
||||
/** Индикатор загрузки (фирменное золотое кольцо на navy-дорожке). */
|
||||
export const Spinner: React.FC<ISpinnerProps> = ({ size = 'medium', label, block, className }) => {
|
||||
const wrap = [styles.wrap, block ? styles.block : '', className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className={wrap} role="status" aria-live="polite">
|
||||
<span className={[styles.ring, sizeClass[size]].filter(Boolean).join(' ')} aria-hidden="true" />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,8 @@
|
||||
// --- Кнопки ---
|
||||
export { Button } from './Button/Button';
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
export { IconButton } from './IconButton/IconButton';
|
||||
export type { IIconButtonProps } from './IconButton/IconButton';
|
||||
|
||||
// --- Сообщения / плашки ---
|
||||
export { Alert } from './Alert/Alert';
|
||||
@@ -30,6 +32,8 @@ export { MoneyInput } from './MoneyInput/MoneyInput';
|
||||
export { Select } from './Select/Select';
|
||||
export type { ISelectOption } from './Select/Select';
|
||||
export { Toggle } from './Toggle/Toggle';
|
||||
export { Checkbox } from './Checkbox/Checkbox';
|
||||
export type { ICheckboxProps } from './Checkbox/Checkbox';
|
||||
|
||||
// --- Режим «только чтение» для полей формы ---
|
||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
@@ -44,11 +48,19 @@ export { FileDropzone } from './FileDropzone/FileDropzone';
|
||||
export { AttachmentList } from './AttachmentList/AttachmentList';
|
||||
export type { IAttachmentInfo } from './AttachmentList/AttachmentList';
|
||||
|
||||
// --- Индикаторы состояния ---
|
||||
export { Spinner } from './Spinner/Spinner';
|
||||
export type { ISpinnerProps, SpinnerSize } from './Spinner/Spinner';
|
||||
|
||||
// --- Модалка подтверждения ---
|
||||
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
|
||||
export { confirmModal } from './ConfirmDialog/confirmModal';
|
||||
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
|
||||
|
||||
// --- Модальное окно (с телом и футером) ---
|
||||
export { Dialog } from './Dialog/Dialog';
|
||||
export type { IDialogProps } from './Dialog/Dialog';
|
||||
|
||||
// --- Раскладка приложения ---
|
||||
export { AppLayout } from './AppLayout/AppLayout';
|
||||
export type { IAppLayoutProps } from './AppLayout/AppLayout';
|
||||
@@ -62,6 +74,8 @@ export { Sidebar } from './Sidebar/Sidebar';
|
||||
export type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar';
|
||||
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
||||
export type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
|
||||
export { Link } from './Link/Link';
|
||||
export type { ILinkProps } from './Link/Link';
|
||||
|
||||
// --- Таблицы ---
|
||||
export { DataTable } from './DataTable/DataTable';
|
||||
|
||||
@@ -43,6 +43,7 @@ $green-50: #F0FDF4;
|
||||
$blue-600: #2563EB;
|
||||
$blue-50: #EFF6FF;
|
||||
$amber-700: #854D0E;
|
||||
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
|
||||
|
||||
$white: #fff;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user