Compare commits

..

11 Commits

Author SHA1 Message Date
gorbenko.pavel ec13410945 Реестр пакетов: организация Tansu переименована в tansu (#2)
test / test (push) Successful in 20s
Организацию на git.tnsu.kz переименовали в tansu. Адрес реестра @tnsu в .npmrc и resolved в package-lock.json приведены к новому имени. Gitea принимает оба написания, так что сборка работает и до влития.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #2
2026-10-29 12:46:12 +00:00
Тимур Городецкий 86f4ec379a feat: иконка chair — кресло для раздела «Мебель» (0.15.0)
test / test (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:19:27 +05:00
gorbenko.pavel 84964b62c1 Merge pull request 'Пайплайны test и publish, публикация в реестр организации Tansu' (#1) from feature/publish-pipeline into main
test / test (push) Successful in 15s
2026-10-28 12:49:04 +00:00
gorbenko.pavel 65fc6ced30 Публикация ui-kit: нужен только секрет PACKAGES_TOKEN, логин не используется
test / test (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:00 +05:00
gorbenko.pavel 6fd3672edc Ссылка на репозиторий — прежняя репа timur.gorodetskiy/tnsu-ui-kit
test / test (pull_request) Successful in 14s
Код остаётся в этой репе, публикация по-прежнему в реестр организации Tansu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:23:56 +05:00
gorbenko.pavel 2a32b998f6 Пайплайны test и publish, реестр — организация Tansu
- test: юнит-тесты и сборка на каждый push в main, на PR и по кнопке
- publish: по кнопке тесты и публикация в реестр Tansu; ci/publish.sh сам считает патч-версию
  по реестру (major.minor — из package.json) и не публикует, если хэш кода совпал с последней
  опубликованной версией
- .npmrc и publishConfig — реестр Tansu, repository — Tansu/tnsu-ui-kit
- README: установка без токена, публикация кнопкой

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:19:14 +05:00
Тимур Городецкий 048fec0696 feat: Sidebar — разделители и перенос длинных названий (0.14.0)
Проп dividers рисует тонкие линии между пунктами (подпункты раскрытой
группы — без линий). Длинное название пункта переносится на вторую
строку вместо обрезки многоточием (не больше 2 строк).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:54:45 +05:00
Тимур Городецкий 06e9007e73 feat: Sidebar — пункты-ссылки и раскрывающиеся группы (0.13.0)
ISidebarItem.href открывает пункт как внешнюю ссылку в новой вкладке,
ISidebarItem.children делает пункт раскрывающейся группой с подпунктами
(ссылки или разделы). Без новых полей меню работает как раньше.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 10:12:13 +05:00
Тимур Городецкий 0082b90c2b feat: DropdownButton + printRoot() экспорт из PrintButton (0.12.0)
Кнопка с выпадающим меню (клик по кнопке — открыть список, выбор варианта
сразу вызывает onSelect). Логика печати PrintButton вынесена в printRoot(),
чтобы её можно было вызвать программно после выбора варианта в DropdownButton.
2026-08-27 10:02:29 +05:00
Тимур Городецкий f2e8108d1b feat: TextInput/TextArea borderless+ariaLabel+onKeyDown, Alert info, иконка info (0.11.0)
Для инлайн-ячеек таблиц СТ и info-баннеров при перекраске форм.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 08:39:25 +05:00
Тимур Городецкий fa6fe8dbe1 feat: примитивы Checkbox, Spinner, IconButton, Dialog, Link (0.10.0)
Набор B перекраски форм: брендовые адаптивные примитивы + иконки
arrow-up/arrow-down/swap/refresh + токен $amber-50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:28:50 +05:00
47 changed files with 1699 additions and 135 deletions
+3
View File
@@ -0,0 +1,3 @@
# Скрипты и пайплайны запускаются на Linux-раннере: только LF.
*.sh text eol=lf
.gitea/**/*.yml text eol=lf
+34
View File
@@ -0,0 +1,34 @@
name: publish
# Публикация @tnsu/ui-kit в реестр организации tansu — только по кнопке (Actions -> publish -> Run workflow).
# Сначала юнит-тесты и сборка, потом ci/publish.sh: версию он считает сам (major.minor — из package.json,
# патч — следующий после опубликованного), а если код не менялся с последней опубликованной версии,
# падает с «Публиковать нечего».
#
# Секрет: PACKAGES_TOKEN — токен Gitea со scope write:package (его владелец должен состоять в организации
# tansu с правом записи). Логин не нужен: npm авторизуется одним токеном.
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install
run: npm ci --no-audit --no-fund
- name: Unit tests
run: npm test
- name: Publish
env:
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: bash ci/publish.sh
+29
View File
@@ -0,0 +1,29 @@
name: test
# Юнит-тесты и сборка пакета: на каждый push в main, на pull request и по кнопке.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install
run: npm ci --no-audit --no-fund
- name: Unit tests
run: npm test
- name: Build
run: npm run build
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+4 -4
View File
@@ -169,11 +169,11 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
## Публикация (Gitea, НЕ GitLab!) ## Публикация (Gitea, НЕ GitLab!)
- Хостинг git — **Gitea 1.26.4** на http://git.tnsu.kz (не GitLab). Registry npm привязан - Хостинг git — **Gitea 1.26.4** на https://git.tnsu.kz (не GitLab). Registry npm привязан
к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно. к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно.
- Адрес registry: `http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/` - Адрес registry: `https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
- `.npmrc` в проекте (можно коммитить, НЕ секрет): - `.npmrc` в проекте (можно коммитить, НЕ секрет):
`@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/` `@tnsu:registry=https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
- Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает: - Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает:
`//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>` `//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>`
- Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись. - Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись.
@@ -188,4 +188,4 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
- Node: v22.x (как в SPFx: >=22.14 <23) - Node: v22.x (как в SPFx: >=22.14 <23)
- React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1 - React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1
- GitLab: http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git - GitLab: https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git
+22 -37
View File
@@ -4,40 +4,15 @@
> Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md). > Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md).
## Первичная настройка (один раз на каждого разработчика)
Пакет лежит в приватном registry (Gitea), поэтому перед первой установкой нужно один
раз настроить доступ. Дальше про это можно забыть.
**Шаг 1. Создать токен в Gitea:**
1. Открой http://git.tnsu.kz → кликни на свой аватар справа вверху → **Настройки**.
2. Слева: **Приложения**.
3. В блоке «Управление токенами» → **Создать новый токен**:
- Имя: например `npm-read`.
- Права (scopes): найди строку **`package`** и поставь **«Прочитанные»** (Read).
Остальное — «Нет доступа».
4. Нажми **Генерировать токен** и **сразу скопируй** его (показывается один раз).
**Шаг 2. Прописать токен у себя на компьютере.**
Создай (или дополни) файл `.npmrc` в своей домашней папке
(`C:\Users\<твоё-имя>\.npmrc`) одной строкой:
```
//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ВСТАВЬ_СВОЙ_ТОКЕН
```
> ⚠️ Токен — это как пароль. Он лежит в домашней папке, **вне проекта**, и в git не попадает.
> Никогда не коммить его и не вставляй в код.
Всё. Теперь `npm i` в любом проекте, где подключён `@tnsu/ui-kit`, будет работать.
## Установка в проект ## Установка в проект
Убедись, что в корне проекта есть файл `.npmrc` со строкой (для request-portal он уже Пакет лежит в реестре организации **tansu** на git.tnsu.kz. Реестр публичный, а сам git.tnsu.kz
закоммичен): доступен только из внутренней сети, поэтому **токен для установки не нужен** (из сети или по VPN).
В корне проекта — файл `.npmrc` со строкой:
``` ```
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
``` ```
Затем: Затем:
@@ -86,11 +61,21 @@ npm run storybook # витрина компонентов (localhost:6006)
### Публикация новой версии ### Публикация новой версии
```powershell Публикует пайплайн **publish** в Gitea — по кнопке: Actions → publish → Run workflow. Сначала юнит-тесты
# 1. поднять version в package.json (напр. 0.0.1 → 0.0.2) и сборка, потом публикация в реестр tansu.
npm run build
npm publish
```
> npm не даёт перезаписать уже опубликованную версию — перед публикацией всегда - **Версию поднимает пайплайн.** В `package.json` задаются только major.minor (патч там не важен):
> бампай `version` в package.json. новая функциональность — поднять minor руками (`0.14.x` → `0.15.0`). Патч — следующий после самого
большого опубликованного с тем же major.minor. В git ничего не коммитится.
- **Тот же код не публикуется.** Пайплайн считает хэш исходников (`src` без тестов и историй Storybook,
`package.json`, конфиги сборки, README), кладёт его в публикуемый `package.json` (поле `sourceHash`)
и сравнивает с последней опубликованной версией. Совпало — пайплайн падает с «Публиковать нечего».
- Секрет: `PACKAGES_TOKEN` — токен Gitea со scope `write:package` (владелец — участник организации tansu
с правом записи). Логин не нужен.
Пробный прогон без публикации: `DRY_RUN=1 bash ci/publish.sh`.
Версии до 0.14.0 скопированы из прежнего реестра `timur.gorodetskiy` без хэша, поэтому первая публикация
пайплайном выпустит `0.14.1` даже без изменений кода; дальше проверка работает как обычно.
На каждый push и pull request пайплайн **test** гоняет юнит-тесты и сборку.
Executable
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Публикация @tnsu/ui-kit в реестр организации tansu с автоматической версией.
#
# bash ci/publish.sh
#
# Версия: в package.json задаются только major.minor (патч там не важен). Патч считается здесь:
# следующий после самого большого опубликованного с тем же major.minor, или 0. В git ничего не коммитится.
#
# Хэш кода: sha256 от путей и git-хэшей файлов пакета (тесты и истории Storybook не входят). Кладётся
# в публикуемый package.json (поле sourceHash). Перед публикацией скачивается последняя опубликованная
# версия и хэш сравнивается: код тот же — публикации нет. Хэш берётся из самого архива, а не из
# метаданных реестра: архив реестр хранит как есть.
#
# Переменные: PACKAGES_TOKEN (scope write:package), REGISTRY_BASE (по умолчанию
# https://git.tnsu.kz/api/packages/tansu), DRY_RUN=1 — всё посчитать и собрать, но не публиковать.
#
# Коды выхода: 0 — опубликовано; 1 — ошибка; 3 — публиковать нечего (код не менялся).
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY_BASE="${REGISTRY_BASE:-https://git.tnsu.kz/api/packages/tansu}"
DRY_RUN="${DRY_RUN:-}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
source_hash() {
git ls-files -s -- "$@" | grep -vE '\.(test\.ts|stories\.tsx)$' | awk '{print $2" "$4}' | sha256sum | cut -c1-16
}
max_version() {
node -e '
const v = require("fs").readFileSync(0, "utf8").split(/\s+/).filter((s) => /^\d+\.\d+\.\d+$/.test(s));
const key = (s) => s.split(".").map(Number);
v.sort((a, b) => { const x = key(a), y = key(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; });
process.stdout.write(v.length ? v[v.length - 1] : "");
'
}
next_version() {
local repo_version="$1" published="$2"
node -e '
const [repo, published] = [process.argv[1], process.argv[2]];
const [major, minor] = repo.split(".").map(Number);
const versions = published.split(/\s+/).filter((s) => /^\d+\.\d+\.\d+$/.test(s)).map((s) => s.split(".").map(Number));
const newer = versions.find(([a, b]) => a > major || (a === major && b > minor));
if (newer) { console.error(`В реестре уже есть ${newer.join(".")}, а в репозитории ${major}.${minor}: поднимите версию в репозитории.`); process.exit(1); }
const patches = versions.filter(([a, b]) => a === major && b === minor).map(([, , c]) => c);
process.stdout.write(`${major}.${minor}.${patches.length ? Math.max(...patches) + 1 : 0}`);
' "$repo_version" "$published"
}
# Реестр публичный, читать можно без токена.
fetch() {
local url="$1" output="$2" status
status=$(curl -sS -o "$output" -w '%{http_code}' "$url") || status=000
echo "$status"
}
hash=$(source_hash src package.json vite.config.ts tsconfig.json global.d.ts README.md)
repo_version=$(node -p "require('./package.json').version")
latest="" published="" latest_hash=""
status=$(fetch "$REGISTRY_BASE/npm/@tnsu%2Fui-kit" "$WORK/packument.json")
case "$status" in
200)
published=$(node -e 'process.stdout.write(Object.keys(require(process.argv[1]).versions || {}).join(" "))' "$WORK/packument.json")
latest=$(echo "$published" | tr ' ' '\n' | max_version)
;;
404) ;;
*) echo "Реестр npm ответил $status" >&2; cat "$WORK/packument.json" >&2 || true; exit 1 ;;
esac
if [[ -n $latest ]]; then
tarball=$(node -e 'process.stdout.write(require(process.argv[1]).versions[process.argv[2]].dist.tarball)' "$WORK/packument.json" "$latest")
status=$(fetch "$tarball" "$WORK/latest.tgz")
[[ $status == 200 ]] || { echo "Не скачался @tnsu/ui-kit@$latest: $status" >&2; exit 1; }
latest_hash=$(tar -xzOf "$WORK/latest.tgz" package/package.json | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).sourceHash || "")')
fi
echo "хэш кода $hash; последняя опубликованная ${latest:-нет} (хэш ${latest_hash:-нет})"
if [[ -n $latest_hash && $latest_hash == "$hash" ]]; then
echo "Публиковать нечего: код не менялся с $latest." >&2
exit 3
fi
version=$(next_version "$repo_version" "$published")
echo "публикую $version"
# Версия и хэш — только в рабочей копии на время публикации.
cp package.json "$WORK/package.json.orig"
trap 'cp "$WORK/package.json.orig" package.json 2> /dev/null; rm -rf "$WORK"' EXIT
node -e '
const fs = require("fs"); const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
p.version = process.argv[1]; p.sourceHash = process.argv[2];
fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
' "$version" "$hash"
npm run build > /dev/null
if [[ -n $DRY_RUN ]]; then
npm pack --dry-run 2>&1 | grep -E 'name:|version:|total files'
echo "DRY_RUN: не публикую"
else
printf '//%s:_authToken=%s\n' "${REGISTRY_BASE#*://}/npm/" "$PACKAGES_TOKEN" > "$WORK/npmrc"
NPM_CONFIG_USERCONFIG="$WORK/npmrc" npm publish
fi
echo "Готово: @tnsu/ui-kit@$version"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.0.1", "version": "0.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.0.1", "version": "0.15.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "latest", "@chromatic-com/storybook": "latest",
+5 -2
View File
@@ -1,12 +1,15 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.9.0", "version": "0.15.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",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git" "url": "https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git"
},
"publishConfig": {
"registry": "https://git.tnsu.kz/api/packages/tansu/npm/"
}, },
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
+6
View File
@@ -28,3 +28,9 @@
color: $red-500; color: $red-500;
border-color: #fecaca; border-color: #fecaca;
} }
.info {
background: $navy-50;
color: $navy-800;
border-color: $navy-100;
}
+5 -3
View File
@@ -3,7 +3,7 @@ import styles from './Alert.module.scss';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames'; import { IconName } from '../Icon/iconNames';
type AlertVariant = 'warn' | 'ok' | 'err'; type AlertVariant = 'warn' | 'ok' | 'err' | 'info';
interface IAlertProps { interface IAlertProps {
variant: AlertVariant; variant: AlertVariant;
@@ -13,13 +13,15 @@ interface IAlertProps {
const variantClass: Record<AlertVariant, string> = { const variantClass: Record<AlertVariant, string> = {
warn: styles.warn, warn: styles.warn,
ok: styles.ok, ok: styles.ok,
err: styles.err err: styles.err,
info: styles.info
}; };
const variantIcon: Record<AlertVariant, IconName> = { const variantIcon: Record<AlertVariant, IconName> = {
warn: 'warning', warn: 'warning',
ok: 'check', ok: 'check',
err: 'close' err: 'close',
info: 'info'
}; };
export const Alert: React.FC<IAlertProps> = ({ variant, children }) => { export const Alert: React.FC<IAlertProps> = ({ variant, children }) => {
+103
View File
@@ -0,0 +1,103 @@
@import '../tokens/tokens';
.wrap {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-height: 24px;
vertical-align: middle;
}
// Нативный input скрыт, но остаётся фокусируемым/доступным.
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.box {
position: relative;
flex-shrink: 0;
width: 18px;
height: 18px;
border: 1.5px solid $gray-400;
border-radius: 4px;
background: $white;
transition: background .15s, border-color .15s;
}
// Выбран: navy-заливка + белая галка (контраст ~9:1).
.input:checked + .box {
background: $navy-800;
border-color: $navy-800;
}
.input:checked + .box::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 9px;
border: solid $white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
// Промежуточное (после :checked, чтобы перебить галку на чёрточку).
.input:indeterminate + .box {
background: $navy-800;
border-color: $navy-800;
}
.input:indeterminate + .box::after {
content: '';
position: absolute;
left: 3px;
top: 7px;
width: 10px;
height: 2px;
border: 0;
border-radius: 1px;
background: $white;
transform: none;
}
// Фокус с клавиатуры — золотое кольцо (бренд).
.input:focus-visible + .box {
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
border-color: $gold-500;
}
// Ховер (когда доступен).
.wrap:hover .input:not(:disabled) + .box {
border-color: $navy-600;
}
// Недоступен / только чтение.
.input:disabled + .box {
opacity: .5;
}
.input:disabled ~ .text {
color: $gray-400;
}
.wrap:has(.input:disabled) {
cursor: default;
}
.text {
font-size: $fs-sm;
color: $gray-700;
user-select: none;
}
// На узких экранах увеличиваем тач-таргет.
@include below($bp-md) {
.wrap { min-height: 32px; gap: 10px; }
.box { width: 20px; height: 20px; }
.input:checked + .box::after { left: 6px; top: 2px; }
}
+38
View File
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { Checkbox } from './Checkbox';
const meta: Meta<typeof Checkbox> = {
title: 'Поля/Checkbox',
component: Checkbox,
tags: ['autodocs'],
};
export default meta;
export const Default: StoryObj<typeof Checkbox> = {
render: () => {
const [c, setC] = useState(false);
return <Checkbox checked={c} onChange={setC} label="Согласен" />;
},
};
export const States: StoryObj<typeof Checkbox> = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox checked={false} onChange={() => undefined} label="Не выбран" />
<Checkbox checked onChange={() => undefined} label="Выбран" />
<Checkbox checked={false} indeterminate onChange={() => undefined} label="Промежуточный (выбрать все)" />
<Checkbox checked disabled onChange={() => undefined} label="Выбран, недоступен" />
<Checkbox checked={false} disabled onChange={() => undefined} label="Недоступен" />
</div>
),
};
/** В ячейках таблиц — без видимой подписи, с ariaLabel для доступности. */
export const InTableCell: StoryObj<typeof Checkbox> = {
render: () => {
const [c, setC] = useState(true);
return <Checkbox checked={c} onChange={setC} ariaLabel="Выбрать строку" />;
},
};
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react';
import styles from './Checkbox.module.scss';
import { useFormReadOnly } from '../FormMode';
export interface ICheckboxProps {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
/** Промежуточное состояние (шапка «выбрать все» при частичном выборе). */
indeterminate?: boolean;
/** Недоступен (заблокирован). */
disabled?: boolean;
/** Только чтение (режим согласующего). По умолчанию берётся из FormMode-контекста. */
readOnly?: boolean;
/** Подпись справа от флажка. */
label?: string;
/** a11y-подпись для флажков без видимого label (в ячейках таблиц). */
ariaLabel?: string;
}
/** Флажок (SharePoint Boolean / выбор строк). Управляемый: checked внутрь, onChange наружу. */
export const Checkbox: React.FC<ICheckboxProps> = ({
id,
checked,
onChange,
indeterminate,
disabled,
readOnly,
label,
ariaLabel
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveDisabled = disabled || (readOnly ?? contextReadOnly);
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (ref.current) {
ref.current.indeterminate = !!indeterminate;
}
}, [indeterminate]);
return (
<label className={styles.wrap}>
<input
ref={ref}
id={id}
type="checkbox"
className={styles.input}
checked={checked}
disabled={effectiveDisabled}
aria-label={ariaLabel}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true" />
{label && <span className={styles.text}>{label}</span>}
</label>
);
};
+102
View File
@@ -0,0 +1,102 @@
@import '../tokens/tokens';
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(10, 27, 48, .45);
animation: tnsu-dlg-fade .15s ease-out;
}
.panel {
width: 100%;
max-width: 480px;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: $white;
border-radius: $r-lg;
box-shadow: $sh-modal;
overflow: hidden;
animation: tnsu-dlg-pop .16s ease-out;
}
.sm { max-width: 380px; }
.lg { max-width: 680px; }
.header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 20px 22px 14px;
border-bottom: 1px solid $gray-200;
}
.titles { flex: 1; min-width: 0; }
.title {
margin: 0;
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-900;
}
.sub {
margin: 6px 0 0;
font-size: $fs-sm;
color: $gray-500;
line-height: 1.4;
}
.close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: background .15s, color .15s;
&:hover { background: $gray-100; color: $navy-800; }
&:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(212, 160, 30, .25); }
}
.body {
padding: 18px 22px;
overflow-y: auto;
font-size: $fs-body;
color: $gray-900;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 22px 18px;
border-top: 1px solid $gray-200;
flex-wrap: wrap;
}
@keyframes tnsu-dlg-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes tnsu-dlg-pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
// На телефоне — почти на весь экран, футер-кнопки на всю ширину.
@include below($bp-sm) {
.overlay { padding: 0; align-items: flex-end; }
.panel { max-width: 100%; max-height: 92vh; border-radius: $r-lg $r-lg 0 0; }
.footer > * { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
.overlay, .panel { animation: none; }
}
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { Dialog } from './Dialog';
import { Button } from '../Button/Button';
import { TextArea } from '../TextArea/TextArea';
import { IconSprite } from '../Icon/IconSprite';
const meta: Meta<typeof Dialog> = {
title: 'Оверлеи/Dialog',
component: Dialog,
tags: ['autodocs'],
decorators: [
(Story) => (
<>
<IconSprite />
<Story />
</>
),
],
};
export default meta;
export const DecisionForm: StoryObj<typeof Dialog> = {
render: () => {
const [open, setOpen] = useState(true);
const [comment, setComment] = useState('');
return (
<>
<Button onClick={() => setOpen(true)}>Открыть диалог</Button>
<Dialog
open={open}
title="Отклонить СТ"
subText="Укажите причину отклонения (обязательно)."
onDismiss={() => setOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>Отмена</Button>
<Button onClick={() => setOpen(false)}>Отклонить</Button>
</>
}
>
<TextArea value={comment} onChange={setComment} placeholder="Причина…" />
</Dialog>
</>
);
},
};
+70
View File
@@ -0,0 +1,70 @@
import * as React from 'react';
import styles from './Dialog.module.scss';
import { Icon } from '../Icon/Icon';
export interface IDialogProps {
open: boolean;
title: string;
/** Пояснение под заголовком. */
subText?: string;
onDismiss: () => void;
/** Кнопки внизу (обычно Button-ы). */
footer?: React.ReactNode;
/** Ширина панели. */
width?: 'sm' | 'md' | 'lg';
children?: React.ReactNode;
}
const widthClass: Record<NonNullable<IDialogProps['width']>, string> = {
sm: styles.sm,
md: '',
lg: styles.lg
};
/**
* Модальное окно с телом и произвольным футером (форма решения/промпт).
* Управляемое: open/onDismiss. Закрытие по Esc и клику по подложке.
* Для простых да/нет — используй confirmModal.
*/
export const Dialog: React.FC<IDialogProps> = ({ open, title, subText, onDismiss, footer, width = 'md', children }) => {
React.useEffect(() => {
if (!open) {
return undefined;
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
onDismiss();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onDismiss]);
if (!open) {
return null;
}
return (
<div className={styles.overlay} onClick={onDismiss} role="presentation">
<div
className={[styles.panel, widthClass[width]].filter(Boolean).join(' ')}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.header}>
<div className={styles.titles}>
<h2 className={styles.title}>{title}</h2>
{subText && <p className={styles.sub}>{subText}</p>}
</div>
<button type="button" className={styles.close} title="Закрыть" aria-label="Закрыть" onClick={onDismiss}>
<Icon name="close" size="sm" />
</button>
</div>
{children && <div className={styles.body}>{children}</div>}
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>
);
};
@@ -0,0 +1,83 @@
@import "../tokens/tokens";
.root {
position: relative;
display: inline-block;
}
.chevron,
.chevronOpen {
transition: transform 0.18s;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: calc(100% + 6px);
z-index: 20;
min-width: 220px;
max-width: min(360px, calc(100vw - 24px));
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-3;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.right {
right: 0;
}
.left {
left: 0;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 12px;
border: 0;
background: transparent;
border-radius: $r-sm;
font-family: $font-sans;
font-size: 13px;
color: $navy-800;
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
&:hover {
background: $navy-50;
}
}
.itemLabel {
overflow: hidden;
text-overflow: ellipsis;
}
.check {
flex: none;
color: $gold-600;
}
@include below($bp-xs) {
.menu {
min-width: 180px;
padding: 4px;
}
.item {
padding: 8px 10px;
white-space: normal;
}
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { DropdownButton } from './DropdownButton';
import { Icon } from '../Icon/Icon';
const OPTIONS = [
{ value: '0', label: 'ТОО Монтажник-Т — Директор' },
{ value: '1', label: 'ТОО Стройсервис — Главный инженер' },
];
const meta: Meta<typeof DropdownButton> = {
title: 'Кнопки/DropdownButton',
component: DropdownButton,
tags: ['autodocs'],
};
export default meta;
export const Default: StoryObj<typeof DropdownButton> = {
render: () => {
const [v, setV] = useState('0');
return (
<DropdownButton options={OPTIONS} value={v} onSelect={setV}>
<Icon name="document" size="sm" />
Печать
</DropdownButton>
);
},
};
+107
View File
@@ -0,0 +1,107 @@
import * as React from 'react';
import styles from './DropdownButton.module.scss';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
export interface IDropdownButtonOption {
value: string;
label: string;
}
type DropdownButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
type DropdownButtonSize = 'md' | 'sm';
export interface IDropdownButtonProps {
options: IDropdownButtonOption[];
onSelect: (value: string) => void;
/** Текущее значение — помечается галочкой в списке. */
value?: string;
variant?: DropdownButtonVariant;
size?: DropdownButtonSize;
disabled?: boolean;
/** К какому краю кнопки прижимать меню (по умолчанию — правый). */
align?: 'left' | 'right';
/** Содержимое кнопки-триггера (текст/иконка) — стрелка добавляется сама. */
children: React.ReactNode;
}
/**
* Кнопка с выпадающим списком вариантов: клик по кнопке открывает меню,
* выбор варианта сразу вызывает onSelect и закрывает его. В отличие от
* классической split-button здесь нет отдельной зоны действия по умолчанию —
* вся кнопка целиком является триггером меню, так как без выбора варианта
* действие не определено (см. PrintSplitButton в request-portal).
*/
export const DropdownButton: React.FC<IDropdownButtonProps> = ({
options,
onSelect,
value,
variant = 'secondary',
size = 'md',
disabled,
align = 'right',
children
}) => {
const [open, setOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) {
return undefined;
}
const onDocMouseDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const pick = (v: string): void => {
setOpen(false);
onSelect(v);
};
return (
<div className={styles.root} ref={rootRef}>
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
{children}
<Icon name="chevron-down" size="sm" className={open ? styles.chevronOpen : styles.chevron} />
</Button>
{open && (
<div className={`${styles.menu} ${align === 'left' ? styles.left : styles.right}`} role="menu">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="menuitem"
className={styles.item}
onClick={() => pick(opt.value)}
>
<span className={styles.itemLabel}>{opt.label}</span>
{value === opt.value && <Icon name="check" size="sm" className={styles.check} />}
</button>
))}
</div>
)}
</div>
);
};
+23
View File
@@ -78,6 +78,11 @@ export const IconSprite: React.FC = () => (
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" /> <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" /> <circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
</symbol> </symbol>
<symbol id="i-chair" viewBox="0 0 24 24">
<path d="M6 10V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4" />
<path d="M4 16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4a2 2 0 0 0-4 0v1H8v-1a2 2 0 0 0-4 0z" />
<path d="M6 18v2M18 18v2" />
</symbol>
<symbol id="i-chat" viewBox="0 0 24 24"> <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" /> <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>
@@ -122,6 +127,20 @@ export const IconSprite: React.FC = () => (
<symbol id="i-arrow-left" viewBox="0 0 24 24"> <symbol id="i-arrow-left" viewBox="0 0 24 24">
<path d="M19 12H5m0 0 5-5m-5 5 5 5" /> <path d="M19 12H5m0 0 5-5m-5 5 5 5" />
</symbol> </symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24">
<path d="M12 19V5m0 0-5 5m5-5 5 5" />
</symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24">
<path d="M12 5v14m0 0 5-5m-5 5-5-5" />
</symbol>
<symbol id="i-swap" viewBox="0 0 24 24">
<path d="M7 4 3 8l4 4" />
<path d="M3 8h13a4 4 0 0 1 4 4M17 20l4-4-4-4" />
<path d="M21 16H8a4 4 0 0 1-4-4" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v4h-4" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24"> <symbol id="i-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" /> <path d="m21 21-4.35-4.35" />
@@ -133,6 +152,10 @@ export const IconSprite: React.FC = () => (
<path d="M12 2 1 21h22z" strokeLinejoin="round" /> <path d="M12 2 1 21h22z" strokeLinejoin="round" />
<path d="M12 9v5M12 17h.01" /> <path d="M12 9v5M12 17h.01" />
</symbol> </symbol>
<symbol id="i-info" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5h.01" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24"> <symbol id="i-check" viewBox="0 0 24 24">
<path d="M5 12l5 5L20 7" /> <path d="M5 12l5 5L20 7" />
</symbol> </symbol>
+6
View File
@@ -16,6 +16,7 @@ export const ICON_NAMES = [
'clipboard', 'clipboard',
'people', 'people',
'vehicle', 'vehicle',
'chair',
'chat', 'chat',
'camera', 'camera',
'image', 'image',
@@ -28,9 +29,14 @@ export const ICON_NAMES = [
'chevron-down', 'chevron-down',
'chevron-left', 'chevron-left',
'arrow-left', 'arrow-left',
'arrow-up',
'arrow-down',
'swap',
'refresh',
'search', 'search',
'menu', 'menu',
'warning', 'warning',
'info',
'check', 'check',
'plus' 'plus'
] as const; ] as const;
+49
View File
@@ -0,0 +1,49 @@
@import '../tokens/tokens';
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-700;
cursor: pointer;
transition: background .15s, color .15s;
&:hover {
background: $navy-50;
color: $navy-800;
}
&:focus-visible {
outline: 0;
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
}
&:disabled {
color: $gray-400;
background: transparent;
cursor: default;
}
}
.sm {
width: 26px;
height: 26px;
}
// Акцент удаления.
.danger:hover {
background: $red-50;
color: $red-500;
}
// Тач-таргет на узких экранах.
@include below($bp-md) {
.btn { width: 36px; height: 36px; }
.sm { width: 32px; height: 32px; }
}
+30
View File
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { IconButton } from './IconButton';
import { IconSprite } from '../Icon/IconSprite';
const meta: Meta<typeof IconButton> = {
title: 'Кнопки/IconButton',
component: IconButton,
tags: ['autodocs'],
decorators: [
(Story) => (
<>
<IconSprite />
<Story />
</>
),
],
};
export default meta;
export const Actions: StoryObj<typeof IconButton> = {
render: () => (
<div style={{ display: 'flex', gap: 8 }}>
<IconButton icon="arrow-up" title="Выше" onClick={() => undefined} />
<IconButton icon="arrow-down" title="Ниже" disabled onClick={() => undefined} />
<IconButton icon="swap" title="Заменить" onClick={() => undefined} />
<IconButton icon="trash" title="Удалить" danger onClick={() => undefined} />
</div>
),
};
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import styles from './IconButton.module.scss';
import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames';
export interface IIconButtonProps {
icon: IconName;
/** Подсказка (tooltip) и a11y-подпись — обязательна: кнопка без текста. */
title: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
/** Акцент удаления (красный ховер). */
danger?: boolean;
size?: 'sm' | 'md';
className?: string;
}
/** Кнопка-иконка (удалить/переместить/заменить). Иконка — из общего спрайта. */
export const IconButton: React.FC<IIconButtonProps> = ({
icon,
title,
onClick,
disabled,
danger,
size = 'md',
className
}) => {
const classes = [styles.btn, size === 'sm' ? styles.sm : '', danger ? styles.danger : '', className]
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} title={title} aria-label={title} disabled={disabled} onClick={onClick}>
<Icon name={icon} size={size === 'sm' ? 'sm' : 'md'} />
</button>
);
};
+24
View File
@@ -0,0 +1,24 @@
@import '../tokens/tokens';
.link {
color: $navy-700;
font-size: inherit;
text-decoration: none;
background-image: linear-gradient(currentColor, currentColor);
background-size: 0 1.5px;
background-position: 0 100%;
background-repeat: no-repeat;
transition: background-size .18s, color .15s;
cursor: pointer;
&:hover {
color: $gold-600;
background-size: 100% 1.5px;
}
&:focus-visible {
outline: 0;
border-radius: 2px;
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Link } from './Link';
const meta: Meta<typeof Link> = {
title: 'Навигация/Link',
component: Link,
tags: ['autodocs'],
};
export default meta;
export const Default: StoryObj<typeof Link> = {
render: () => (
<p style={{ fontSize: 14 }}>
Файл: <Link href="#" download>скачать КП поставщика.pdf</Link>
</p>
),
};
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react';
import styles from './Link.module.scss';
export interface ILinkProps {
href?: string;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
target?: '_blank' | '_self';
/** Скачивание файла (атрибут download). */
download?: boolean | string;
className?: string;
children: React.ReactNode;
}
/** Текстовая ссылка (`<a>`) в бренд-стиле: navy + золотое подчёркивание на ховере. */
export const Link: React.FC<ILinkProps> = ({ href, onClick, target, download, className, children }) => {
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
return (
<a
className={[styles.link, className].filter(Boolean).join(' ')}
href={href}
onClick={onClick}
target={target}
rel={rel}
download={download}
>
{children}
</a>
);
};
+13 -7
View File
@@ -59,13 +59,15 @@ const PRINT_CSS =
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}'; '.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
/** /**
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно, * Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно,
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл- * заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм. * контейнеров/высоты SharePoint, корректно разбивается на страницы. Вынесена
* из PrintButton отдельной функцией, чтобы печать можно было вызвать
* программно — например, сразу после выбора варианта в DropdownButton, а не
* только по клику на саму кнопку (см. PrintSplitButton в request-portal).
*/ */
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => { export function printRoot(selector = '.rp-print-root'): void {
const onPrint = (): void => { const source = document.querySelector(selector);
const source = document.querySelector('.rp-print-root');
if (!source) { if (!source) {
window.print(); window.print();
return; return;
@@ -106,10 +108,14 @@ export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печат
window.setTimeout(done, 350); window.setTimeout(done, 350);
}; };
} }
}; }
/**
* Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм.
*/
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
return ( return (
<Button variant="secondary" onClick={onPrint}> <Button variant="secondary" onClick={() => printRoot()}>
<Icon name="document" size="sm" /> <Icon name="document" size="sm" />
{label} {label}
</Button> </Button>
+69 -2
View File
@@ -97,7 +97,10 @@
font-size: 13.5px; font-size: 13.5px;
font-weight: 500; font-weight: 500;
text-align: left; text-align: left;
white-space: nowrap; // Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка.
text-decoration: none;
// Длинное название переносится (не больше 2 строк — см. .itemTitle), а не обрезается многоточием.
white-space: normal;
cursor: pointer; cursor: pointer;
transition: color .18s, background .18s, border-color .18s; transition: color .18s, background .18s, border-color .18s;
@@ -128,11 +131,26 @@
color: inherit; color: inherit;
} }
// Не больше 2 строк: совсем длинное название обрезается многоточием на второй строке.
.itemTitle { .itemTitle {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
overflow-wrap: anywhere;
}
// ---- Линии между пунктами (проп dividers) ----
// Линия — над каждым пунктом, кроме первого; подпункты раскрытой группы лежат внутри .group — их не трогаем.
.navDividers .items {
gap: 0;
> * + * {
border-top: 1px solid rgba(255, 255, 255, .06);
}
} }
.badge { .badge {
@@ -153,6 +171,55 @@
right: 8px; right: 8px;
} }
// ---- Раскрывающаяся группа: подпункты без иконок, с отступом под иконку шапки ----
$sub-item-indent: 48px; // 18px отступ пункта + 3px маркер + иконка 16px + зазор
.group {
display: flex;
flex-direction: column;
}
.groupChevron {
flex-shrink: 0;
display: inline-flex;
color: rgba(255, 255, 255, .45);
}
.subItems {
display: flex;
flex-direction: column;
padding-bottom: $sp-1;
}
.subItem {
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
padding: 8px 18px 8px $sub-item-indent;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: rgba(255, 255, 255, .55);
font-family: $font-sans;
font-size: 13px;
text-align: left;
text-decoration: none;
white-space: normal;
cursor: pointer;
transition: color .18s, background .18s;
&:hover {
color: rgba(255, 255, 255, .92);
background: rgba(255, 255, 255, .04);
}
}
.subItemActive {
color: $white;
border-left-color: $gold-500;
}
// ---- Затемнение фона под мобильным drawer ---- // ---- Затемнение фона под мобильным drawer ----
.backdrop { .backdrop {
display: none; display: none;
+53
View File
@@ -63,6 +63,59 @@ export const Collapsed: Story = {
}, },
}; };
// Пункт с children — раскрывающаяся группа; подпункт с href открывается в новой вкладке.
const ITEMS_WITH_GROUP: ISidebarItem[] = [
...ITEMS.slice(0, 3),
{ id: 'long', title: 'Загрузить работы из сметы проекта', icon: 'download' },
{
id: 'catalogs',
title: 'Справочники',
icon: 'info',
children: [
{ id: 'companies', title: 'Компании', href: 'https://example.com/companies' },
{ id: 'units', title: 'Единицы измерения', href: 'https://example.com/units' },
{ id: 'manual', title: 'Инструкция', href: 'https://example.com/manual' },
],
},
{ id: 'help', title: 'Справка', icon: 'chat', href: 'https://example.com/help' },
];
export const WithGroupAndLinks: Story = {
render: () => {
const [active, setActive] = useState('approvals');
const [collapsed, setCollapsed] = useState(false);
return (
<Sidebar
items={ITEMS_WITH_GROUP}
activeId={active}
onChange={setActive}
brand="Портал заявок"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
/>
);
},
};
// Линии между пунктами; длинное название переносится на вторую строку.
export const WithDividers: Story = {
render: () => {
const [active, setActive] = useState('approvals');
const [collapsed, setCollapsed] = useState(false);
return (
<Sidebar
items={ITEMS_WITH_GROUP}
activeId={active}
onChange={setActive}
brand="Портал заявок"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
dividers
/>
);
},
};
// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook) // Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook)
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента. // ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
export const MobileDrawer: Story = { export const MobileDrawer: Story = {
+35 -31
View File
@@ -1,16 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Sidebar.module.scss'; import styles from './Sidebar.module.scss';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames'; import { ISidebarItem } from './sidebarLogic';
import { SidebarEntry } from './SidebarEntry';
import { SidebarGroup } from './SidebarGroup';
export interface ISidebarItem { export type { ISidebarItem, ISidebarSubItem } from './sidebarLogic';
/** Уникальный идентификатор раздела (любая строка). */
id: string;
title: string;
icon: IconName;
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
count?: number;
}
export interface ISidebarProps { export interface ISidebarProps {
items: ISidebarItem[]; items: ISidebarItem[];
@@ -29,10 +24,13 @@ export interface ISidebarProps {
mobileOpen?: boolean; mobileOpen?: boolean;
/** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */ /** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */
onMobileClose?: () => void; onMobileClose?: () => void;
/** Тонкие линии между пунктами (подпункты раскрытой группы — без линий, они читаются как одно целое). */
dividers?: boolean;
} }
/** /**
* Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый. * Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый.
* Пункт с href — внешняя ссылка (новая вкладка), с children — раскрывающаяся группа.
* Адаптивный: * Адаптивный:
* • десктоп — рейл, сворачивается в иконки (collapsed); * • десктоп — рейл, сворачивается в иконки (collapsed);
* • < 768px — выезжающий поверх контента drawer с затемнением фона * • < 768px — выезжающий поверх контента drawer с затемнением фона
@@ -47,20 +45,26 @@ export const Sidebar: React.FC<ISidebarProps> = ({
onToggleCollapsed, onToggleCollapsed,
mobileOpen = false, mobileOpen = false,
onMobileClose, onMobileClose,
dividers = false,
}) => { }) => {
const navClasses = [ const navClasses = [
styles.nav, styles.nav,
collapsed ? styles.navCollapsed : '', collapsed ? styles.navCollapsed : '',
mobileOpen ? styles.navMobileOpen : '', mobileOpen ? styles.navMobileOpen : '',
dividers ? styles.navDividers : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
// На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose // На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose
// обычно не передан — вызов безвреден. // обычно не передан — вызов безвреден.
const closeMobile = (): void => {
if (onMobileClose) onMobileClose();
};
const handleSelect = (id: string): void => { const handleSelect = (id: string): void => {
onChange(id); onChange(id);
if (onMobileClose) onMobileClose(); closeMobile();
}; };
return ( return (
@@ -84,28 +88,28 @@ export const Sidebar: React.FC<ISidebarProps> = ({
</div> </div>
<div className={styles.items}> <div className={styles.items}>
{items.map((item) => { {items.map((item) =>
const isActive = item.id === activeId; item.children ? (
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' '); <SidebarGroup
const showCount = !!item.count;
return (
<button
key={item.id} key={item.id}
type="button" item={item}
className={itemClasses} activeId={activeId}
aria-current={isActive ? 'page' : undefined} collapsed={collapsed}
onClick={() => handleSelect(item.id)} onSelect={handleSelect}
title={collapsed ? item.title : undefined} onLinkOpen={closeMobile}
> onExpandRail={onToggleCollapsed}
<span className={styles.itemIcon}> />
<Icon name={item.icon} size="sm" /> ) : (
</span> <SidebarEntry
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>} key={item.id}
{showCount && <span className={styles.badge}>{item.count}</span>} item={item}
</button> isActive={item.id === activeId}
); collapsed={collapsed}
})} onSelect={handleSelect}
onLinkOpen={closeMobile}
/>
)
)}
</div> </div>
</nav> </nav>
</> </>
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import styles from './Sidebar.module.scss';
import { ISidebarItem } from './sidebarLogic';
import { SidebarItemContent } from './SidebarItemContent';
interface ISidebarEntryProps {
item: ISidebarItem;
isActive: boolean;
collapsed: boolean;
onSelect: (id: string) => void;
/** Переход по внешней ссылке — закрыть мобильный drawer. */
onLinkOpen: () => void;
}
/** Обычный пункт: раздел (кнопка, onChange) или внешняя ссылка (новая вкладка). */
export const SidebarEntry: React.FC<ISidebarEntryProps> = ({ item, isActive, collapsed, onSelect, onLinkOpen }) => {
const classes = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
const title = collapsed ? item.title : undefined;
if (item.href) {
return (
<a className={classes} href={item.href} target="_blank" rel="noopener noreferrer" title={title} onClick={onLinkOpen}>
<SidebarItemContent item={item} collapsed={collapsed} />
</a>
);
}
return (
<button
type="button"
className={classes}
aria-current={isActive ? 'page' : undefined}
onClick={() => onSelect(item.id)}
title={title}
>
<SidebarItemContent item={item} collapsed={collapsed} />
</button>
);
};
+78
View File
@@ -0,0 +1,78 @@
import * as React from 'react';
import styles from './Sidebar.module.scss';
import { Icon } from '../Icon/Icon';
import { hasActiveChild, ISidebarItem } from './sidebarLogic';
import { SidebarItemContent } from './SidebarItemContent';
import { SidebarSubItem } from './SidebarSubItem';
interface ISidebarGroupProps {
item: ISidebarItem;
activeId: string;
collapsed: boolean;
onSelect: (id: string) => void;
onLinkOpen: () => void;
/** Свёрнутый рейл: подпунктам некуда раскрыться — сначала разворачиваем рейл. */
onExpandRail: () => void;
}
/**
* Раскрывающаяся группа. Сама разделом не является: клик раскрывает/сворачивает подпункты.
* Группа с активным подпунктом всегда раскрыта — иначе текущий раздел не виден в меню.
*/
export const SidebarGroup: React.FC<ISidebarGroupProps> = ({
item,
activeId,
collapsed,
onSelect,
onLinkOpen,
onExpandRail,
}) => {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const containsActive = hasActiveChild(item, activeId);
const isExpanded = isOpen || containsActive;
const listId = `sidebar-group-${item.id}`;
const toggle = (): void => {
if (collapsed) {
onExpandRail();
setIsOpen(true);
return;
}
setIsOpen((prev) => !prev);
};
const headerClasses = [styles.item, containsActive ? styles.itemActive : ''].filter(Boolean).join(' ');
const chevron = (
<span className={styles.groupChevron}>
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="sm" />
</span>
);
return (
<div className={styles.group}>
<button
type="button"
className={headerClasses}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={toggle}
title={collapsed ? item.title : undefined}
>
<SidebarItemContent item={item} collapsed={collapsed} trailing={chevron} />
</button>
{!collapsed && isExpanded && (
<div id={listId} className={styles.subItems}>
{(item.children || []).map((child) => (
<SidebarSubItem
key={child.id}
item={child}
isActive={child.id === activeId}
onSelect={onSelect}
onLinkOpen={onLinkOpen}
/>
))}
</div>
)}
</div>
);
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import styles from './Sidebar.module.scss';
import { Icon } from '../Icon/Icon';
import { ISidebarItem } from './sidebarLogic';
interface ISidebarItemContentProps {
item: ISidebarItem;
collapsed: boolean;
/** Справа после названия (стрелка группы). */
trailing?: React.ReactNode;
}
/** Иконка, название и бейдж пункта — одинаковые у раздела, ссылки и шапки группы. */
export const SidebarItemContent: React.FC<ISidebarItemContentProps> = ({ item, collapsed, trailing }) => (
<>
<span className={styles.itemIcon}>
<Icon name={item.icon} size="sm" />
</span>
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
{!!item.count && <span className={styles.badge}>{item.count}</span>}
{!collapsed && trailing}
</>
);
+34
View File
@@ -0,0 +1,34 @@
import * as React from 'react';
import styles from './Sidebar.module.scss';
import { ISidebarSubItem } from './sidebarLogic';
interface ISidebarSubItemProps {
item: ISidebarSubItem;
isActive: boolean;
onSelect: (id: string) => void;
onLinkOpen: () => void;
}
/** Подпункт группы: внешняя ссылка (новая вкладка) или раздел (onChange). */
export const SidebarSubItem: React.FC<ISidebarSubItemProps> = ({ item, isActive, onSelect, onLinkOpen }) => {
const classes = [styles.subItem, isActive ? styles.subItemActive : ''].filter(Boolean).join(' ');
const content = (
<>
<span className={styles.itemTitle}>{item.title}</span>
{!!item.count && <span className={styles.badge}>{item.count}</span>}
</>
);
if (item.href) {
return (
<a className={classes} href={item.href} target="_blank" rel="noopener noreferrer" onClick={onLinkOpen}>
{content}
</a>
);
}
return (
<button type="button" className={classes} aria-current={isActive ? 'page' : undefined} onClick={() => onSelect(item.id)}>
{content}
</button>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { hasActiveChild, isItemHighlighted, ISidebarItem } from './sidebarLogic';
const PLAIN: ISidebarItem = { id: 'objects', title: 'Объекты', icon: 'folder' };
const GROUP: ISidebarItem = {
id: 'catalogs',
title: 'Справочники',
icon: 'info',
children: [
{ id: 'companies', title: 'Компании', href: 'https://example.com/companies' },
{ id: 'settings', title: 'Настройки' }
]
};
describe('hasActiveChild — раскрытие группы', () => {
it('активен подпункт — группа раскрыта', () => {
expect(hasActiveChild(GROUP, 'settings')).toBe(true);
});
it('активен другой раздел или пункт без вложенности — нет', () => {
expect(hasActiveChild(GROUP, 'objects')).toBe(false);
expect(hasActiveChild(PLAIN, 'objects')).toBe(false);
});
});
describe('isItemHighlighted — подсветка пункта', () => {
it('обычный пункт — по своему id', () => {
expect(isItemHighlighted(PLAIN, 'objects')).toBe(true);
expect(isItemHighlighted(PLAIN, 'catalogs')).toBe(false);
});
it('группа — когда активен её подпункт', () => {
expect(isItemHighlighted(GROUP, 'settings')).toBe(true);
expect(isItemHighlighted(GROUP, 'objects')).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { IconName } from '../Icon/iconNames';
/** Подпункт раскрывающейся группы. Без иконки — отступ показывает вложенность. */
export interface ISidebarSubItem {
/** Уникальный идентификатор (среди всех пунктов меню). */
id: string;
title: string;
/** Внешняя ссылка: открывается в новой вкладке, onChange не вызывается. */
href?: string;
/** Счётчик-бейдж справа. 0/undefined — не показывать. */
count?: number;
}
export interface ISidebarItem {
/** Уникальный идентификатор раздела (любая строка). */
id: string;
title: string;
icon: IconName;
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
count?: number;
/** Внешняя ссылка: пункт открывается в новой вкладке, onChange не вызывается. */
href?: string;
/**
* Вложенные пункты: пункт становится раскрывающейся группой (клик — раскрыть/свернуть),
* сам он разделом не является и onChange не вызывает.
*/
children?: ISidebarSubItem[];
}
/** Активный раздел — один из подпунктов группы: группу показываем раскрытой и подсвеченной. */
export function hasActiveChild(item: ISidebarItem, activeId: string): boolean {
return (item.children || []).some((child) => child.id === activeId);
}
/** Подсветка пункта: сам раздел активен или (для группы) активен подпункт. */
export function isItemHighlighted(item: ISidebarItem, activeId: string): boolean {
return item.id === activeId || hasActiveChild(item, activeId);
}
+41
View File
@@ -0,0 +1,41 @@
@import '../tokens/tokens';
.wrap {
display: inline-flex;
align-items: center;
gap: 8px;
}
.block {
flex-direction: column;
justify-content: center;
}
.ring {
display: inline-block;
width: 24px;
height: 24px;
border: 2.5px solid $navy-100;
border-top-color: $gold-500;
border-radius: 50%;
animation: tnsu-spin .7s linear infinite;
flex-shrink: 0;
}
.xSmall { width: 12px; height: 12px; border-width: 2px; }
.small { width: 16px; height: 16px; border-width: 2px; }
.large { width: 32px; height: 32px; border-width: 3px; }
.label {
font-size: $fs-sm;
color: $gray-700;
}
@keyframes tnsu-spin {
to { transform: rotate(360deg); }
}
// Уважение к prefers-reduced-motion: замедляем, не убираем совсем (индикатор занятости).
@media (prefers-reduced-motion: reduce) {
.ring { animation-duration: 1.6s; }
}
+29
View File
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Spinner } from './Spinner';
const meta: Meta<typeof Spinner> = {
title: 'Состояния/Spinner',
component: Spinner,
tags: ['autodocs'],
};
export default meta;
export const Sizes: StoryObj<typeof Spinner> = {
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<Spinner size="xSmall" />
<Spinner size="small" />
<Spinner size="medium" />
<Spinner size="large" />
</div>
),
};
export const WithLabel: StoryObj<typeof Spinner> = {
render: () => <Spinner size="small" label="Генерация комментария…" />,
};
export const Block: StoryObj<typeof Spinner> = {
render: () => <Spinner size="large" label="Загрузка данных…" block />,
};
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import styles from './Spinner.module.scss';
export type SpinnerSize = 'xSmall' | 'small' | 'medium' | 'large';
export interface ISpinnerProps {
size?: SpinnerSize;
/** Подпись справа (или под спиннером при block). */
label?: string;
/** Вертикальная раскладка (спиннер над подписью, по центру). */
block?: boolean;
className?: string;
}
const sizeClass: Record<SpinnerSize, string> = {
xSmall: styles.xSmall,
small: styles.small,
medium: '',
large: styles.large
};
/** Индикатор загрузки (фирменное золотое кольцо на navy-дорожке). */
export const Spinner: React.FC<ISpinnerProps> = ({ size = 'medium', label, block, className }) => {
const wrap = [styles.wrap, block ? styles.block : '', className].filter(Boolean).join(' ');
return (
<span className={wrap} role="status" aria-live="polite">
<span className={[styles.ring, sizeClass[size]].filter(Boolean).join(' ')} aria-hidden="true" />
{label && <span className={styles.label}>{label}</span>}
</span>
);
};
+14
View File
@@ -21,3 +21,17 @@
color: $gray-400; color: $gray-400;
} }
} }
// Инлайн-ввод в ячейке таблицы: без рамки/фона, компактный, без ручного ресайза.
.borderless {
border-color: transparent;
background: transparent;
padding: 6px 8px;
min-height: 32px;
resize: none;
&:focus {
background: $white;
border-color: $gold-500;
}
}
+7 -2
View File
@@ -10,20 +10,25 @@ interface ITextAreaProps {
rows?: number; rows?: number;
/** Только чтение (режим согласующего). */ /** Только чтение (режим согласующего). */
readOnly?: boolean; readOnly?: boolean;
/** Без рамки/фона — для инлайн-ввода в ячейках таблиц. */
borderless?: boolean;
/** a11y-подпись, когда видимого label нет. */
ariaLabel?: string;
} }
/** Многострочный ввод (SharePoint Note). */ /** Многострочный ввод (SharePoint Note). */
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3, readOnly }) => { export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3, readOnly, borderless, ariaLabel }) => {
const contextReadOnly = useFormReadOnly(); const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly; const effectiveReadOnly = readOnly ?? contextReadOnly;
return ( return (
<textarea <textarea
id={id} id={id}
className={styles.textarea} className={[styles.textarea, borderless ? styles.borderless : ''].filter(Boolean).join(' ')}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
readOnly={effectiveReadOnly} readOnly={effectiveReadOnly}
aria-label={ariaLabel}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
); );
+13
View File
@@ -19,3 +19,16 @@
color: $gray-400; color: $gray-400;
} }
} }
// Инлайн-ввод в ячейке таблицы: без рамки/фона, компактнее; рамка/фон проявляются на фокусе.
.borderless {
border-color: transparent;
background: transparent;
padding: 6px 8px;
border-radius: 4px;
&:focus {
background: $white;
border-color: $gold-500;
}
}
+15 -2
View File
@@ -19,6 +19,12 @@ interface ITextInputProps {
*/ */
min?: string | number; min?: string | number;
max?: string | number; max?: string | number;
/** Без рамки/фона — для инлайн-ввода в ячейках таблиц (рамка проявляется на фокусе). */
borderless?: boolean;
/** a11y-подпись, когда видимого label нет (ячейки таблиц). */
ariaLabel?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
} }
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */ /** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
@@ -30,20 +36,27 @@ export const TextInput: React.FC<ITextInputProps> = ({
placeholder, placeholder,
readOnly, readOnly,
min, min,
max max,
borderless,
ariaLabel,
onKeyDown,
autoFocus
}) => { }) => {
const contextReadOnly = useFormReadOnly(); const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly; const effectiveReadOnly = readOnly ?? contextReadOnly;
return ( return (
<input <input
id={id} id={id}
className={styles.input} className={[styles.input, borderless ? styles.borderless : ''].filter(Boolean).join(' ')}
type={type} type={type}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
readOnly={effectiveReadOnly} readOnly={effectiveReadOnly}
aria-label={ariaLabel}
min={min} min={min}
max={max} max={max}
autoFocus={autoFocus}
onKeyDown={onKeyDown}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
); );
+18 -2
View File
@@ -5,7 +5,11 @@
// --- Кнопки --- // --- Кнопки ---
export { Button } from './Button/Button'; export { Button } from './Button/Button';
export { PrintButton } from './PrintButton/PrintButton'; export { PrintButton, printRoot } from './PrintButton/PrintButton';
export { IconButton } from './IconButton/IconButton';
export type { IIconButtonProps } from './IconButton/IconButton';
export { DropdownButton } from './DropdownButton/DropdownButton';
export type { IDropdownButtonProps, IDropdownButtonOption } from './DropdownButton/DropdownButton';
// --- Сообщения / плашки --- // --- Сообщения / плашки ---
export { Alert } from './Alert/Alert'; export { Alert } from './Alert/Alert';
@@ -30,6 +34,8 @@ export { MoneyInput } from './MoneyInput/MoneyInput';
export { Select } from './Select/Select'; export { Select } from './Select/Select';
export type { ISelectOption } from './Select/Select'; export type { ISelectOption } from './Select/Select';
export { Toggle } from './Toggle/Toggle'; export { Toggle } from './Toggle/Toggle';
export { Checkbox } from './Checkbox/Checkbox';
export type { ICheckboxProps } from './Checkbox/Checkbox';
// --- Режим «только чтение» для полей формы --- // --- Режим «только чтение» для полей формы ---
export { FormModeProvider, useFormReadOnly } from './FormMode'; export { FormModeProvider, useFormReadOnly } from './FormMode';
@@ -44,11 +50,19 @@ export { FileDropzone } from './FileDropzone/FileDropzone';
export { AttachmentList } from './AttachmentList/AttachmentList'; export { AttachmentList } from './AttachmentList/AttachmentList';
export type { IAttachmentInfo } from './AttachmentList/AttachmentList'; export type { IAttachmentInfo } from './AttachmentList/AttachmentList';
// --- Индикаторы состояния ---
export { Spinner } from './Spinner/Spinner';
export type { ISpinnerProps, SpinnerSize } from './Spinner/Spinner';
// --- Модалка подтверждения --- // --- Модалка подтверждения ---
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog'; export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal'; export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } from './ConfirmDialog/confirmModal'; export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
// --- Модальное окно (с телом и футером) ---
export { Dialog } from './Dialog/Dialog';
export type { IDialogProps } from './Dialog/Dialog';
// --- Раскладка приложения --- // --- Раскладка приложения ---
export { AppLayout } from './AppLayout/AppLayout'; export { AppLayout } from './AppLayout/AppLayout';
export type { IAppLayoutProps } from './AppLayout/AppLayout'; export type { IAppLayoutProps } from './AppLayout/AppLayout';
@@ -59,9 +73,11 @@ export type { ITab, ITabsProps } from './Tabs/Tabs';
export { Stepper, highestReachableIndex } from './Stepper/Stepper'; export { Stepper, highestReachableIndex } from './Stepper/Stepper';
export type { IStepperStep, IStepperProps } from './Stepper/Stepper'; export type { IStepperStep, IStepperProps } from './Stepper/Stepper';
export { Sidebar } from './Sidebar/Sidebar'; export { Sidebar } from './Sidebar/Sidebar';
export type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar'; export type { ISidebarItem, ISidebarSubItem, ISidebarProps } from './Sidebar/Sidebar';
export { SidebarToggle } from './Sidebar/SidebarToggle'; export { SidebarToggle } from './Sidebar/SidebarToggle';
export type { ISidebarToggleProps } from './Sidebar/SidebarToggle'; export type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
export { Link } from './Link/Link';
export type { ILinkProps } from './Link/Link';
// --- Таблицы --- // --- Таблицы ---
export { DataTable } from './DataTable/DataTable'; export { DataTable } from './DataTable/DataTable';
+1
View File
@@ -43,6 +43,7 @@ $green-50: #F0FDF4;
$blue-600: #2563EB; $blue-600: #2563EB;
$blue-50: #EFF6FF; $blue-50: #EFF6FF;
$amber-700: #854D0E; $amber-700: #854D0E;
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
$white: #fff; $white: #fff;