feat: перенос быстрой пачки компонентов 0.1.0

This commit is contained in:
Тимур Городецкий
2026-08-13 14:53:11 +05:00
parent f5637f8b2c
commit e65805379e
42 changed files with 1911 additions and 5 deletions
+44
View File
@@ -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-токеном — кросс-доступ к личным пакетам работает, переносить в организацию пока НЕ нужно.
## Окружение ## Окружение
+11 -3
View File
@@ -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
View File
@@ -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",
+30
View File
@@ -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;
}
+34
View File
@@ -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;
}
+49
View File
@@ -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;
}
}
+32
View File
@@ -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); }
}
+82
View File
@@ -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)} />
);
};
+44
View File
@@ -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));
}
+75
View File
@@ -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;
}
}
+87
View File
@@ -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>
);
};
+15
View File
@@ -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);
}
+11
View File
@@ -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>;
};
+42
View File
@@ -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;
}
+30
View File
@@ -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>
);
};
+23
View File
@@ -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;
}
+26
View File
@@ -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>
);
};
+11
View File
@@ -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;
}
}
+11
View File
@@ -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>;
};
+16
View File
@@ -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);
}
+22
View File
@@ -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;
}
+30
View File
@@ -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>
);
};
+140
View File
@@ -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>
);
+37
View File
@@ -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];
+31
View File
@@ -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}
/>
);
+45
View File
@@ -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}
/>
);
+117
View File
@@ -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;
}
+7
View File
@@ -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>
);
+20
View File
@@ -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');
}
+40
View File
@@ -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>
);
};
+21
View File
@@ -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;
}
}
+30
View File
@@ -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)}
/>
);
};
+19
View File
@@ -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;
}
}
+39
View File
@@ -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)}
/>
);
};
+65
View File
@@ -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;
}
+35
View File
@@ -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
View File
@@ -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';
+255
View File
@@ -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;
}