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:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
<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" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user