Compare commits
16 Commits
e3a9b5360c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec13410945 | |||
| 86f4ec379a | |||
| 84964b62c1 | |||
| 65fc6ced30 | |||
| 6fd3672edc | |||
| 2a32b998f6 | |||
| 048fec0696 | |||
| 06e9007e73 | |||
| 0082b90c2b | |||
| f2e8108d1b | |||
| fa6fe8dbe1 | |||
| 2958ab7320 | |||
| 4bdaca7b8f | |||
| 6bf198f8f2 | |||
| 49a7665418 | |||
| d7bebe0a2f |
@@ -0,0 +1,3 @@
|
||||
# Скрипты и пайплайны запускаются на Linux-раннере: только LF.
|
||||
*.sh text eol=lf
|
||||
.gitea/**/*.yml text eol=lf
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||
|
||||
+14
-4
@@ -140,6 +140,16 @@ Generic Badge в kit (variant: neutral/muted/info/success/warning/danger/primary
|
||||
StatusBadge переписан в тонкий маппер REQUEST_STATUS→variant поверх Badge; StatusBadge.module.scss
|
||||
удалён. Это шаблон для будущего разделения пикеров (group 3): вид в kit, привязка к данным в проекте.
|
||||
|
||||
### Stepper + тесты (не опубликовано, будет 0.5.0)
|
||||
- Добавлен generic `Stepper` (степпер шагов) — эталон kps Breadcrumbs, но чистый (без шапки),
|
||||
логика навигации по индексу (highestReachableIndex, вынесена в stepperLogic.ts — тестируемо).
|
||||
Кликабельны шаги до highestId включительно (защита от прыжка вперёд). Адаптивный (подписи
|
||||
прячутся < sm). Экспортирован из index.
|
||||
- Заведён юнит-тест: `npm test` (vitest, node-окружение, vitest.config.ts — ОТДЕЛЬНО от
|
||||
storybook-browser-проекта в vite.config). Первый тест stepperLogic.test.ts (4 кейса).
|
||||
`*.test.ts` исключены из сборки пакета (vite dts exclude + tsconfig exclude).
|
||||
- ПРАКТИКА тестов: чистую логику выносить в отдельные файлы (без React/scss) и покрывать в node.
|
||||
|
||||
### Порядок работ
|
||||
1. [СДЕЛАНО в 0.1.0] Быстрая пачка (20 компонентов): вся Группа 1 (кроме ErrorOverlay/
|
||||
StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/
|
||||
@@ -159,11 +169,11 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
|
||||
|
||||
## Публикация (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" не нужно.
|
||||
- Адрес registry: `http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
||||
- Адрес registry: `https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
||||
- `.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 НЕ попадает:
|
||||
`//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>`
|
||||
- Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись.
|
||||
@@ -178,4 +188,4 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
|
||||
|
||||
- Node: v22.x (как в SPFx: >=22.14 <23)
|
||||
- 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
|
||||
|
||||
@@ -4,40 +4,15 @@
|
||||
|
||||
> Внутренняя память проекта (решения, план, прогресс) — в [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
|
||||
# 1. поднять version в package.json (напр. 0.0.1 → 0.0.2)
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
Публикует пайплайн **publish** в Gitea — по кнопке: Actions → publish → Run workflow. Сначала юнит-тесты
|
||||
и сборка, потом публикация в реестр tansu.
|
||||
|
||||
> npm не даёт перезаписать уже опубликованную версию — перед публикацией всегда
|
||||
> бампай `version` в package.json.
|
||||
- **Версию поднимает пайплайн.** В `package.json` задаются только major.minor (патч там не важен):
|
||||
новая функциональность — поднять 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
@@ -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"
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.0.1",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.0.1",
|
||||
"version": "0.15.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "latest",
|
||||
|
||||
+6
-2
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.4.0",
|
||||
"version": "0.15.0",
|
||||
"description": "TNSU shared UI components for SPFx web parts",
|
||||
"author": "Timur Gorodetskiy",
|
||||
"license": "ISC",
|
||||
"repository": {
|
||||
"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",
|
||||
"main": "./dist/index.cjs",
|
||||
@@ -29,6 +32,7 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
|
||||
@@ -28,3 +28,9 @@
|
||||
color: $red-500;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: $navy-50;
|
||||
color: $navy-800;
|
||||
border-color: $navy-100;
|
||||
}
|
||||
|
||||
+5
-3
@@ -3,7 +3,7 @@ import styles from './Alert.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
type AlertVariant = 'warn' | 'ok' | 'err';
|
||||
type AlertVariant = 'warn' | 'ok' | 'err' | 'info';
|
||||
|
||||
interface IAlertProps {
|
||||
variant: AlertVariant;
|
||||
@@ -13,13 +13,15 @@ interface IAlertProps {
|
||||
const variantClass: Record<AlertVariant, string> = {
|
||||
warn: styles.warn,
|
||||
ok: styles.ok,
|
||||
err: styles.err
|
||||
err: styles.err,
|
||||
info: styles.info
|
||||
};
|
||||
|
||||
const variantIcon: Record<AlertVariant, IconName> = {
|
||||
warn: 'warning',
|
||||
ok: 'check',
|
||||
err: 'close'
|
||||
err: 'close',
|
||||
info: 'info'
|
||||
};
|
||||
|
||||
export const Alert: React.FC<IAlertProps> = ({ variant, children }) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: stretch; // боковая панель тянется на высоту контента
|
||||
width: 100%;
|
||||
min-height: 78vh; // «присутствие» рейла даже когда контента мало
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.side {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; // чтобы контент не распирал колонку
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $sp-5 $sp-6 $sp-8;
|
||||
}
|
||||
|
||||
// Бургер: виден только на мобилке (над контентом).
|
||||
.mobileBar {
|
||||
display: none;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
// < 768px: боковая панель превращается в drawer (position:fixed внутри Sidebar) —
|
||||
// колонка .side схлопывается сама, контент на всю ширину. Показываем бургер.
|
||||
@include below($bp-md) {
|
||||
.layout {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobileBar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: $sp-4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { AppLayout } from './AppLayout';
|
||||
import { Sidebar, ISidebarItem } from '../Sidebar/Sidebar';
|
||||
import { SidebarToggle } from '../Sidebar/SidebarToggle';
|
||||
|
||||
const ITEMS: ISidebarItem[] = [
|
||||
{ id: 'ct', title: 'СТ ОМТС', icon: 'clipboard', count: 1 },
|
||||
{ id: 'oid', title: 'СТ ОИД', icon: 'chart' },
|
||||
{ id: 'kps', title: 'СТ КПС', icon: 'building' }
|
||||
];
|
||||
|
||||
const meta: Meta<typeof AppLayout> = {
|
||||
title: 'Раскладка/AppLayout',
|
||||
component: AppLayout,
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
// Оболочка: сайдбар слева + контент справа. Сузь окно < 768px — сайдбар уйдёт в
|
||||
// drawer, появится бургер, контент займёт всю ширину.
|
||||
export const SidebarWithContent: StoryObj<typeof AppLayout> = {
|
||||
render: () => {
|
||||
const [active, setActive] = useState<string | number>('ct');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
return (
|
||||
<AppLayout
|
||||
sidebar={
|
||||
<Sidebar
|
||||
items={ITEMS}
|
||||
activeId={active}
|
||||
onChange={setActive}
|
||||
brand="Договора"
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
}
|
||||
mobileToggle={<SidebarToggle onClick={() => setMobileOpen(true)} />}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontFamily: "'Lora', Georgia, serif", color: '#0F2440' }}>Контентная область</h2>
|
||||
<p style={{ color: '#6B7280' }}>Здесь — содержимое активного раздела (реестр, форма и т.д.).</p>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import styles from './AppLayout.module.scss';
|
||||
|
||||
export interface IAppLayoutProps {
|
||||
/** Боковая панель — обычно <Sidebar/>. */
|
||||
sidebar: React.ReactNode;
|
||||
/**
|
||||
* Кнопка-бургер для мобильного drawer — обычно <SidebarToggle/>. Видна только
|
||||
* на мобилке (< 768px), над контентом. Необязательна.
|
||||
*/
|
||||
mobileToggle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оболочка приложения — один из видов раскладки: боковая панель слева + контентная
|
||||
* область справа. Переиспользуемый app-shell.
|
||||
* Адаптивно: на < 768px боковая панель уходит в выезжающий drawer (за это отвечает
|
||||
* сам Sidebar), контент занимает всю ширину, сверху показывается бургер (mobileToggle).
|
||||
*/
|
||||
export const AppLayout: React.FC<IAppLayoutProps> = ({ sidebar, mobileToggle, children }) => (
|
||||
<div className={styles.layout}>
|
||||
<div className={styles.side}>{sidebar}</div>
|
||||
<main className={styles.main}>
|
||||
{mobileToggle && <div className={styles.mobileBar}>{mobileToggle}</div>}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
min-height: 24px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Нативный input скрыт, но остаётся фокусируемым/доступным.
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid $gray-400;
|
||||
border-radius: 4px;
|
||||
background: $white;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
|
||||
// Выбран: navy-заливка + белая галка (контраст ~9:1).
|
||||
.input:checked + .box {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
}
|
||||
|
||||
.input:checked + .box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid $white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// Промежуточное (после :checked, чтобы перебить галку на чёрточку).
|
||||
.input:indeterminate + .box {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
}
|
||||
|
||||
.input:indeterminate + .box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 7px;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
border: 0;
|
||||
border-radius: 1px;
|
||||
background: $white;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// Фокус с клавиатуры — золотое кольцо (бренд).
|
||||
.input:focus-visible + .box {
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
border-color: $gold-500;
|
||||
}
|
||||
|
||||
// Ховер (когда доступен).
|
||||
.wrap:hover .input:not(:disabled) + .box {
|
||||
border-color: $navy-600;
|
||||
}
|
||||
|
||||
// Недоступен / только чтение.
|
||||
.input:disabled + .box {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.input:disabled ~ .text {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.wrap:has(.input:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// На узких экранах увеличиваем тач-таргет.
|
||||
@include below($bp-md) {
|
||||
.wrap { min-height: 32px; gap: 10px; }
|
||||
.box { width: 20px; height: 20px; }
|
||||
.input:checked + .box::after { left: 6px; top: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from './Checkbox';
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: 'Поля/Checkbox',
|
||||
component: Checkbox,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof Checkbox> = {
|
||||
render: () => {
|
||||
const [c, setC] = useState(false);
|
||||
return <Checkbox checked={c} onChange={setC} label="Согласен" />;
|
||||
},
|
||||
};
|
||||
|
||||
export const States: StoryObj<typeof Checkbox> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox checked={false} onChange={() => undefined} label="Не выбран" />
|
||||
<Checkbox checked onChange={() => undefined} label="Выбран" />
|
||||
<Checkbox checked={false} indeterminate onChange={() => undefined} label="Промежуточный (выбрать все)" />
|
||||
<Checkbox checked disabled onChange={() => undefined} label="Выбран, недоступен" />
|
||||
<Checkbox checked={false} disabled onChange={() => undefined} label="Недоступен" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** В ячейках таблиц — без видимой подписи, с ariaLabel для доступности. */
|
||||
export const InTableCell: StoryObj<typeof Checkbox> = {
|
||||
render: () => {
|
||||
const [c, setC] = useState(true);
|
||||
return <Checkbox checked={c} onChange={setC} ariaLabel="Выбрать строку" />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Checkbox.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
export interface ICheckboxProps {
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Промежуточное состояние (шапка «выбрать все» при частичном выборе). */
|
||||
indeterminate?: boolean;
|
||||
/** Недоступен (заблокирован). */
|
||||
disabled?: boolean;
|
||||
/** Только чтение (режим согласующего). По умолчанию берётся из FormMode-контекста. */
|
||||
readOnly?: boolean;
|
||||
/** Подпись справа от флажка. */
|
||||
label?: string;
|
||||
/** a11y-подпись для флажков без видимого label (в ячейках таблиц). */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Флажок (SharePoint Boolean / выбор строк). Управляемый: checked внутрь, onChange наружу. */
|
||||
export const Checkbox: React.FC<ICheckboxProps> = ({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
indeterminate,
|
||||
disabled,
|
||||
readOnly,
|
||||
label,
|
||||
ariaLabel
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveDisabled = disabled || (readOnly ?? contextReadOnly);
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.indeterminate = !!indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
return (
|
||||
<label className={styles.wrap}>
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.box} aria-hidden="true" />
|
||||
{label && <span className={styles.text}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { DataTable, IDataTableColumn } from './DataTable';
|
||||
import { Badge } from '../Badge/Badge';
|
||||
import { TextInput } from '../TextInput/TextInput';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Таблицы/DataTable',
|
||||
@@ -72,17 +73,13 @@ export const Editable: StoryObj = {
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Наименование',
|
||||
render: (r, i) => (
|
||||
<input value={r.name} onChange={(e) => setCell(i, 'name', e.target.value)} style={{ width: '100%' }} />
|
||||
)
|
||||
render: (r, i) => <TextInput value={r.name} onChange={(v) => setCell(i, 'name', v)} />
|
||||
},
|
||||
{
|
||||
key: 'qty',
|
||||
title: 'Кол-во',
|
||||
align: 'right',
|
||||
render: (r, i) => (
|
||||
<input value={r.qty} onChange={(e) => setCell(i, 'qty', e.target.value)} style={{ width: '80px' }} />
|
||||
)
|
||||
width: '120px',
|
||||
render: (r, i) => <TextInput value={r.qty} onChange={(v) => setCell(i, 'qty', v)} />
|
||||
}
|
||||
];
|
||||
return <DataTable columns={columns} rows={items} />;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 27, 48, .45);
|
||||
animation: tnsu-dlg-fade .15s ease-out;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $white;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-modal;
|
||||
overflow: hidden;
|
||||
animation: tnsu-dlg-pop .16s ease-out;
|
||||
}
|
||||
|
||||
.sm { max-width: 380px; }
|
||||
.lg { max-width: 680px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.titles { flex: 1; min-width: 0; }
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
|
||||
&:hover { background: $gray-100; color: $navy-800; }
|
||||
&:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(212, 160, 30, .25); }
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
font-size: $fs-body;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid $gray-200;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@keyframes tnsu-dlg-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes tnsu-dlg-pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
|
||||
// На телефоне — почти на весь экран, футер-кнопки на всю ширину.
|
||||
@include below($bp-sm) {
|
||||
.overlay { padding: 0; align-items: flex-end; }
|
||||
.panel { max-width: 100%; max-height: 92vh; border-radius: $r-lg $r-lg 0 0; }
|
||||
.footer > * { flex: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay, .panel { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
import { Button } from '../Button/Button';
|
||||
import { TextArea } from '../TextArea/TextArea';
|
||||
import { IconSprite } from '../Icon/IconSprite';
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: 'Оверлеи/Dialog',
|
||||
component: Dialog,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<IconSprite />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const DecisionForm: StoryObj<typeof Dialog> = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Открыть диалог</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Отклонить СТ"
|
||||
subText="Укажите причину отклонения (обязательно)."
|
||||
onDismiss={() => setOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button onClick={() => setOpen(false)}>Отклонить</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea value={comment} onChange={setComment} placeholder="Причина…" />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Dialog.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface IDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
/** Пояснение под заголовком. */
|
||||
subText?: string;
|
||||
onDismiss: () => void;
|
||||
/** Кнопки внизу (обычно Button-ы). */
|
||||
footer?: React.ReactNode;
|
||||
/** Ширина панели. */
|
||||
width?: 'sm' | 'md' | 'lg';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const widthClass: Record<NonNullable<IDialogProps['width']>, string> = {
|
||||
sm: styles.sm,
|
||||
md: '',
|
||||
lg: styles.lg
|
||||
};
|
||||
|
||||
/**
|
||||
* Модальное окно с телом и произвольным футером (форма решения/промпт).
|
||||
* Управляемое: open/onDismiss. Закрытие по Esc и клику по подложке.
|
||||
* Для простых да/нет — используй confirmModal.
|
||||
*/
|
||||
export const Dialog: React.FC<IDialogProps> = ({ open, title, subText, onDismiss, footer, width = 'md', children }) => {
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onDismiss]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onDismiss} role="presentation">
|
||||
<div
|
||||
className={[styles.panel, widthClass[width]].filter(Boolean).join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titles}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
{subText && <p className={styles.sub}>{subText}</p>}
|
||||
</div>
|
||||
<button type="button" className={styles.close} title="Закрыть" aria-label="Закрыть" onClick={onDismiss}>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
{children && <div className={styles.body}>{children}</div>}
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-3;
|
||||
padding: 14px 20px;
|
||||
background: $navy-50;
|
||||
border: 1px solid $navy-100;
|
||||
border-left: 3px solid $gold-500; // фирменный золотой акцент
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: $navy-700;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
// Мобилка: разрешаем перенос и заголовку, и правому слоту — ничего не обрезается.
|
||||
@include below($bp-sm) {
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { FormHeader } from './FormHeader';
|
||||
import { Badge } from '../Badge/Badge';
|
||||
|
||||
const meta: Meta<typeof FormHeader> = {
|
||||
title: 'Формы/FormHeader',
|
||||
component: FormHeader,
|
||||
tags: ['autodocs'],
|
||||
args: { title: 'Сравнительная таблица №1' }
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FormHeader>;
|
||||
|
||||
export const Basic: Story = {};
|
||||
|
||||
export const WithStatus: Story = {
|
||||
args: { right: <Badge variant="info">На согласовании</Badge> }
|
||||
};
|
||||
|
||||
export const LongTitle: Story = {
|
||||
args: {
|
||||
title: 'Сравнительная таблица по закупке материалов для строительства жилого дома в п. Топар',
|
||||
right: <Badge variant="success">Согласована</Badge>
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormHeader.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
export interface IFormHeaderProps {
|
||||
title: React.ReactNode;
|
||||
/** Иконка слева. По умолчанию 'document'. */
|
||||
icon?: IconName;
|
||||
/**
|
||||
* Правый слот — обычно статус-бейдж (Badge), но может быть что угодно
|
||||
* (номер, кнопки). Смысл статуса задаёт проект, FormHeader про него не знает.
|
||||
*/
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Шапка формы: золотой акцент слева, иконка, заголовок и правый слот (статус). */
|
||||
export const FormHeader: React.FC<IFormHeaderProps> = ({ title, icon = 'document', right }) => (
|
||||
<div className={styles.header}>
|
||||
<Icon name={icon} className={styles.icon} />
|
||||
<span className={styles.title}>{title}</span>
|
||||
{right && <span className={styles.right}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
@@ -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="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
</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">
|
||||
<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>
|
||||
@@ -122,6 +127,20 @@ export const IconSprite: React.FC = () => (
|
||||
<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-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">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<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 9v5M12 17h.01" />
|
||||
</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">
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</symbol>
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ICON_NAMES = [
|
||||
'clipboard',
|
||||
'people',
|
||||
'vehicle',
|
||||
'chair',
|
||||
'chat',
|
||||
'camera',
|
||||
'image',
|
||||
@@ -28,9 +29,14 @@ export const ICON_NAMES = [
|
||||
'chevron-down',
|
||||
'chevron-left',
|
||||
'arrow-left',
|
||||
'arrow-up',
|
||||
'arrow-down',
|
||||
'swap',
|
||||
'refresh',
|
||||
'search',
|
||||
'menu',
|
||||
'warning',
|
||||
'info',
|
||||
'check',
|
||||
'plus'
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
color: $navy-800;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $gray-400;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.sm {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
// Акцент удаления.
|
||||
.danger:hover {
|
||||
background: $red-50;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
// Тач-таргет на узких экранах.
|
||||
@include below($bp-md) {
|
||||
.btn { width: 36px; height: 36px; }
|
||||
.sm { width: 32px; height: 32px; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconSprite } from '../Icon/IconSprite';
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Кнопки/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<IconSprite />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Actions: StoryObj<typeof IconButton> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<IconButton icon="arrow-up" title="Выше" onClick={() => undefined} />
|
||||
<IconButton icon="arrow-down" title="Ниже" disabled onClick={() => undefined} />
|
||||
<IconButton icon="swap" title="Заменить" onClick={() => undefined} />
|
||||
<IconButton icon="trash" title="Удалить" danger onClick={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import styles from './IconButton.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
export interface IIconButtonProps {
|
||||
icon: IconName;
|
||||
/** Подсказка (tooltip) и a11y-подпись — обязательна: кнопка без текста. */
|
||||
title: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
/** Акцент удаления (красный ховер). */
|
||||
danger?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Кнопка-иконка (удалить/переместить/заменить). Иконка — из общего спрайта. */
|
||||
export const IconButton: React.FC<IIconButtonProps> = ({
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
disabled,
|
||||
danger,
|
||||
size = 'md',
|
||||
className
|
||||
}) => {
|
||||
const classes = [styles.btn, size === 'sm' ? styles.sm : '', danger ? styles.danger : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<button type="button" className={classes} title={title} aria-label={title} disabled={disabled} onClick={onClick}>
|
||||
<Icon name={icon} size={size === 'sm' ? 'sm' : 'md'} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.link {
|
||||
color: $navy-700;
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
background-size: 0 1.5px;
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
transition: background-size .18s, color .15s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
background-size: 100% 1.5px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Link } from './Link';
|
||||
|
||||
const meta: Meta<typeof Link> = {
|
||||
title: 'Навигация/Link',
|
||||
component: Link,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof Link> = {
|
||||
render: () => (
|
||||
<p style={{ fontSize: 14 }}>
|
||||
Файл: <Link href="#" download>скачать КП поставщика.pdf</Link>
|
||||
</p>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Link.module.scss';
|
||||
|
||||
export interface ILinkProps {
|
||||
href?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
target?: '_blank' | '_self';
|
||||
/** Скачивание файла (атрибут download). */
|
||||
download?: boolean | string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Текстовая ссылка (`<a>`) в бренд-стиле: navy + золотое подчёркивание на ховере. */
|
||||
export const Link: React.FC<ILinkProps> = ({ href, onClick, target, download, className, children }) => {
|
||||
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
|
||||
return (
|
||||
<a
|
||||
className={[styles.link, className].filter(Boolean).join(' ')}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target={target}
|
||||
rel={rel}
|
||||
download={download}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -59,13 +59,15 @@ const PRINT_CSS =
|
||||
'.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 = 'Печать' }) => {
|
||||
const onPrint = (): void => {
|
||||
const source = document.querySelector('.rp-print-root');
|
||||
export function printRoot(selector = '.rp-print-root'): void {
|
||||
const source = document.querySelector(selector);
|
||||
if (!source) {
|
||||
window.print();
|
||||
return;
|
||||
@@ -106,10 +108,14 @@ export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печат
|
||||
window.setTimeout(done, 350);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм.
|
||||
*/
|
||||
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
||||
return (
|
||||
<Button variant="secondary" onClick={onPrint}>
|
||||
<Button variant="secondary" onClick={() => printRoot()}>
|
||||
<Icon name="document" size="sm" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -97,7 +97,10 @@
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
// Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка.
|
||||
text-decoration: none;
|
||||
// Длинное название переносится (не больше 2 строк — см. .itemTitle), а не обрезается многоточием.
|
||||
white-space: normal;
|
||||
cursor: pointer;
|
||||
transition: color .18s, background .18s, border-color .18s;
|
||||
|
||||
@@ -128,11 +131,26 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Не больше 2 строк: совсем длинное название обрезается многоточием на второй строке.
|
||||
.itemTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
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 {
|
||||
@@ -153,6 +171,55 @@
|
||||
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 ----
|
||||
.backdrop {
|
||||
display: none;
|
||||
|
||||
@@ -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)
|
||||
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
|
||||
export const MobileDrawer: Story = {
|
||||
|
||||
+35
-31
@@ -1,16 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
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 {
|
||||
/** Уникальный идентификатор раздела (любая строка). */
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
|
||||
count?: number;
|
||||
}
|
||||
export type { ISidebarItem, ISidebarSubItem } from './sidebarLogic';
|
||||
|
||||
export interface ISidebarProps {
|
||||
items: ISidebarItem[];
|
||||
@@ -29,10 +24,13 @@ export interface ISidebarProps {
|
||||
mobileOpen?: boolean;
|
||||
/** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */
|
||||
onMobileClose?: () => void;
|
||||
/** Тонкие линии между пунктами (подпункты раскрытой группы — без линий, они читаются как одно целое). */
|
||||
dividers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый.
|
||||
* Пункт с href — внешняя ссылка (новая вкладка), с children — раскрывающаяся группа.
|
||||
* Адаптивный:
|
||||
* • десктоп — рейл, сворачивается в иконки (collapsed);
|
||||
* • < 768px — выезжающий поверх контента drawer с затемнением фона
|
||||
@@ -47,20 +45,26 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
||||
onToggleCollapsed,
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
dividers = false,
|
||||
}) => {
|
||||
const navClasses = [
|
||||
styles.nav,
|
||||
collapsed ? styles.navCollapsed : '',
|
||||
mobileOpen ? styles.navMobileOpen : '',
|
||||
dividers ? styles.navDividers : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose
|
||||
// обычно не передан — вызов безвреден.
|
||||
const closeMobile = (): void => {
|
||||
if (onMobileClose) onMobileClose();
|
||||
};
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
onChange(id);
|
||||
if (onMobileClose) onMobileClose();
|
||||
closeMobile();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,28 +88,28 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className={styles.items}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||
const showCount = !!item.count;
|
||||
|
||||
return (
|
||||
<button
|
||||
{items.map((item) =>
|
||||
item.children ? (
|
||||
<SidebarGroup
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={itemClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
</span>
|
||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
||||
{showCount && <span className={styles.badge}>{item.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
collapsed={collapsed}
|
||||
onSelect={handleSelect}
|
||||
onLinkOpen={closeMobile}
|
||||
onExpandRail={onToggleCollapsed}
|
||||
/>
|
||||
) : (
|
||||
<SidebarEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={item.id === activeId}
|
||||
collapsed={collapsed}
|
||||
onSelect={handleSelect}
|
||||
onLinkOpen={closeMobile}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.block {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid $navy-100;
|
||||
border-top-color: $gold-500;
|
||||
border-radius: 50%;
|
||||
animation: tnsu-spin .7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xSmall { width: 12px; height: 12px; border-width: 2px; }
|
||||
.small { width: 16px; height: 16px; border-width: 2px; }
|
||||
.large { width: 32px; height: 32px; border-width: 3px; }
|
||||
|
||||
.label {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
@keyframes tnsu-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Уважение к prefers-reduced-motion: замедляем, не убираем совсем (индикатор занятости).
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring { animation-duration: 1.6s; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
const meta: Meta<typeof Spinner> = {
|
||||
title: 'Состояния/Spinner',
|
||||
component: Spinner,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Sizes: StoryObj<typeof Spinner> = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||
<Spinner size="xSmall" />
|
||||
<Spinner size="small" />
|
||||
<Spinner size="medium" />
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabel: StoryObj<typeof Spinner> = {
|
||||
render: () => <Spinner size="small" label="Генерация комментария…" />,
|
||||
};
|
||||
|
||||
export const Block: StoryObj<typeof Spinner> = {
|
||||
render: () => <Spinner size="large" label="Загрузка данных…" block />,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Spinner.module.scss';
|
||||
|
||||
export type SpinnerSize = 'xSmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
export interface ISpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
/** Подпись справа (или под спиннером при block). */
|
||||
label?: string;
|
||||
/** Вертикальная раскладка (спиннер над подписью, по центру). */
|
||||
block?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClass: Record<SpinnerSize, string> = {
|
||||
xSmall: styles.xSmall,
|
||||
small: styles.small,
|
||||
medium: '',
|
||||
large: styles.large
|
||||
};
|
||||
|
||||
/** Индикатор загрузки (фирменное золотое кольцо на navy-дорожке). */
|
||||
export const Spinner: React.FC<ISpinnerProps> = ({ size = 'medium', label, block, className }) => {
|
||||
const wrap = [styles.wrap, block ? styles.block : '', className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className={wrap} role="status" aria-live="polite">
|
||||
<span className={[styles.ring, sizeClass[size]].filter(Boolean).join(' ')} aria-hidden="true" />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
// Безопасность на любой ширине: если шаги с подписями не влезают — горизонтальная
|
||||
// прокрутка, а не сломанная вёрстка. padding — чтобы золотое свечение текущего
|
||||
// шага не обрезалось областью прокрутки.
|
||||
overflow-x: auto;
|
||||
padding: 4px 0;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: $r-full;
|
||||
border: 1.5px solid $gray-300;
|
||||
background: $white;
|
||||
color: $gray-500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: .18s;
|
||||
}
|
||||
|
||||
// Завершённый — синий с галочкой.
|
||||
.completed {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
// Текущий — золотой акцент (перебивает completed, если шаг и активный, и завершён).
|
||||
.current {
|
||||
border-color: $gold-500;
|
||||
background: $grad-gold;
|
||||
color: $navy-950;
|
||||
box-shadow: $sh-gold;
|
||||
}
|
||||
|
||||
.connector {
|
||||
flex: 1 1 auto;
|
||||
min-width: 24px;
|
||||
height: 2px;
|
||||
margin-top: 15px; // по центру кружка (30/2)
|
||||
background: $gray-200;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.labelCurrent {
|
||||
color: $navy-900;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// На узких экранах подписи прячем — остаются кружки с номерами.
|
||||
@include below($bp-sm) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Stepper, IStepperStep } from './Stepper';
|
||||
|
||||
const STEPS: IStepperStep[] = [
|
||||
{ id: 1, label: 'Выбор позиций' },
|
||||
{ id: 2, label: 'Загрузка КП' },
|
||||
{ id: 3, label: 'Формирование' },
|
||||
{ id: 4, label: 'Согласование' }
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Stepper> = {
|
||||
title: 'Навигация/Stepper',
|
||||
component: Stepper,
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Stepper>;
|
||||
|
||||
// Дошли до шага 3, шаг 1 завершён, сейчас на шаге 2. Клик работает только по
|
||||
// шагам 1–3 (шаг 4 недоступен — до него ещё не дошли).
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [current, setCurrent] = useState<string | number>(2);
|
||||
return (
|
||||
<Stepper
|
||||
steps={STEPS}
|
||||
currentId={current}
|
||||
completedIds={[1]}
|
||||
highestId={3}
|
||||
onStepClick={setCurrent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const FirstStep: Story = {
|
||||
render: () => (
|
||||
<Stepper steps={STEPS} currentId={1} completedIds={[]} highestId={1} onStepClick={() => undefined} />
|
||||
)
|
||||
};
|
||||
|
||||
export const AllDone: Story = {
|
||||
render: () => (
|
||||
<Stepper steps={STEPS} currentId={4} completedIds={[1, 2, 3, 4]} highestId={4} onStepClick={() => undefined} />
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Stepper.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IStepperStep, highestReachableIndex } from './stepperLogic';
|
||||
|
||||
// Логику и тип держим в stepperLogic.ts (чистые, тестируемые), здесь — реэкспорт
|
||||
// для единой точки импорта компонента.
|
||||
export type { IStepperStep } from './stepperLogic';
|
||||
export { highestReachableIndex } from './stepperLogic';
|
||||
|
||||
export interface IStepperProps {
|
||||
steps: IStepperStep[];
|
||||
/** Текущий (активный) шаг. */
|
||||
currentId: string | number;
|
||||
/** Завершённые шаги (показываются галочкой). */
|
||||
completedIds: Array<string | number>;
|
||||
/**
|
||||
* Максимально достигнутый шаг. Кликабельны шаги ДО него включительно —
|
||||
* защита от «прыжка вперёд» на шаг, до которого ещё не дошли.
|
||||
*/
|
||||
highestId: string | number;
|
||||
onStepClick: (id: string | number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Степпер прогресса: кружки с номером/галочкой, подпись, коннекторы между.
|
||||
* Кликабельны только достигнутые шаги (см. highestId). Презентационный и
|
||||
* управляемый: состояние (current/completed/highest) приходит пропсами, клик —
|
||||
* наружу. Шапку формы (номер/статус) держать ОТДЕЛЬНО (FormHeader), не тут.
|
||||
*/
|
||||
export function Stepper({ steps, currentId, completedIds, highestId, onStepClick }: IStepperProps): JSX.Element {
|
||||
const highestIdx = highestReachableIndex(steps, highestId);
|
||||
|
||||
return (
|
||||
<nav className={styles.stepper} aria-label="Прогресс">
|
||||
{steps.map((s, index) => {
|
||||
const isCompleted = completedIds.indexOf(s.id) !== -1;
|
||||
const isCurrent = s.id === currentId;
|
||||
const isNavigable = index <= highestIdx;
|
||||
|
||||
const circleClass = [
|
||||
styles.circle,
|
||||
isCompleted ? styles.completed : '',
|
||||
isCurrent ? styles.current : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
{index > 0 && <span className={styles.connector} aria-hidden="true" />}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.step}
|
||||
disabled={!isNavigable}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
onClick={() => onStepClick(s.id)}
|
||||
>
|
||||
<span className={circleClass}>
|
||||
{isCompleted && !isCurrent ? <Icon name="check" size="sm" /> : index + 1}
|
||||
</span>
|
||||
<span className={[styles.label, isCurrent ? styles.labelCurrent : ''].filter(Boolean).join(' ')}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { highestReachableIndex, IStepperStep } from './stepperLogic';
|
||||
|
||||
const STEPS: IStepperStep[] = [
|
||||
{ id: 1, label: 'Выбор позиций' },
|
||||
{ id: 2, label: 'Загрузка КП' },
|
||||
{ id: 3, label: 'Формирование' },
|
||||
{ id: 4, label: 'Согласование' }
|
||||
];
|
||||
|
||||
describe('highestReachableIndex — навигация степпера', () => {
|
||||
it('возвращает индекс достигнутого шага', () => {
|
||||
expect(highestReachableIndex(STEPS, 3)).toBe(2);
|
||||
expect(highestReachableIndex(STEPS, 4)).toBe(3);
|
||||
});
|
||||
|
||||
it('первый шаг → индекс 0', () => {
|
||||
expect(highestReachableIndex(STEPS, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('неизвестный id → 0 (безопасный фолбэк, не даём улететь вперёд)', () => {
|
||||
expect(highestReachableIndex(STEPS, 99)).toBe(0);
|
||||
});
|
||||
|
||||
it('работает и со строковыми id', () => {
|
||||
const steps: IStepperStep[] = [
|
||||
{ id: 'select', label: 'Выбор' },
|
||||
{ id: 'load', label: 'Загрузка' },
|
||||
{ id: 'build', label: 'Сборка' }
|
||||
];
|
||||
expect(highestReachableIndex(steps, 'load')).toBe(1);
|
||||
expect(highestReachableIndex(steps, 'build')).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Чистая логика степпера — без React и стилей, чтобы покрывать юнит-тестами
|
||||
// в node-окружении (быстро, без браузера).
|
||||
|
||||
export interface IStepperStep {
|
||||
/** Идентификатор шага (число или строка). */
|
||||
id: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Индекс максимально доступного шага. Навигация разрешена по шагам с индексом
|
||||
* <= этого. Порядок берём по ПОЗИЦИИ в массиве (а не по значению id) — работает
|
||||
* и для строковых id. Неизвестный id → 0 (безопасный фолбэк на первый шаг).
|
||||
*/
|
||||
export function highestReachableIndex(steps: IStepperStep[], highestId: string | number): number {
|
||||
const idx = steps.findIndex((s) => s.id === highestId);
|
||||
return idx === -1 ? 0 : idx;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@import '../tokens/tokens';
|
||||
|
||||
// Светлая полоса вкладок; активная — navy с золотым подчёркиванием.
|
||||
// На узких экранах — горизонтальная прокрутка (не ломается, не переносится «лесенкой»).
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: $sp-2;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $navy-800;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $navy-900;
|
||||
font-weight: 600;
|
||||
border-bottom-color: $gold-500;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: $gray-400;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { Tabs, ITab } from './Tabs';
|
||||
|
||||
const QUEUE: ITab[] = [
|
||||
{ id: 'pending', label: 'На согласовании у меня', count: 1 },
|
||||
{ id: 'mine', label: 'Инициализированные мной', count: 71 },
|
||||
{ id: 'approved', label: 'Согласованные мной', count: 4 }
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
title: 'Навигация/Tabs',
|
||||
component: Tabs,
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
|
||||
// Вкладки-очереди реестра (как в дашборде myDocuments).
|
||||
export const Queues: Story = {
|
||||
render: () => {
|
||||
const [active, setActive] = useState<string | number>('pending');
|
||||
return <Tabs tabs={QUEUE} activeId={active} onChange={setActive} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Вкладки категорий с иконками.
|
||||
export const WithIcons: Story = {
|
||||
render: () => {
|
||||
const [active, setActive] = useState<string | number>('rec');
|
||||
return (
|
||||
<Tabs
|
||||
activeId={active}
|
||||
onChange={setActive}
|
||||
tabs={[
|
||||
{ id: 'rec', label: 'Отдел рекрутинга', icon: 'people' },
|
||||
{ id: 'it', label: 'IT', icon: 'monitor' },
|
||||
{ id: 'buh', label: 'Бухгалтерия', icon: 'calculator' }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Tabs.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
export interface ITab {
|
||||
id: string | number;
|
||||
label: React.ReactNode;
|
||||
/** Счётчик после подписи, напр. «(71)». undefined/0 — не показывать. */
|
||||
count?: number;
|
||||
/** Иконка слева (необязательно). */
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
export interface ITabsProps {
|
||||
tabs: ITab[];
|
||||
activeId: string | number;
|
||||
onChange: (id: string | number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Горизонтальные вкладки: активная — navy с золотым подчёркиванием. Управляемый.
|
||||
* На узких экранах прокручивается вбок (не ломается). Подходит и для очередей со
|
||||
* счётчиками (реестр), и для категорий с иконками.
|
||||
*/
|
||||
export const Tabs: React.FC<ITabsProps> = ({ tabs, activeId, onChange }) => (
|
||||
<nav className={styles.tabs} aria-label="Вкладки">
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={[styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ')}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
{t.icon && <Icon name={t.icon} size="sm" />}
|
||||
<span>{t.label}</span>
|
||||
{!!t.count && <span className={styles.count}>({t.count})</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
@@ -21,3 +21,17 @@
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// Инлайн-ввод в ячейке таблицы: без рамки/фона, компактный, без ручного ресайза.
|
||||
.borderless {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
padding: 6px 8px;
|
||||
min-height: 32px;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
background: $white;
|
||||
border-color: $gold-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,20 +10,25 @@ interface ITextAreaProps {
|
||||
rows?: number;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
/** Без рамки/фона — для инлайн-ввода в ячейках таблиц. */
|
||||
borderless?: boolean;
|
||||
/** a11y-подпись, когда видимого label нет. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Многострочный ввод (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 effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
className={styles.textarea}
|
||||
className={[styles.textarea, borderless ? styles.borderless : ''].filter(Boolean).join(' ')}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
readOnly={effectiveReadOnly}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,3 +19,16 @@
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// Инлайн-ввод в ячейке таблицы: без рамки/фона, компактнее; рамка/фон проявляются на фокусе.
|
||||
.borderless {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
background: $white;
|
||||
border-color: $gold-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,19 @@ interface ITextInputProps {
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Границы значения — для type='date' YYYY-MM-DD (native min/max), для
|
||||
* type='number' числовая граница. Браузер сам блокирует недоступные даты в
|
||||
* календаре при type='date'.
|
||||
*/
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
/** Без рамки/фона — для инлайн-ввода в ячейках таблиц (рамка проявляется на фокусе). */
|
||||
borderless?: boolean;
|
||||
/** a11y-подпись, когда видимого label нет (ячейки таблиц). */
|
||||
ariaLabel?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
|
||||
@@ -21,18 +34,29 @@ export const TextInput: React.FC<ITextInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly
|
||||
readOnly,
|
||||
min,
|
||||
max,
|
||||
borderless,
|
||||
ariaLabel,
|
||||
onKeyDown,
|
||||
autoFocus
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
className={styles.input}
|
||||
className={[styles.input, borderless ? styles.borderless : ''].filter(Boolean).join(' ')}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
aria-label={ariaLabel}
|
||||
min={min}
|
||||
max={max}
|
||||
autoFocus={autoFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
+28
-2
@@ -5,7 +5,11 @@
|
||||
|
||||
// --- Кнопки ---
|
||||
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';
|
||||
@@ -14,6 +18,8 @@ export { Badge } from './Badge/Badge';
|
||||
export type { IBadgeProps, BadgeVariant } from './Badge/Badge';
|
||||
|
||||
// --- Оболочка и раскладка формы ---
|
||||
export { FormHeader } from './FormHeader/FormHeader';
|
||||
export type { IFormHeaderProps } from './FormHeader/FormHeader';
|
||||
export { FormCard } from './FormCard/FormCard';
|
||||
export { FormGrid } from './FormGrid/FormGrid';
|
||||
export { FormField } from './FormField/FormField';
|
||||
@@ -28,6 +34,8 @@ export { MoneyInput } from './MoneyInput/MoneyInput';
|
||||
export { Select } from './Select/Select';
|
||||
export type { ISelectOption } from './Select/Select';
|
||||
export { Toggle } from './Toggle/Toggle';
|
||||
export { Checkbox } from './Checkbox/Checkbox';
|
||||
export type { ICheckboxProps } from './Checkbox/Checkbox';
|
||||
|
||||
// --- Режим «только чтение» для полей формы ---
|
||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
@@ -42,16 +50,34 @@ export { FileDropzone } from './FileDropzone/FileDropzone';
|
||||
export { AttachmentList } 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 { confirmModal } 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 type { IAppLayoutProps } from './AppLayout/AppLayout';
|
||||
|
||||
// --- Навигация ---
|
||||
export { Tabs } from './Tabs/Tabs';
|
||||
export type { ITab, ITabsProps } from './Tabs/Tabs';
|
||||
export { Stepper, highestReachableIndex } from './Stepper/Stepper';
|
||||
export type { IStepperStep, IStepperProps } from './Stepper/Stepper';
|
||||
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 type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
|
||||
export { Link } from './Link/Link';
|
||||
export type { ILinkProps } from './Link/Link';
|
||||
|
||||
// --- Таблицы ---
|
||||
export { DataTable } from './DataTable/DataTable';
|
||||
|
||||
@@ -43,6 +43,7 @@ $green-50: #F0FDF4;
|
||||
$blue-600: #2563EB;
|
||||
$blue-50: #EFF6FF;
|
||||
$amber-700: #854D0E;
|
||||
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
|
||||
|
||||
$white: #fff;
|
||||
|
||||
|
||||
+1
-1
@@ -13,5 +13,5 @@
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.stories.tsx"]
|
||||
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
plugins: [react(), dts({
|
||||
include: ["src"],
|
||||
// истории — только для витрины Storybook, в пакет их типы не кладём
|
||||
exclude: ["src/**/*.stories.tsx"],
|
||||
exclude: ["src/**/*.stories.tsx", "src/**/*.test.ts"],
|
||||
insertTypesEntry: true
|
||||
})],
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Отдельный конфиг для быстрых ЮНИТ-тестов чистой логики (node, без браузера).
|
||||
// Storybook добавил свой browser-проект в vite.config.ts — его не трогаем;
|
||||
// логику гоняем этим конфигом: npm test.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
css: false
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user