feat: перенос быстрой пачки компонентов 0.1.0
This commit is contained in:
+44
@@ -67,6 +67,48 @@ web part через `npm i`, и держать единый вид через д
|
|||||||
- **Шаг 2 в работе**: переписан package.json (@tnsu/ui-kit, exports, peerDeps, Vite-скрипты),
|
- **Шаг 2 в работе**: переписан package.json (@tnsu/ui-kit, exports, peerDeps, Vite-скрипты),
|
||||||
ставятся devDependencies, добавлен .gitignore.
|
ставятся devDependencies, добавлен .gitignore.
|
||||||
|
|
||||||
|
## План переноса компонентов (инвентаризация + решения, 2026-08-13)
|
||||||
|
|
||||||
|
Источник — request-portal `shared/ui/`, `shared/approval/ui/`, `shared/dictionary/ui/`,
|
||||||
|
и (фаза 2) `components/`.
|
||||||
|
|
||||||
|
### Группа 1 — чистая презентация, переносим КАК ЕСТЬ
|
||||||
|
Alert, FormCard, FormGrid, FormField, TextInput, TextArea, PhoneInput, MoneyInput,
|
||||||
|
Select, Toggle, CollapseSection, FormActions, Icon (+ IconSprite/iconNames),
|
||||||
|
RequestNumber, StatusBadge, ErrorOverlay, FormMode (FormModeProvider + useFormReadOnly).
|
||||||
|
|
||||||
|
### Группа 2 — презентация с нюансами (решения приняты)
|
||||||
|
- ConfirmDialog (+ confirmModal), FileDropzone, useFlipRows → переносим КАК ЕСТЬ (проверено: чисты).
|
||||||
|
- AttachmentList → переносим, но РАЗВЯЗАТЬ тип: вместо `import { IAttachmentInfo }`
|
||||||
|
из attachments.service объявить локальный `{ url: string; fileName: string }`.
|
||||||
|
- PrintButton → переносим КАК ЕСТЬ; сохраняем соглашение классов `.rp-print-root` /
|
||||||
|
`.rp-no-print` — задокументировать в README, чтобы потребители их соблюдали.
|
||||||
|
- ItemsTable → ОБОБЩАЕМ в generic `DataTable` (колонки через проп: {key,title,width?,
|
||||||
|
align?,editable?,render?}, value/onChange/readOnly). Excel-вставка — ОПЦИОНАЛЬНАЯ/
|
||||||
|
подключаемая (не хардкодить parseExcelRows под ТМЦ; проект даёт свой маппинг/парсер).
|
||||||
|
ТМЦ-таблица = конфиг колонок поверх DataTable. Это ОТДЕЛЬНАЯ design-задача (не copy-paste),
|
||||||
|
делать позже, сверяя абстракцию со 2-м реальным примером таблицы.
|
||||||
|
|
||||||
|
### Группа 3 — бизнес-логика / SharePoint, НЕ в ui-kit (как есть)
|
||||||
|
PeopleField/PeopleFieldMulti, UserPicker, InitiatorLine, SubmitActions, useRequestSubmit,
|
||||||
|
DecisionPanel, ApprovalMatrix, DepartmentPicker, ProjectPicker.
|
||||||
|
- SubmitActions — на обсуждении: можно «очистить» от useRequestSubmit → презентационный → в ui-kit.
|
||||||
|
- Пикеры — возможно позже РАЗДЕЛИТЬ: презентационный UI (ui-kit) + data-обёртка (проект).
|
||||||
|
- Логику согласования — возможно в отдельный пакет @tnsu/approval (или @tnsu/sp-core). На будущее.
|
||||||
|
|
||||||
|
### Группа 4 — навигация / app shell (ФАЗА 2)
|
||||||
|
Tabs (из CategoryTabs), Breadcrumbs (новый), Sidebar (из SideNav), AppLayout (из RequestPortal),
|
||||||
|
Drawer(?). Сначала «расцепить» от роутинга портала → сделать controlled
|
||||||
|
(items + activeId + onSelect), потом переносить. Даёт новым приложениям готовую оболочку.
|
||||||
|
|
||||||
|
### Порядок работ
|
||||||
|
1. [СДЕЛАНО в 0.1.0] Быстрая пачка (20 компонентов): вся Группа 1 (кроме ErrorOverlay/
|
||||||
|
StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/
|
||||||
|
FileDropzone/useFlipRows + AttachmentList (тип развязан на локальный {url,fileName}) +
|
||||||
|
PrintButton. Сборка чистая, типы ок.
|
||||||
|
2. DataTable — отдельной design-задачей.
|
||||||
|
3. Группа 3/4 — по мере надобности.
|
||||||
|
|
||||||
## Как мигрировать старый проект (памятка на будущее)
|
## Как мигрировать старый проект (памятка на будущее)
|
||||||
|
|
||||||
1. `npm i @tnsu/ui-kit` — ничего не ломается, пакет просто лежит рядом.
|
1. `npm i @tnsu/ui-kit` — ничего не ломается, пакет просто лежит рядом.
|
||||||
@@ -90,6 +132,8 @@ web part через `npm i`, и держать единый вид через д
|
|||||||
публикацией (npm не даёт перезаписать уже опубликованную версию).
|
публикацией (npm не даёт перезаписать уже опубликованную версию).
|
||||||
- Потребителю (request-portal и др.) нужен такой же `@tnsu:registry` в его `.npmrc` +
|
- Потребителю (request-portal и др.) нужен такой же `@tnsu:registry` в его `.npmrc` +
|
||||||
свой токен на чтение пакетов.
|
свой токен на чтение пакетов.
|
||||||
|
- ПРОВЕРЕНО: коллега (другой пользователь Gitea) успешно установил @tnsu/ui-kit со своим
|
||||||
|
read-токеном — кросс-доступ к личным пакетам работает, переносить в организацию пока НЕ нужно.
|
||||||
|
|
||||||
## Окружение
|
## Окружение
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,17 @@ import '@tnsu/ui-kit/styles.css'; // один раз в корневом ком
|
|||||||
|
|
||||||
_Наполняется по мере переноса. Полная витрина со всеми состояниями — в Storybook._
|
_Наполняется по мере переноса. Полная витрина со всеми состояниями — в Storybook._
|
||||||
|
|
||||||
| Компонент | Описание | Статус |
|
**Кнопки:** `Button`, `PrintButton`
|
||||||
|-----------|----------|--------|
|
**Сообщения:** `Alert`, `RequestNumber`
|
||||||
| Button | Кнопка, варианты primary/accent/secondary/ghost/danger | ✅ готов |
|
**Форма (оболочка/раскладка):** `FormCard`, `FormGrid`, `FormField`, `FormActions`, `CollapseSection`
|
||||||
|
**Поля ввода:** `TextInput`, `TextArea`, `PhoneInput`, `MoneyInput`, `Select`, `Toggle`
|
||||||
|
**Режим чтения:** `FormModeProvider`, `useFormReadOnly`
|
||||||
|
**Иконки:** `Icon`, `IconSprite`
|
||||||
|
**Файлы:** `FileDropzone`, `AttachmentList`
|
||||||
|
**Модалка:** `ConfirmHost`, `confirmModal`
|
||||||
|
**Хуки:** `useFlipRows`
|
||||||
|
|
||||||
|
> ⏳ В планах: `DataTable` (обобщение ItemsTable), `Badge`, `ErrorOverlay`, навигация (Tabs/Sidebar/Breadcrumbs).
|
||||||
|
|
||||||
## Разработка пакета
|
## Разработка пакета
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tnsu/ui-kit",
|
"name": "@tnsu/ui-kit",
|
||||||
"version": "0.0.1",
|
"version": "0.1.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,30 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: $r-md;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
background: $gold-50;
|
||||||
|
color: $amber-700;
|
||||||
|
border-color: $gold-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
background: $green-50;
|
||||||
|
color: $green-600;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Alert.module.scss';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { IconName } from '../Icon/iconNames';
|
||||||
|
|
||||||
|
type AlertVariant = 'warn' | 'ok' | 'err';
|
||||||
|
|
||||||
|
interface IAlertProps {
|
||||||
|
variant: AlertVariant;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClass: Record<AlertVariant, string> = {
|
||||||
|
warn: styles.warn,
|
||||||
|
ok: styles.ok,
|
||||||
|
err: styles.err
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantIcon: Record<AlertVariant, IconName> = {
|
||||||
|
warn: 'warning',
|
||||||
|
ok: 'check',
|
||||||
|
err: 'close'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Alert: React.FC<IAlertProps> = ({ variant, children }) => {
|
||||||
|
const classes = [styles.alert, variantClass[variant]].join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes} role="status">
|
||||||
|
<Icon name={variantIcon[variant]} size="sm" />
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: $navy-700;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .16s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $red-500;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './AttachmentList.module.scss';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
/**
|
||||||
|
* Минимальная форма вложения для отображения. В request-portal этот тип жил в
|
||||||
|
* SP-сервисе (attachments.service) — здесь развязан, чтобы ui-kit не тянул
|
||||||
|
* SharePoint. Компоненту нужны только ссылка и имя файла.
|
||||||
|
*/
|
||||||
|
export interface IAttachmentInfo {
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAttachmentListProps {
|
||||||
|
items: IAttachmentInfo[];
|
||||||
|
/** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */
|
||||||
|
onRemove?: (fileName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */
|
||||||
|
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items, onRemove }) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className={styles.empty}>Вложений нет.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{items.map((attachment) => (
|
||||||
|
<li key={attachment.url} className={styles.item}>
|
||||||
|
<Icon name="document" size="sm" />
|
||||||
|
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
|
||||||
|
{attachment.fileName}
|
||||||
|
</a>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
title="Удалить вложение"
|
||||||
|
aria-label={`Удалить ${attachment.fileName}`}
|
||||||
|
onClick={() => onRemove(attachment.fileName)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 1px;
|
||||||
|
background: $gold-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
|
||||||
|
svg { color: $gold-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
display: flex;
|
||||||
|
color: $gray-400;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px 22px;
|
||||||
|
padding-top: 16px;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import styles from './CollapseSection.module.scss';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { IconName } from '../Icon/iconNames';
|
||||||
|
|
||||||
|
interface ICollapseSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon: IconName;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сворачиваемая секция формы: заголовок с иконкой и шевроном + тело. */
|
||||||
|
export const CollapseSection: React.FC<ICollapseSectionProps> = ({ title, icon, defaultOpen = true, children }) => {
|
||||||
|
const [open, setOpen] = useState<boolean>(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button type="button" className={styles.header} onClick={() => setOpen((prev) => !prev)}>
|
||||||
|
<span className={styles.title}>
|
||||||
|
<Icon name={icon} size="sm" />
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<span className={[styles.chevron, open ? styles.open : ''].filter(Boolean).join(' ')}>
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && <div className={styles.body}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
// Поверх всего, включая SPFx-хром вебпарта.
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999997;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $sp-6;
|
||||||
|
background: rgba($navy-950, .55);
|
||||||
|
font-family: $font-sans;
|
||||||
|
// Плавное появление подложки.
|
||||||
|
animation: overlayIn .14s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: $sp-5 $sp-5 $sp-4;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
animation: cardIn .16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-form-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-top: $sp-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $navy-800;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn {
|
||||||
|
@include btn-accent;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmDanger {
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $red-500;
|
||||||
|
color: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px) scale(.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import styles from './ConfirmDialog.module.scss';
|
||||||
|
import { IConfirmOptions, registerConfirmHost } from './confirmModal';
|
||||||
|
|
||||||
|
interface IConfirmDialogProps extends IConfirmOptions {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Само окно подтверждения. Рендерится в document.body поверх всего (portal). */
|
||||||
|
const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Продолжить',
|
||||||
|
cancelText = 'Отмена',
|
||||||
|
danger,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
// Esc — отмена. Слушаем на всё окно, пока модалка открыта.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
// Клик по фону = отмена; клик внутри карточки не всплывает до фона.
|
||||||
|
<div className={styles.overlay} onMouseDown={onCancel}>
|
||||||
|
<div
|
||||||
|
className={styles.card}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
|
<div className={styles.message}>{message}</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={danger ? styles.confirmDanger : styles.confirmBtn}
|
||||||
|
onClick={onConfirm}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хост модалки: монтируется ОДИН раз в портале. Слушает императивные вызовы
|
||||||
|
* confirmModal(...) и показывает окно; ответ пользователя резолвит их промис.
|
||||||
|
*/
|
||||||
|
export const ConfirmHost: React.FC = () => {
|
||||||
|
const [state, setState] = React.useState<{
|
||||||
|
opts: IConfirmOptions;
|
||||||
|
resolve: (ok: boolean) => void;
|
||||||
|
} | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => registerConfirmHost((opts, resolve) => setState({ opts, resolve })), []);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const close = (ok: boolean): void => {
|
||||||
|
state.resolve(ok);
|
||||||
|
setState(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog {...state.opts} onConfirm={() => close(true)} onCancel={() => close(false)} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Императивный вызов модалки подтверждения из любого места (в т.ч. из обычных
|
||||||
|
// модулей вроде shared/unsavedChanges.ts, где нет React-контекста).
|
||||||
|
//
|
||||||
|
// Модалка живёт в React-дереве (ConfirmHost, монтируется один раз в
|
||||||
|
// RequestPortal). Здесь — только «мостик»: confirmModal(...) отдаёт промис,
|
||||||
|
// который резолвится, когда пользователь нажмёт «Продолжить»/«Отмена».
|
||||||
|
|
||||||
|
export interface IConfirmOptions {
|
||||||
|
/** Заголовок окна. */
|
||||||
|
title?: string;
|
||||||
|
/** Основной текст-вопрос. */
|
||||||
|
message: string;
|
||||||
|
/** Подпись подтверждающей кнопки. По умолчанию — «Продолжить». */
|
||||||
|
confirmText?: string;
|
||||||
|
/** Подпись кнопки отмены. По умолчанию — «Отмена». */
|
||||||
|
cancelText?: string;
|
||||||
|
/** Опасное действие (потеря данных) — подтверждающая кнопка красная. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmHandler = (opts: IConfirmOptions, resolve: (ok: boolean) => void) => void;
|
||||||
|
|
||||||
|
// Единственный подписчик — смонтированный ConfirmHost. Больше одного окна
|
||||||
|
// подтверждения одновременно нам не нужно.
|
||||||
|
let handler: ConfirmHandler | undefined;
|
||||||
|
|
||||||
|
export function registerConfirmHost(fn: ConfirmHandler): () => void {
|
||||||
|
handler = fn;
|
||||||
|
return () => {
|
||||||
|
if (handler === fn) handler = undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать модальное подтверждение. Промис резолвится в true («Продолжить») или
|
||||||
|
* false («Отмена»/Esc/клик по фону). Если хост почему-то не смонтирован —
|
||||||
|
* падаем на нативный confirm, чтобы не потерять защиту от потери данных.
|
||||||
|
*/
|
||||||
|
export function confirmModal(opts: IConfirmOptions): Promise<boolean> {
|
||||||
|
if (!handler) {
|
||||||
|
return Promise.resolve(window.confirm(opts.message));
|
||||||
|
}
|
||||||
|
return new Promise<boolean>((resolve) => handler!(opts, resolve));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.zone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1.5px dashed $gray-300;
|
||||||
|
border-radius: $r-md;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.over {
|
||||||
|
border-color: $gold-500;
|
||||||
|
color: $gold-600;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 11.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import styles from './FileDropzone.module.scss';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
|
||||||
|
interface IFileDropzoneProps {
|
||||||
|
files: File[];
|
||||||
|
onChange: (files: File[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зона выбора файлов (перетащить/кликнуть) со списком выбранных и удалением. */
|
||||||
|
export const FileDropzone: React.FC<IFileDropzoneProps> = ({ files, onChange }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragOver, setDragOver] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const addFiles = (incoming: FileList | null): void => {
|
||||||
|
if (!incoming || incoming.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...files, ...Array.from(incoming)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (index: number): void => {
|
||||||
|
onChange(files.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneClasses = [styles.zone, dragOver ? styles.over : ''].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={zoneClasses}
|
||||||
|
onClick={() => inputRef.current && inputRef.current.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
<span>Нажмите или перетащите файлы сюда</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className={styles.hidden}
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.target.files);
|
||||||
|
e.target.value = ''; // чтобы можно было выбрать тот же файл повторно
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<li key={`${file.name}-${index}`} className={styles.item}>
|
||||||
|
<Icon name="document" size="sm" />
|
||||||
|
<span className={styles.name}>{file.name}</span>
|
||||||
|
<span className={styles.size}>{formatSize(file.size)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label="Удалить файл"
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 26px -32px -32px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: $white;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
border-radius: 0 0 $r-lg $r-lg;
|
||||||
|
box-shadow: 0 -4px 16px rgba(15, 36, 64, .04);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormActions.module.scss';
|
||||||
|
|
||||||
|
interface IFormActionsProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нижняя липкая панель действий формы («Отмена» / «Сохранить»). */
|
||||||
|
export const FormActions: React.FC<IFormActionsProps> = ({ children }) => {
|
||||||
|
return <div className={styles.actions}>{children}</div>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-form-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-400;
|
||||||
|
margin-top: 6px;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hairline {
|
||||||
|
@include hairline;
|
||||||
|
margin: 6px 0 22px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormCard.module.scss';
|
||||||
|
|
||||||
|
interface IFormCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** Контент в правом верхнем углу шапки (например, номер заявки). */
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
|
/** Строка под подзаголовком (например, инициатор заявки). */
|
||||||
|
belowSubtitle?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
|
||||||
|
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, belowSubtitle, children }) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||||
|
{belowSubtitle}
|
||||||
|
</div>
|
||||||
|
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
|
||||||
|
</div>
|
||||||
|
<hr className={styles.hairline} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-700;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
interface IFormFieldProps {
|
||||||
|
label: string;
|
||||||
|
htmlFor?: string;
|
||||||
|
required?: boolean;
|
||||||
|
/** Занять обе колонки сетки. */
|
||||||
|
full?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ячейка формы: подпись со звёздочкой для обязательных + контрол. */
|
||||||
|
export const FormField: React.FC<IFormFieldProps> = ({ label, htmlFor, required, full, children }) => {
|
||||||
|
const classes = [styles.field, full ? styles.full : ''].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<label className={styles.label} htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
{required && <span className={styles.req}> *</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px 22px;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormGrid.module.scss';
|
||||||
|
|
||||||
|
interface IFormGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сетка полей в 2 колонки. Поля-дети — FormField (full — на всю ширину). */
|
||||||
|
export const FormGrid: React.FC<IFormGridProps> = ({ children }) => {
|
||||||
|
return <div className={styles.grid}>{children}</div>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Режим формы «только чтение». Проставляется один раз оборачиванием
|
||||||
|
// (<FormModeProvider readOnly>...), а поля-примитивы сами его подхватывают —
|
||||||
|
// не нужно тащить readOnly через каждую секцию и каждое поле.
|
||||||
|
const FormModeContext = React.createContext<boolean>(false);
|
||||||
|
|
||||||
|
export const FormModeProvider: React.FC<{ readOnly: boolean; children: React.ReactNode }> = ({
|
||||||
|
readOnly,
|
||||||
|
children
|
||||||
|
}) => <FormModeContext.Provider value={readOnly}>{children}</FormModeContext.Provider>;
|
||||||
|
|
||||||
|
/** true, если форма отрисовывается в режиме «только чтение». */
|
||||||
|
export function useFormReadOnly(): boolean {
|
||||||
|
return React.useContext(FormModeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Icon.module.scss';
|
||||||
|
import { IconName } from './iconNames';
|
||||||
|
|
||||||
|
type IconSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface IIconProps {
|
||||||
|
name: IconName;
|
||||||
|
size?: IconSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass: Record<IconSize, string> = {
|
||||||
|
sm: styles.sm,
|
||||||
|
md: '',
|
||||||
|
lg: styles.lg
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Иконка из общего спрайта. Спрайт (IconSprite) должен быть отрисован
|
||||||
|
* один раз в корне портала, иначе ссылки #i-<name> ничего не покажут.
|
||||||
|
*/
|
||||||
|
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
|
||||||
|
const classes = [styles.icon, sizeClass[size], className].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<svg className={classes} aria-hidden="true" focusable="false">
|
||||||
|
<use href={`#i-${name}`} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрытый SVG-спрайт со всеми иконками портала.
|
||||||
|
* Рендерится один раз в корне (RequestPortal). Компонент Icon ссылается
|
||||||
|
* на символы по id `#i-<name>` (см. iconNames.ts).
|
||||||
|
*/
|
||||||
|
export const IconSprite: React.FC = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{ display: 'none' }} aria-hidden="true">
|
||||||
|
<symbol id="i-building" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
|
||||||
|
<path d="M13 21V9a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v12" />
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
<rect x="7" y="7" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||||
|
<rect x="7" y="12" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||||
|
<rect x="16" y="11" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-chart" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||||
|
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||||
|
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-monitor" viewBox="0 0 24 24">
|
||||||
|
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-helmet" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 16a9 9 0 0 1 18 0" />
|
||||||
|
<path d="M2 16h20" />
|
||||||
|
<path d="M9 8V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-user-check" viewBox="0 0 24 24">
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M3 21v-1a5 5 0 0 1 5-5h3" />
|
||||||
|
<path d="M15 12l2.5 2.5L22 10" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-calculator" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||||
|
<path d="M8 6h8M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01M8 18h4" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-send" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
<path d="m22 2-7 20-4-9-9-4z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-download" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3v12m0 0-4-4m4 4 4-4" />
|
||||||
|
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-upload" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 15V3m0 0-4 4m4-4 4 4" />
|
||||||
|
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-folder" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-document" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 3h8l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
|
||||||
|
<path d="M14 3v5h5" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-calendar" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||||
|
<path d="M3 9h18M8 2v4M16 2v4" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-clipboard" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 3h6v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z" />
|
||||||
|
<rect x="5" y="5" width="14" height="16" rx="2" />
|
||||||
|
<path d="M9 12h6M9 16h4" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-people" viewBox="0 0 24 24">
|
||||||
|
<circle cx="9" cy="7" r="3" />
|
||||||
|
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
|
||||||
|
<circle cx="17" cy="8" r="2.5" />
|
||||||
|
<path d="M17 13.5a3.5 3.5 0 0 1 3.5 3.5v1" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-vehicle" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 11l1.5-4.5A2 2 0 0 1 8.4 5h7.2a2 2 0 0 1 1.9 1.5L19 11" />
|
||||||
|
<rect x="3" y="11" width="18" height="7" rx="2" />
|
||||||
|
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-chat" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-camera" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 8a2 2 0 0 1 2-2h2l1.5-2h7L17 6h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-image" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<path d="m21 15-5-5L5 21" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-edit" viewBox="0 0 24 24">
|
||||||
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-copy" viewBox="0 0 24 24">
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-more" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-trash" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||||
|
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />
|
||||||
|
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-close" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-chevron-right" viewBox="0 0 24 24">
|
||||||
|
<path d="m9 6 6 6-6 6" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-chevron-down" viewBox="0 0 24 24">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-chevron-left" viewBox="0 0 24 24">
|
||||||
|
<path d="m15 6-6 6 6 6" />
|
||||||
|
</symbol>
|
||||||
|
<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-search" viewBox="0 0 24 24">
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2 1 21h22z" strokeLinejoin="round" />
|
||||||
|
<path d="M12 9v5M12 17h.01" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-check" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 12l5 5L20 7" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="i-plus" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite).
|
||||||
|
// Добавляя иконку: 1) добавь имя сюда, 2) добавь <symbol> в IconSprite.
|
||||||
|
export const ICON_NAMES = [
|
||||||
|
'building',
|
||||||
|
'chart',
|
||||||
|
'monitor',
|
||||||
|
'calculator',
|
||||||
|
'helmet',
|
||||||
|
'user-check',
|
||||||
|
'send',
|
||||||
|
'download',
|
||||||
|
'upload',
|
||||||
|
'folder',
|
||||||
|
'document',
|
||||||
|
'calendar',
|
||||||
|
'clipboard',
|
||||||
|
'people',
|
||||||
|
'vehicle',
|
||||||
|
'chat',
|
||||||
|
'camera',
|
||||||
|
'image',
|
||||||
|
'edit',
|
||||||
|
'copy',
|
||||||
|
'more',
|
||||||
|
'trash',
|
||||||
|
'close',
|
||||||
|
'chevron-right',
|
||||||
|
'chevron-down',
|
||||||
|
'chevron-left',
|
||||||
|
'arrow-left',
|
||||||
|
'search',
|
||||||
|
'warning',
|
||||||
|
'check',
|
||||||
|
'plus'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IconName = (typeof ICON_NAMES)[number];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TextInput } from '../TextInput/TextInput';
|
||||||
|
|
||||||
|
// Показ с разделителем тысяч пробелом: 350000 → «350 000».
|
||||||
|
function formatThousands(input: string): string {
|
||||||
|
const digits = input.replace(/\D/g, '');
|
||||||
|
if (!digits) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMoneyInputProps {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ввод суммы с разделителем тысяч. В модель формы кладём «сырые» цифры
|
||||||
|
* (без пробелов), поэтому запись в числовую колонку SP работает как есть.
|
||||||
|
*/
|
||||||
|
export const MoneyInput: React.FC<IMoneyInputProps> = ({ id, value, onChange, readOnly }) => (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
value={formatThousands(value)}
|
||||||
|
onChange={(v) => onChange(v.replace(/\D/g, ''))}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TextInput } from '../TextInput/TextInput';
|
||||||
|
|
||||||
|
// Маска номера Казахстана: +7 (XXX) XXX-XX-XX. Идемпотентна — можно применять
|
||||||
|
// и к уже отформатированному значению (напр. загруженному из SharePoint).
|
||||||
|
function formatKzPhone(input: string): string {
|
||||||
|
let digits = input.replace(/\D/g, '');
|
||||||
|
if (digits.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (digits[0] === '8') {
|
||||||
|
digits = '7' + digits.slice(1);
|
||||||
|
}
|
||||||
|
if (digits[0] !== '7') {
|
||||||
|
digits = '7' + digits;
|
||||||
|
}
|
||||||
|
digits = digits.slice(0, 11); // код страны + 10 цифр
|
||||||
|
|
||||||
|
const rest = digits.slice(1);
|
||||||
|
let out = '+7';
|
||||||
|
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
|
||||||
|
if (rest.length >= 3) out += ')';
|
||||||
|
if (rest.length > 3) out += ' ' + rest.slice(3, 6);
|
||||||
|
if (rest.length > 6) out += '-' + rest.slice(6, 8);
|
||||||
|
if (rest.length > 8) out += '-' + rest.slice(8, 10);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPhoneInputProps {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ввод телефона с маской +7 (XXX) XXX-XX-XX. */
|
||||||
|
export const PhoneInput: React.FC<IPhoneInputProps> = ({ id, value, onChange, readOnly }) => (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
value={formatKzPhone(value)}
|
||||||
|
onChange={(v) => onChange(formatKzPhone(v))}
|
||||||
|
placeholder="+7 (___) ___-__-__"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
|
||||||
|
// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг.
|
||||||
|
function formatValue(el: HTMLInputElement): string {
|
||||||
|
if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) {
|
||||||
|
const [y, m, d] = el.value.split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
return el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на
|
||||||
|
// обычный текст его значения (значения берём из живого DOM — React держит их как
|
||||||
|
// свойства, в клонированном HTML их нет). Так textarea/поля не занимают лишнего
|
||||||
|
// места и лист выглядит как справка, а не как незаполненный бланк.
|
||||||
|
function renderValuesAsText(source: Element, clone: Element): void {
|
||||||
|
const put = (cloneEl: Element | undefined, text: string): void => {
|
||||||
|
if (!cloneEl) return;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'rp-print-value';
|
||||||
|
div.textContent = text && text.length > 0 ? text : '—';
|
||||||
|
(cloneEl.closest('label') ?? cloneEl).replaceWith(div);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cInputs = clone.querySelectorAll('input');
|
||||||
|
source.querySelectorAll('input').forEach((el, i) => {
|
||||||
|
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||||
|
put(cInputs[i], el.checked ? 'Да' : 'Нет');
|
||||||
|
} else {
|
||||||
|
put(cInputs[i], formatValue(el));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cAreas = clone.querySelectorAll('textarea');
|
||||||
|
source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value));
|
||||||
|
|
||||||
|
const cSelects = clone.querySelectorAll('select');
|
||||||
|
source.querySelectorAll('select').forEach((el, i) => {
|
||||||
|
const opt = el.options[el.selectedIndex];
|
||||||
|
put(cSelects[i], opt && opt.value ? opt.text : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стили печатного окна: чистый лист (без «коробки» карточки), без иконок,
|
||||||
|
// значения — обычным текстом с переносами.
|
||||||
|
const PRINT_CSS =
|
||||||
|
// height:auto перебивает глобальный CSS SharePoint (html/body 100vh) — иначе
|
||||||
|
// body раздувается выше листа A4 и в печать идёт лишняя пустая страница.
|
||||||
|
'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' +
|
||||||
|
'body{padding:24px;font-size:13px}' +
|
||||||
|
'svg{display:none!important}' +
|
||||||
|
// Фото сотрудника (аватар персоны PeoplePicker) в печать не идёт — на бумаге
|
||||||
|
// нужны только ФИО и e-mail. Прячем весь круг-аватар, чтобы не оставался
|
||||||
|
// пустой кружок. Текст персоны (имя/почта) остаётся.
|
||||||
|
'.ms-Persona-imageArea,.ms-Persona-image{display:none!important}' +
|
||||||
|
'.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' +
|
||||||
|
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно,
|
||||||
|
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
|
||||||
|
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм.
|
||||||
|
*/
|
||||||
|
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
||||||
|
const onPrint = (): void => {
|
||||||
|
const source = document.querySelector('.rp-print-root');
|
||||||
|
if (!source) {
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||||
|
if (!win) {
|
||||||
|
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = source.cloneNode(true) as Element;
|
||||||
|
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
||||||
|
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||||
|
renderValuesAsText(source, clone);
|
||||||
|
|
||||||
|
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
||||||
|
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||||
|
.map((n) => n.outerHTML)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||||
|
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||||
|
);
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
|
||||||
|
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
||||||
|
const done = (): void => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
};
|
||||||
|
if (win.document.readyState === 'complete') {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
} else {
|
||||||
|
win.onload = (): void => {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={onPrint}>
|
||||||
|
<Icon name="document" size="sm" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $navy-50;
|
||||||
|
color: $navy-800;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './RequestNumber.module.scss';
|
||||||
|
|
||||||
|
/** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */
|
||||||
|
export const RequestNumber: React.FC<{ code: string }> = ({ code }) => (
|
||||||
|
<span className={styles.badge}>№{code}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
padding: 11px 36px 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background-color: $white;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':disabled');
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Select.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
|
export interface ISelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISelectProps {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: ISelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). У select нет readonly — используем disabled. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выпадающий список (SharePoint Choice). */
|
||||||
|
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
className={styles.select}
|
||||||
|
value={value}
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{placeholder && <option value="">{placeholder}</option>}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './TextArea.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
|
interface ITextAreaProps {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Многострочный ввод (SharePoint Note). */
|
||||||
|
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
className={styles.textarea}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './TextInput.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
|
type InputType = 'text' | 'number' | 'date';
|
||||||
|
|
||||||
|
interface ITextInputProps {
|
||||||
|
id?: string;
|
||||||
|
type?: InputType;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
|
||||||
|
export const TextInput: React.FC<ITextInputProps> = ({
|
||||||
|
id,
|
||||||
|
type = 'text',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: $gray-300;
|
||||||
|
transition: background .18s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
transition: transform .18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:checked + .track {
|
||||||
|
background: $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:checked + .track .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus-visible + .track {
|
||||||
|
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только чтение: приглушаем переключатель и подпись.
|
||||||
|
.input:disabled + .track {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled ~ .text {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Toggle.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
|
interface IToggleProps {
|
||||||
|
id?: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
/** Подпись справа от переключателя (да/нет-поля). */
|
||||||
|
onLabel?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переключатель да/нет (SharePoint Boolean). */
|
||||||
|
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
return (
|
||||||
|
<label className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.input}
|
||||||
|
checked={checked}
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.track}>
|
||||||
|
<span className={styles.thumb} />
|
||||||
|
</span>
|
||||||
|
{onLabel && <span className={styles.text}>{onLabel}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
+49
-1
@@ -1 +1,49 @@
|
|||||||
export { Button } from "./Button/Button";
|
// ============================================================
|
||||||
|
// @tnsu/ui-kit — единая точка экспорта компонентов.
|
||||||
|
// Только чистая презентация. Бизнес-логика/SharePoint — в проектах.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// --- Кнопки ---
|
||||||
|
export { Button } from './Button/Button';
|
||||||
|
export { PrintButton } from './PrintButton/PrintButton';
|
||||||
|
|
||||||
|
// --- Сообщения / плашки ---
|
||||||
|
export { Alert } from './Alert/Alert';
|
||||||
|
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||||
|
|
||||||
|
// --- Оболочка и раскладка формы ---
|
||||||
|
export { FormCard } from './FormCard/FormCard';
|
||||||
|
export { FormGrid } from './FormGrid/FormGrid';
|
||||||
|
export { FormField } from './FormField/FormField';
|
||||||
|
export { FormActions } from './FormActions/FormActions';
|
||||||
|
export { CollapseSection } from './CollapseSection/CollapseSection';
|
||||||
|
|
||||||
|
// --- Поля ввода ---
|
||||||
|
export { TextInput } from './TextInput/TextInput';
|
||||||
|
export { TextArea } from './TextArea/TextArea';
|
||||||
|
export { PhoneInput } from './PhoneInput/PhoneInput';
|
||||||
|
export { MoneyInput } from './MoneyInput/MoneyInput';
|
||||||
|
export { Select } from './Select/Select';
|
||||||
|
export type { ISelectOption } from './Select/Select';
|
||||||
|
export { Toggle } from './Toggle/Toggle';
|
||||||
|
|
||||||
|
// --- Режим «только чтение» для полей формы ---
|
||||||
|
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||||
|
|
||||||
|
// --- Иконки ---
|
||||||
|
export { Icon } from './Icon/Icon';
|
||||||
|
export { IconSprite } from './Icon/IconSprite';
|
||||||
|
export type { IconName } from './Icon/iconNames';
|
||||||
|
|
||||||
|
// --- Файлы / вложения ---
|
||||||
|
export { FileDropzone } from './FileDropzone/FileDropzone';
|
||||||
|
export { AttachmentList } from './AttachmentList/AttachmentList';
|
||||||
|
export type { IAttachmentInfo } from './AttachmentList/AttachmentList';
|
||||||
|
|
||||||
|
// --- Модалка подтверждения ---
|
||||||
|
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
|
||||||
|
export { confirmModal } from './ConfirmDialog/confirmModal';
|
||||||
|
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
|
||||||
|
|
||||||
|
// --- Хук анимации перестановки строк таблицы (FLIP) ---
|
||||||
|
export { useFlipRows } from './useFlipRows';
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Анимация перестановки строк маршрута по технике FLIP (First–Last–Invert–Play):
|
||||||
|
// вместо ручного «переезда» мы даём React переставить строки мгновенно, затем
|
||||||
|
// вспоминаем, где строка была до этого, и проигрываем её путь от старого
|
||||||
|
// положения к новому. Так анимация всегда совпадает с реальной раскладкой
|
||||||
|
// таблицы, даже когда у шага несколько параллельных строк разной высоты
|
||||||
|
// (см. rowSpan в ApprovalMatrix).
|
||||||
|
//
|
||||||
|
// Работает ТОЛЬКО при устойчивых ключах строк (см. matrixRowKeys в
|
||||||
|
// useMatrixEditor): с key={index} React не двигает <tr>, а переписывает их
|
||||||
|
// содержимое на месте — двигаться было бы нечему.
|
||||||
|
//
|
||||||
|
// Всё, что мы навешиваем на узлы, — это ИНЛАЙНОВЫЕ стили и анимации Web
|
||||||
|
// Animations API, и это принципиально: className у <tr> задаёт React (там
|
||||||
|
// живёт .parallelRow), и при перерисовке он затёр бы класс, добавленный руками.
|
||||||
|
// Свойство style у <tr>/<td> в разметке не задано, поэтому React его не трогает.
|
||||||
|
//
|
||||||
|
// Чего здесь намеренно НЕТ (проверено на макете — выглядело плохо):
|
||||||
|
// • scale у строки: ячейка «Шаг» объединена по вертикали (rowSpan) и живёт в
|
||||||
|
// ПЕРВОЙ строке блока, поэтому масштабирование каждой строки вокруг своего
|
||||||
|
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||||
|
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||||
|
// горизонтальной прокрутки.
|
||||||
|
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
||||||
|
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
||||||
|
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||||
|
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||||
|
// пользователь (activeKeys).
|
||||||
|
|
||||||
|
const MOVE_MS = 380;
|
||||||
|
const MOVE_EASING = 'cubic-bezier(.22, 1, .36, 1)';
|
||||||
|
const FLASH_MS = 620;
|
||||||
|
const ENTER_MS = 320;
|
||||||
|
const ENTER_STAGGER_MS = 45;
|
||||||
|
/** Смещение меньше этого — дребезг раскладки, а не перестановка: не анимируем. */
|
||||||
|
const MIN_SHIFT_PX = 1;
|
||||||
|
|
||||||
|
// Пока блок летит, он поднят над остальными и непрозрачен — иначе при обмене
|
||||||
|
// двух шагов строки пересекаются и текст накладывается друг на друга. Значения
|
||||||
|
// повторяют $white и (чуть плотнее) $sh-2 из styles/_design-tokens.scss —
|
||||||
|
// инлайновыми они заданы потому, что снимать их надо тоже из JS.
|
||||||
|
const LIFT_BG = '#fff';
|
||||||
|
const LIFT_SHADOW = '0 6px 18px rgba(15, 36, 64, .13)';
|
||||||
|
const FLASH_FROM = 'rgba(212, 160, 30, .22)'; // $gold-500
|
||||||
|
const FLASH_TO = 'rgba(212, 160, 30, 0)';
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lift(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = 'relative';
|
||||||
|
row.style.zIndex = '2';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = LIFT_BG;
|
||||||
|
td.style.boxShadow = LIFT_SHADOW;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = '';
|
||||||
|
row.style.zIndex = '';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = '';
|
||||||
|
td.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Положение строк ОТНОСИТЕЛЬНО их <tbody>, а не окна.
|
||||||
|
*
|
||||||
|
* Это принципиально: getBoundingClientRect() отдаёт координату относительно
|
||||||
|
* ОКНА, а прокрутка страницы не вызывает ре-рендер — значит, запомненные с
|
||||||
|
* прошлого рендера позиции остаются от прежнего положения прокрутки. Стоит
|
||||||
|
* пользователю прокрутить страницу к маршруту и нажать стрелку, как в сдвиг
|
||||||
|
* попадает вся дельта прокрутки (сотни пикселей), и строки улетают за пределы
|
||||||
|
* таблицы. Именно так выглядел баг «первое нажатие ломает, дальше нормально»:
|
||||||
|
* повторные клики уже сравнивались с позициями, замеренными при текущей
|
||||||
|
* прокрутке, поэтому шли правильно.
|
||||||
|
*
|
||||||
|
* Относительная база от этого избавляет: и строка, и <tbody> замеряются в
|
||||||
|
* одном кадре, поэтому прокрутка сокращается. Заодно это чинит и случай, когда
|
||||||
|
* блок маршрута целиком уезжает из-за изменений НАД ним (например, свернулся
|
||||||
|
* блок вложений): tbody уезжает вместе со строками, и мнимого сдвига нет.
|
||||||
|
*/
|
||||||
|
function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, number> {
|
||||||
|
const tops = new Map<string, number>();
|
||||||
|
|
||||||
|
let firstRow: HTMLTableRowElement | undefined;
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
if (!firstRow) firstRow = el;
|
||||||
|
});
|
||||||
|
if (!firstRow) return tops;
|
||||||
|
|
||||||
|
const container = (firstRow as HTMLTableRowElement).parentElement;
|
||||||
|
const base = container ? container.getBoundingClientRect().top : 0;
|
||||||
|
|
||||||
|
nodes.forEach((el, key) => tops.set(key, el.getBoundingClientRect().top - base));
|
||||||
|
return tops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
|
||||||
|
* @param activeKeys ключи строк того шага, который двигает пользователь —
|
||||||
|
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
|
||||||
|
* Для прочих изменений состава (добавление/удаление) — пустой массив.
|
||||||
|
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
||||||
|
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
||||||
|
* геометрии для этого недостаточно).
|
||||||
|
*/
|
||||||
|
export function useFlipRows(
|
||||||
|
activeKeys: string[],
|
||||||
|
rowKeys: string[]
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||||
|
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||||
|
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
||||||
|
// Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы
|
||||||
|
// ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
|
||||||
|
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
|
||||||
|
const prevTops = React.useRef(new Map<string, number>());
|
||||||
|
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
||||||
|
const prevRowKeys = React.useRef<string[]>([]);
|
||||||
|
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||||
|
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||||
|
const running = React.useRef<Animation[]>([]);
|
||||||
|
|
||||||
|
const register = React.useCallback((key: string) => {
|
||||||
|
let cb = refCache.current.get(key);
|
||||||
|
if (!cb) {
|
||||||
|
cb = (el: HTMLTableRowElement | null): void => {
|
||||||
|
if (el) nodes.current.set(key, el);
|
||||||
|
else nodes.current.delete(key);
|
||||||
|
};
|
||||||
|
refCache.current.set(key, cb);
|
||||||
|
}
|
||||||
|
return cb;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Без списка зависимостей — намеренно: измерять надо ПОСЛЕ КАЖДОГО коммита,
|
||||||
|
// строка могла переехать от любого изменения. useLayoutEffect, а не
|
||||||
|
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
|
||||||
|
// иначе будет заметен скачок.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
// ВАЖНО: «строка сдвинулась по пикселям» — недостаточный повод анимировать.
|
||||||
|
// Пример живого бага: заявку отправили на согласование → таблица стала
|
||||||
|
// read-only → у строк пропали колонки со стрелками/кнопками → ширина
|
||||||
|
// текстовых колонок изменилась → где-то текст перенёсся по-другому →
|
||||||
|
// строки СМЕСТИЛИСЬ ПО ВЫСОТЕ, хотя их состав и порядок не менялись вовсе.
|
||||||
|
// Раньше это трактовалось как «переезд» и запускало translateY на
|
||||||
|
// случайную величину — на кадре это выглядело как строки, которые на
|
||||||
|
// мгновение пропадают (уезжают за пределы видимой области) и выскакивает
|
||||||
|
// скролл. Настоящая перестановка меняет НАБОР/ПОРЯДОК ключей — вот это и
|
||||||
|
// проверяем, а не сам факт смещения.
|
||||||
|
const orderChanged =
|
||||||
|
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
||||||
|
prevRowKeys.current = rowKeys;
|
||||||
|
|
||||||
|
if (!orderChanged) {
|
||||||
|
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
||||||
|
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
||||||
|
prevTops.current = measureTops(nodes.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
|
||||||
|
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||||
|
// текущего положения на экране, иначе будет рывок.
|
||||||
|
const visualTops = measureTops(nodes.current);
|
||||||
|
|
||||||
|
running.current.forEach((a) => a.cancel());
|
||||||
|
running.current = [];
|
||||||
|
nodes.current.forEach(drop);
|
||||||
|
|
||||||
|
const previous = prevTops.current;
|
||||||
|
// После отмены анимаций transform снят — это истинные новые положения.
|
||||||
|
const current = measureTops(nodes.current);
|
||||||
|
const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = [];
|
||||||
|
const entered: HTMLTableRowElement[] = [];
|
||||||
|
|
||||||
|
nodes.current.forEach((el, key) => {
|
||||||
|
const layoutTop = current.get(key);
|
||||||
|
if (layoutTop === undefined) return;
|
||||||
|
|
||||||
|
const before = previous.get(key);
|
||||||
|
if (before === undefined) {
|
||||||
|
entered.push(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Недоигранный сдвиг прошлой анимации + переезд из-за перестановки.
|
||||||
|
const leftover = (visualTops.get(key) ?? layoutTop) - layoutTop;
|
||||||
|
const shift = before - layoutTop + leftover;
|
||||||
|
if (Math.abs(shift) >= MIN_SHIFT_PX) moved.push({ el, key, shift });
|
||||||
|
});
|
||||||
|
|
||||||
|
prevTops.current = current;
|
||||||
|
|
||||||
|
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
||||||
|
|
||||||
|
moved.forEach(({ el, key, shift }) => {
|
||||||
|
const isActive = activeKeys.indexOf(key) !== -1;
|
||||||
|
if (isActive) lift(el);
|
||||||
|
|
||||||
|
running.current.push(
|
||||||
|
el.animate([{ transform: `translateY(${shift}px)` }, { transform: 'translateY(0)' }], {
|
||||||
|
duration: MOVE_MS,
|
||||||
|
easing: MOVE_EASING,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Вспышка по ячейкам, а не по <tr>: у параллельных строк фон задан на td
|
||||||
|
// (.parallelRow > td), и подсветка строки была бы им закрыта.
|
||||||
|
Array.prototype.forEach.call(el.cells, (td: HTMLTableCellElement) => {
|
||||||
|
running.current.push(
|
||||||
|
td.animate([{ backgroundColor: FLASH_FROM }, { backgroundColor: FLASH_TO }], {
|
||||||
|
duration: FLASH_MS,
|
||||||
|
easing: 'ease-out',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
entered.forEach((el, i) => {
|
||||||
|
running.current.push(
|
||||||
|
el.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: 'translateY(-6px)' },
|
||||||
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
],
|
||||||
|
{ duration: ENTER_MS, delay: i * ENTER_STAGGER_MS, easing: 'ease-out', fill: 'backwards' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Приземлились — снимаем подъём. Отменённая анимация роняет finished
|
||||||
|
// с AbortError (это штатный путь при перехвате следующим кликом), поэтому
|
||||||
|
// ошибку гасим: подъём снимет уже тот проход, который её отменил.
|
||||||
|
const landing = running.current.map((a) => a.finished);
|
||||||
|
Promise.all(landing)
|
||||||
|
.then(() => nodes.current.forEach(drop))
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
running.current.forEach((a) => a.cancel());
|
||||||
|
running.current = [];
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return register;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user