feat: request portal — bespoke forms + approval mechanism

This commit is contained in:
Тимур Городецкий
2026-08-03 09:35:14 +05:00
commit bc91c9861d
110 changed files with 30562 additions and 0 deletions
@@ -0,0 +1,30 @@
@import '../../../../../styles/design-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,73 @@
@import '../../../../../styles/design-tokens';
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 10px 20px;
border: 1px solid transparent;
border-radius: $r-sm;
font-family: $font-sans;
font-size: 13px;
font-weight: 500;
letter-spacing: .15px;
white-space: nowrap;
cursor: pointer;
transition: .18s;
&:disabled {
opacity: .55;
cursor: default;
pointer-events: none;
}
}
.sm {
padding: 7px 14px;
font-size: 12.5px;
}
.full {
width: 100%;
}
.primary {
background: $navy-800;
border-color: $navy-800;
color: #fff;
&:hover { background: $navy-700; box-shadow: $sh-2; }
}
.accent {
background: $grad-gold;
border-color: $gold-500;
color: $navy-950;
font-weight: 600;
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
}
.secondary {
background: $white;
border-color: $gray-300;
color: $navy-800;
&:hover { border-color: $gold-500; color: $navy-900; }
}
.ghost {
background: transparent;
color: $navy-700;
&:hover { background: $navy-100; }
}
.danger {
background: $white;
border-color: $gray-300;
color: $red-500;
&:hover { border-color: $red-500; background: $red-50; }
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import styles from './Button.module.scss';
type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'sm';
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
}
const variantClass: Record<ButtonVariant, string> = {
primary: styles.primary,
accent: styles.accent,
secondary: styles.secondary,
ghost: styles.ghost,
danger: styles.danger
};
export const Button: React.FC<IButtonProps> = ({
variant = 'secondary',
size = 'md',
fullWidth = false,
type = 'button',
className,
children,
...rest
}) => {
const classes = [
styles.btn,
variantClass[variant],
size === 'sm' ? styles.sm : '',
fullWidth ? styles.full : '',
className
]
.filter(Boolean)
.join(' ');
// type по умолчанию 'button', чтобы кнопки внутри формы не сабмитили её случайно.
return (
<button type={type} className={classes} {...rest}>
{children}
</button>
);
};
@@ -0,0 +1,62 @@
@import '../../../../../styles/design-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,15 @@
@import '../../../../../styles/design-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,28 @@
@import '../../../../../styles/design-tokens';
.card {
background: $white;
border: 1px solid $gray-200;
border-radius: $r-lg;
box-shadow: $sh-1;
padding: 32px;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-900;
}
.subtitle {
font-size: 12px;
color: $gray-400;
margin-top: 6px;
letter-spacing: .3px;
}
.hairline {
@include hairline;
margin: 6px 0 22px;
}
@@ -0,0 +1,20 @@
import * as React from 'react';
import styles from './FormCard.module.scss';
interface IFormCardProps {
title: string;
subtitle?: string;
children: React.ReactNode;
}
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, children }) => {
return (
<div className={styles.card}>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
<hr className={styles.hairline} />
{children}
</div>
);
};
@@ -0,0 +1,23 @@
@import '../../../../../styles/design-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 '../../../../../styles/design-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,22 @@
@import '../../../../../styles/design-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,130 @@
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-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,35 @@
// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite).
// Добавляя иконку: 1) добавь имя сюда, 2) добавь <symbol> в IconSprite.
export const ICON_NAMES = [
'building',
'chart',
'monitor',
'calculator',
'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,33 @@
import * as React from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
interface IPeopleFieldProps {
context: WebPartContext;
/** Логин/e-mail выбранного сотрудника (хранится в модели формы). */
value: string;
onChange: (login: string) => void;
placeholder?: string;
}
/** Выбор ОДНОГО сотрудника. Обёртка над PeoplePicker из @pnp/spfx-controls-react. */
export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onChange, placeholder }) => {
const handleChange = (items: IPickedUser[]): void => {
const first = items && items.length > 0 ? items[0] : undefined;
onChange(first ? pickedLogin(first) : '');
};
return (
<PeoplePicker
context={buildPickerContext(context)}
personSelectionLimit={1}
principalTypes={[PrincipalType.User]}
defaultSelectedUsers={value ? [value] : []}
resolveDelay={300}
ensureUser
placeholder={placeholder}
onChange={handleChange}
/>
);
};
@@ -0,0 +1,41 @@
import * as React from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
interface IPeopleFieldMultiProps {
context: WebPartContext;
/** Логины выбранных сотрудников. */
value: string[];
onChange: (logins: string[]) => void;
placeholder?: string;
/** Максимум выбираемых людей. */
limit?: number;
}
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
context,
value,
onChange,
placeholder,
limit = 10
}) => {
const handleChange = (items: IPickedUser[]): void => {
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
onChange(logins);
};
return (
<PeoplePicker
context={buildPickerContext(context)}
personSelectionLimit={limit}
principalTypes={[PrincipalType.User]}
defaultSelectedUsers={value}
resolveDelay={300}
ensureUser
placeholder={placeholder}
onChange={handleChange}
/>
);
};
@@ -0,0 +1,32 @@
import { IPeoplePickerContext } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base';
/** Что возвращает picker в onChange — берём логин (надёжнее для ensureUser/предзаполнения). */
export interface IPickedUser {
loginName?: string;
secondaryText?: string;
id?: string;
}
/**
* Идентификатор выбранного пользователя для хранения в форме.
* Берём e-mail (secondaryText): пикер надёжно резолвит его обратно в ФИО через
* defaultSelectedUsers, и ensureUser(email) работает при записи. Клеймовый
* loginName (i:0#.f|membership|…) в чип попадает как есть, без имени — поэтому он в конце.
*/
export function pickedLogin(user: IPickedUser): string {
return user.secondaryText || user.loginName || user.id || '';
}
/**
* Контекст для PeoplePicker из WebPartContext.
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
* из-за чего тип msGraphClientFactory формально несовместим (в рантайме объекты те же).
*/
export function buildPickerContext(context: WebPartContext): IPeoplePickerContext {
return {
absoluteUrl: context.pageContext.web.absoluteUrl,
msGraphClientFactory: context.msGraphClientFactory,
spHttpClient: context.spHttpClient
} as unknown as IPeoplePickerContext;
}
@@ -0,0 +1,19 @@
@import '../../../../../styles/design-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;
}
@@ -0,0 +1,34 @@
import * as React from 'react';
import styles from './Select.module.scss';
export interface ISelectOption {
value: string;
label: string;
}
interface ISelectProps {
id?: string;
value: string;
onChange: (value: string) => void;
options: ISelectOption[];
placeholder?: string;
}
/** Выпадающий список (SharePoint Choice). */
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder }) => {
return (
<select
id={id}
className={styles.select}
value={value}
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,20 @@
@import '../../../../../styles/design-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;
&::placeholder {
color: $gray-400;
}
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import styles from './TextArea.module.scss';
interface ITextAreaProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
/** Многострочный ввод (SharePoint Note). */
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3 }) => {
return (
<textarea
id={id}
className={styles.textarea}
value={value}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value)}
/>
);
};
@@ -0,0 +1,18 @@
@import '../../../../../styles/design-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;
&::placeholder {
color: $gray-400;
}
}
@@ -0,0 +1,26 @@
import * as React from 'react';
import styles from './TextInput.module.scss';
type InputType = 'text' | 'number' | 'date';
interface ITextInputProps {
id?: string;
type?: InputType;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
export const TextInput: React.FC<ITextInputProps> = ({ id, type = 'text', value, onChange, placeholder }) => {
return (
<input
id={id}
className={styles.input}
type={type}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
};
@@ -0,0 +1,55 @@
@import '../../../../../styles/design-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);
}
.text {
font-size: 13px;
color: $gray-700;
}
@@ -0,0 +1,29 @@
import * as React from 'react';
import styles from './Toggle.module.scss';
interface IToggleProps {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
/** Подпись справа от переключателя (да/нет-поля). */
onLabel?: string;
}
/** Переключатель да/нет (SharePoint Boolean). */
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel }) => {
return (
<label className={styles.wrap}>
<input
id={id}
type="checkbox"
className={styles.input}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles.track}>
<span className={styles.thumb} />
</span>
{onLabel && <span className={styles.text}>{onLabel}</span>}
</label>
);
};
@@ -0,0 +1,16 @@
// Единая точка импорта примитивов формы.
export { Button } from './Button/Button';
export { Alert } from './Alert/Alert';
export { FormCard } from './FormCard/FormCard';
export { FormGrid } from './FormGrid/FormGrid';
export { FormField } from './FormField/FormField';
export { TextInput } from './TextInput/TextInput';
export { TextArea } from './TextArea/TextArea';
export { Select } from './Select/Select';
export type { ISelectOption } from './Select/Select';
export { Toggle } from './Toggle/Toggle';
export { CollapseSection } from './CollapseSection/CollapseSection';
export { FormActions } from './FormActions/FormActions';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { Icon } from './Icon/Icon';