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:
Тимур Городецкий
2026-08-25 18:28:50 +05:00
parent 2958ab7320
commit fa6fe8dbe1
20 changed files with 740 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.9.0", "version": "0.10.0",
"description": "TNSU shared UI components for SPFx web parts", "description": "TNSU shared UI components for SPFx web parts",
"author": "Timur Gorodetskiy", "author": "Timur Gorodetskiy",
"license": "ISC", "license": "ISC",
+103
View File
@@ -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; }
}
+38
View File
@@ -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="Выбрать строку" />;
},
};
+58
View File
@@ -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>
);
};
+102
View File
@@ -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; }
}
+48
View File
@@ -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>
</>
);
},
};
+70
View File
@@ -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>
);
};
+14
View File
@@ -122,6 +122,20 @@ export const IconSprite: React.FC = () => (
<symbol id="i-arrow-left" viewBox="0 0 24 24"> <symbol id="i-arrow-left" viewBox="0 0 24 24">
<path d="M19 12H5m0 0 5-5m-5 5 5 5" /> <path d="M19 12H5m0 0 5-5m-5 5 5 5" />
</symbol> </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"> <symbol id="i-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" /> <path d="m21 21-4.35-4.35" />
+4
View File
@@ -28,6 +28,10 @@ export const ICON_NAMES = [
'chevron-down', 'chevron-down',
'chevron-left', 'chevron-left',
'arrow-left', 'arrow-left',
'arrow-up',
'arrow-down',
'swap',
'refresh',
'search', 'search',
'menu', 'menu',
'warning', 'warning',
+49
View File
@@ -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; }
}
+30
View File
@@ -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>
),
};
+36
View File
@@ -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>
);
};
+24
View File
@@ -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);
}
}
+18
View File
@@ -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>
),
};
+29
View File
@@ -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>
);
};
+41
View File
@@ -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; }
}
+29
View File
@@ -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 />,
};
+31
View File
@@ -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>
);
};
+14
View File
@@ -6,6 +6,8 @@
// --- Кнопки --- // --- Кнопки ---
export { Button } from './Button/Button'; export { Button } from './Button/Button';
export { PrintButton } from './PrintButton/PrintButton'; export { PrintButton } from './PrintButton/PrintButton';
export { IconButton } from './IconButton/IconButton';
export type { IIconButtonProps } from './IconButton/IconButton';
// --- Сообщения / плашки --- // --- Сообщения / плашки ---
export { Alert } from './Alert/Alert'; export { Alert } from './Alert/Alert';
@@ -30,6 +32,8 @@ export { MoneyInput } from './MoneyInput/MoneyInput';
export { Select } from './Select/Select'; export { Select } from './Select/Select';
export type { ISelectOption } from './Select/Select'; export type { ISelectOption } from './Select/Select';
export { Toggle } from './Toggle/Toggle'; export { Toggle } from './Toggle/Toggle';
export { Checkbox } from './Checkbox/Checkbox';
export type { ICheckboxProps } from './Checkbox/Checkbox';
// --- Режим «только чтение» для полей формы --- // --- Режим «только чтение» для полей формы ---
export { FormModeProvider, useFormReadOnly } from './FormMode'; export { FormModeProvider, useFormReadOnly } from './FormMode';
@@ -44,11 +48,19 @@ export { FileDropzone } from './FileDropzone/FileDropzone';
export { AttachmentList } from './AttachmentList/AttachmentList'; export { AttachmentList } from './AttachmentList/AttachmentList';
export type { IAttachmentInfo } 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 { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal'; export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } 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 { AppLayout } from './AppLayout/AppLayout';
export type { IAppLayoutProps } 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 type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar';
export { SidebarToggle } from './Sidebar/SidebarToggle'; export { SidebarToggle } from './Sidebar/SidebarToggle';
export type { ISidebarToggleProps } 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'; export { DataTable } from './DataTable/DataTable';
+1
View File
@@ -43,6 +43,7 @@ $green-50: #F0FDF4;
$blue-600: #2563EB; $blue-600: #2563EB;
$blue-50: #EFF6FF; $blue-50: #EFF6FF;
$amber-700: #854D0E; $amber-700: #854D0E;
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
$white: #fff; $white: #fff;