Compare commits
9 Commits
f2e8108d1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec13410945 | |||
| 86f4ec379a | |||
| 84964b62c1 | |||
| 65fc6ced30 | |||
| 6fd3672edc | |||
| 2a32b998f6 | |||
| 048fec0696 | |||
| 06e9007e73 | |||
| 0082b90c2b |
@@ -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/
|
||||||
|
|||||||
+4
-4
@@ -169,11 +169,11 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
|
|||||||
|
|
||||||
## Публикация (Gitea, НЕ GitLab!)
|
## Публикация (Gitea, НЕ GitLab!)
|
||||||
|
|
||||||
- Хостинг git — **Gitea 1.26.4** на http://git.tnsu.kz (не GitLab). Registry npm привязан
|
- Хостинг git — **Gitea 1.26.4** на https://git.tnsu.kz (не GitLab). Registry npm привязан
|
||||||
к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно.
|
к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно.
|
||||||
- Адрес registry: `http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
- Адрес registry: `https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
||||||
- `.npmrc` в проекте (можно коммитить, НЕ секрет):
|
- `.npmrc` в проекте (можно коммитить, НЕ секрет):
|
||||||
`@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
`@tnsu:registry=https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
|
||||||
- Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает:
|
- Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает:
|
||||||
`//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>`
|
`//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>`
|
||||||
- Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись.
|
- Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись.
|
||||||
@@ -188,4 +188,4 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
|
|||||||
|
|
||||||
- Node: v22.x (как в SPFx: >=22.14 <23)
|
- Node: v22.x (как в SPFx: >=22.14 <23)
|
||||||
- React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1
|
- React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1
|
||||||
- GitLab: http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git
|
- GitLab: https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git
|
||||||
|
|||||||
@@ -4,40 +4,15 @@
|
|||||||
|
|
||||||
> Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md).
|
> Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md).
|
||||||
|
|
||||||
## Первичная настройка (один раз на каждого разработчика)
|
|
||||||
|
|
||||||
Пакет лежит в приватном registry (Gitea), поэтому перед первой установкой нужно один
|
|
||||||
раз настроить доступ. Дальше про это можно забыть.
|
|
||||||
|
|
||||||
**Шаг 1. Создать токен в Gitea:**
|
|
||||||
1. Открой http://git.tnsu.kz → кликни на свой аватар справа вверху → **Настройки**.
|
|
||||||
2. Слева: **Приложения**.
|
|
||||||
3. В блоке «Управление токенами» → **Создать новый токен**:
|
|
||||||
- Имя: например `npm-read`.
|
|
||||||
- Права (scopes): найди строку **`package`** и поставь **«Прочитанные»** (Read).
|
|
||||||
Остальное — «Нет доступа».
|
|
||||||
4. Нажми **Генерировать токен** и **сразу скопируй** его (показывается один раз).
|
|
||||||
|
|
||||||
**Шаг 2. Прописать токен у себя на компьютере.**
|
|
||||||
Создай (или дополни) файл `.npmrc` в своей домашней папке
|
|
||||||
(`C:\Users\<твоё-имя>\.npmrc`) одной строкой:
|
|
||||||
|
|
||||||
```
|
|
||||||
//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ВСТАВЬ_СВОЙ_ТОКЕН
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ Токен — это как пароль. Он лежит в домашней папке, **вне проекта**, и в git не попадает.
|
|
||||||
> Никогда не коммить его и не вставляй в код.
|
|
||||||
|
|
||||||
Всё. Теперь `npm i` в любом проекте, где подключён `@tnsu/ui-kit`, будет работать.
|
|
||||||
|
|
||||||
## Установка в проект
|
## Установка в проект
|
||||||
|
|
||||||
Убедись, что в корне проекта есть файл `.npmrc` со строкой (для request-portal он уже
|
Пакет лежит в реестре организации **tansu** на git.tnsu.kz. Реестр публичный, а сам git.tnsu.kz
|
||||||
закоммичен):
|
доступен только из внутренней сети, поэтому **токен для установки не нужен** (из сети или по VPN).
|
||||||
|
|
||||||
|
В корне проекта — файл `.npmrc` со строкой:
|
||||||
|
|
||||||
```
|
```
|
||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем:
|
Затем:
|
||||||
@@ -86,11 +61,21 @@ npm run storybook # витрина компонентов (localhost:6006)
|
|||||||
|
|
||||||
### Публикация новой версии
|
### Публикация новой версии
|
||||||
|
|
||||||
```powershell
|
Публикует пайплайн **publish** в Gitea — по кнопке: Actions → publish → Run workflow. Сначала юнит-тесты
|
||||||
# 1. поднять version в package.json (напр. 0.0.1 → 0.0.2)
|
и сборка, потом публикация в реестр tansu.
|
||||||
npm run build
|
|
||||||
npm publish
|
|
||||||
```
|
|
||||||
|
|
||||||
> npm не даёт перезаписать уже опубликованную версию — перед публикацией всегда
|
- **Версию поднимает пайплайн.** В `package.json` задаются только major.minor (патч там не важен):
|
||||||
> бампай `version` в package.json.
|
новая функциональность — поднять minor руками (`0.14.x` → `0.15.0`). Патч — следующий после самого
|
||||||
|
большого опубликованного с тем же major.minor. В git ничего не коммитится.
|
||||||
|
- **Тот же код не публикуется.** Пайплайн считает хэш исходников (`src` без тестов и историй Storybook,
|
||||||
|
`package.json`, конфиги сборки, README), кладёт его в публикуемый `package.json` (поле `sourceHash`)
|
||||||
|
и сравнивает с последней опубликованной версией. Совпало — пайплайн падает с «Публиковать нечего».
|
||||||
|
- Секрет: `PACKAGES_TOKEN` — токен Gitea со scope `write:package` (владелец — участник организации tansu
|
||||||
|
с правом записи). Логин не нужен.
|
||||||
|
|
||||||
|
Пробный прогон без публикации: `DRY_RUN=1 bash ci/publish.sh`.
|
||||||
|
|
||||||
|
Версии до 0.14.0 скопированы из прежнего реестра `timur.gorodetskiy` без хэша, поэтому первая публикация
|
||||||
|
пайплайном выпустит `0.14.1` даже без изменений кода; дальше проверка работает как обычно.
|
||||||
|
|
||||||
|
На каждый push и pull request пайплайн **test** гоняет юнит-тесты и сборку.
|
||||||
|
|||||||
Executable
+108
@@ -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",
|
"name": "@tnsu/ui-kit",
|
||||||
"version": "0.0.1",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@tnsu/ui-kit",
|
"name": "@tnsu/ui-kit",
|
||||||
"version": "0.0.1",
|
"version": "0.15.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "latest",
|
"@chromatic-com/storybook": "latest",
|
||||||
|
|||||||
+5
-2
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@tnsu/ui-kit",
|
"name": "@tnsu/ui-kit",
|
||||||
"version": "0.11.0",
|
"version": "0.15.0",
|
||||||
"description": "TNSU shared UI components for SPFx web parts",
|
"description": "TNSU shared UI components for SPFx web parts",
|
||||||
"author": "Timur Gorodetskiy",
|
"author": "Timur Gorodetskiy",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git"
|
"url": "https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.tnsu.kz/api/packages/tansu/npm/"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -78,6 +78,11 @@ export const IconSprite: React.FC = () => (
|
|||||||
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="i-chair" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 10V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4" />
|
||||||
|
<path d="M4 16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4a2 2 0 0 0-4 0v1H8v-1a2 2 0 0 0-4 0z" />
|
||||||
|
<path d="M6 18v2M18 18v2" />
|
||||||
|
</symbol>
|
||||||
<symbol id="i-chat" viewBox="0 0 24 24">
|
<symbol id="i-chat" viewBox="0 0 24 24">
|
||||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
|
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const ICON_NAMES = [
|
|||||||
'clipboard',
|
'clipboard',
|
||||||
'people',
|
'people',
|
||||||
'vehicle',
|
'vehicle',
|
||||||
|
'chair',
|
||||||
'chat',
|
'chat',
|
||||||
'camera',
|
'camera',
|
||||||
'image',
|
'image',
|
||||||
|
|||||||
@@ -59,57 +59,63 @@ const PRINT_CSS =
|
|||||||
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно,
|
* Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно,
|
||||||
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
|
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
|
||||||
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм.
|
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Вынесена
|
||||||
|
* из PrintButton отдельной функцией, чтобы печать можно было вызвать
|
||||||
|
* программно — например, сразу после выбора варианта в DropdownButton, а не
|
||||||
|
* только по клику на саму кнопку (см. PrintSplitButton в request-portal).
|
||||||
|
*/
|
||||||
|
export function printRoot(selector = '.rp-print-root'): void {
|
||||||
|
const source = document.querySelector(selector);
|
||||||
|
if (!source) {
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||||
|
if (!win) {
|
||||||
|
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = source.cloneNode(true) as Element;
|
||||||
|
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
||||||
|
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||||
|
renderValuesAsText(source, clone);
|
||||||
|
|
||||||
|
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
||||||
|
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||||
|
.map((n) => n.outerHTML)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||||
|
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||||
|
);
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
|
||||||
|
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
||||||
|
const done = (): void => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
};
|
||||||
|
if (win.document.readyState === 'complete') {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
} else {
|
||||||
|
win.onload = (): void => {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм.
|
||||||
*/
|
*/
|
||||||
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
||||||
const onPrint = (): void => {
|
|
||||||
const source = document.querySelector('.rp-print-root');
|
|
||||||
if (!source) {
|
|
||||||
window.print();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const win = window.open('', '_blank', 'width=980,height=1200');
|
|
||||||
if (!win) {
|
|
||||||
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clone = source.cloneNode(true) as Element;
|
|
||||||
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
|
||||||
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
|
||||||
renderValuesAsText(source, clone);
|
|
||||||
|
|
||||||
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
|
||||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
|
||||||
.map((n) => n.outerHTML)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
win.document.open();
|
|
||||||
win.document.write(
|
|
||||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
|
||||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
|
||||||
);
|
|
||||||
win.document.close();
|
|
||||||
win.focus();
|
|
||||||
|
|
||||||
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
|
||||||
const done = (): void => {
|
|
||||||
win.print();
|
|
||||||
win.close();
|
|
||||||
};
|
|
||||||
if (win.document.readyState === 'complete') {
|
|
||||||
window.setTimeout(done, 350);
|
|
||||||
} else {
|
|
||||||
win.onload = (): void => {
|
|
||||||
window.setTimeout(done, 350);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="secondary" onClick={onPrint}>
|
<Button variant="secondary" onClick={() => printRoot()}>
|
||||||
<Icon name="document" size="sm" />
|
<Icon name="document" size="sm" />
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -97,7 +97,10 @@
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
// Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка.
|
||||||
|
text-decoration: none;
|
||||||
|
// Длинное название переносится (не больше 2 строк — см. .itemTitle), а не обрезается многоточием.
|
||||||
|
white-space: normal;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color .18s, background .18s, border-color .18s;
|
transition: color .18s, background .18s, border-color .18s;
|
||||||
|
|
||||||
@@ -128,11 +131,26 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Не больше 2 строк: совсем длинное название обрезается многоточием на второй строке.
|
||||||
.itemTitle {
|
.itemTitle {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Линии между пунктами (проп dividers) ----
|
||||||
|
// Линия — над каждым пунктом, кроме первого; подпункты раскрытой группы лежат внутри .group — их не трогаем.
|
||||||
|
.navDividers .items {
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -153,6 +171,55 @@
|
|||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Раскрывающаяся группа: подпункты без иконок, с отступом под иконку шапки ----
|
||||||
|
$sub-item-indent: 48px; // 18px отступ пункта + 3px маркер + иконка 16px + зазор
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupChevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
color: rgba(255, 255, 255, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subItems {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 18px 8px $sub-item-indent;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, .55);
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s, background .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(255, 255, 255, .92);
|
||||||
|
background: rgba(255, 255, 255, .04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subItemActive {
|
||||||
|
color: $white;
|
||||||
|
border-left-color: $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Затемнение фона под мобильным drawer ----
|
// ---- Затемнение фона под мобильным drawer ----
|
||||||
.backdrop {
|
.backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -63,6 +63,59 @@ export const Collapsed: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Пункт с children — раскрывающаяся группа; подпункт с href открывается в новой вкладке.
|
||||||
|
const ITEMS_WITH_GROUP: ISidebarItem[] = [
|
||||||
|
...ITEMS.slice(0, 3),
|
||||||
|
{ id: 'long', title: 'Загрузить работы из сметы проекта', icon: 'download' },
|
||||||
|
{
|
||||||
|
id: 'catalogs',
|
||||||
|
title: 'Справочники',
|
||||||
|
icon: 'info',
|
||||||
|
children: [
|
||||||
|
{ id: 'companies', title: 'Компании', href: 'https://example.com/companies' },
|
||||||
|
{ id: 'units', title: 'Единицы измерения', href: 'https://example.com/units' },
|
||||||
|
{ id: 'manual', title: 'Инструкция', href: 'https://example.com/manual' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'help', title: 'Справка', icon: 'chat', href: 'https://example.com/help' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WithGroupAndLinks: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [active, setActive] = useState('approvals');
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
items={ITEMS_WITH_GROUP}
|
||||||
|
activeId={active}
|
||||||
|
onChange={setActive}
|
||||||
|
brand="Портал заявок"
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Линии между пунктами; длинное название переносится на вторую строку.
|
||||||
|
export const WithDividers: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [active, setActive] = useState('approvals');
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
items={ITEMS_WITH_GROUP}
|
||||||
|
activeId={active}
|
||||||
|
onChange={setActive}
|
||||||
|
brand="Портал заявок"
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||||
|
dividers
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook)
|
// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook)
|
||||||
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
|
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
|
||||||
export const MobileDrawer: Story = {
|
export const MobileDrawer: Story = {
|
||||||
|
|||||||
+35
-31
@@ -1,16 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Sidebar.module.scss';
|
import styles from './Sidebar.module.scss';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { IconName } from '../Icon/iconNames';
|
import { ISidebarItem } from './sidebarLogic';
|
||||||
|
import { SidebarEntry } from './SidebarEntry';
|
||||||
|
import { SidebarGroup } from './SidebarGroup';
|
||||||
|
|
||||||
export interface ISidebarItem {
|
export type { ISidebarItem, ISidebarSubItem } from './sidebarLogic';
|
||||||
/** Уникальный идентификатор раздела (любая строка). */
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: IconName;
|
|
||||||
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISidebarProps {
|
export interface ISidebarProps {
|
||||||
items: ISidebarItem[];
|
items: ISidebarItem[];
|
||||||
@@ -29,10 +24,13 @@ export interface ISidebarProps {
|
|||||||
mobileOpen?: boolean;
|
mobileOpen?: boolean;
|
||||||
/** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */
|
/** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */
|
||||||
onMobileClose?: () => void;
|
onMobileClose?: () => void;
|
||||||
|
/** Тонкие линии между пунктами (подпункты раскрытой группы — без линий, они читаются как одно целое). */
|
||||||
|
dividers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый.
|
* Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый.
|
||||||
|
* Пункт с href — внешняя ссылка (новая вкладка), с children — раскрывающаяся группа.
|
||||||
* Адаптивный:
|
* Адаптивный:
|
||||||
* • десктоп — рейл, сворачивается в иконки (collapsed);
|
* • десктоп — рейл, сворачивается в иконки (collapsed);
|
||||||
* • < 768px — выезжающий поверх контента drawer с затемнением фона
|
* • < 768px — выезжающий поверх контента drawer с затемнением фона
|
||||||
@@ -47,20 +45,26 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
|||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
mobileOpen = false,
|
mobileOpen = false,
|
||||||
onMobileClose,
|
onMobileClose,
|
||||||
|
dividers = false,
|
||||||
}) => {
|
}) => {
|
||||||
const navClasses = [
|
const navClasses = [
|
||||||
styles.nav,
|
styles.nav,
|
||||||
collapsed ? styles.navCollapsed : '',
|
collapsed ? styles.navCollapsed : '',
|
||||||
mobileOpen ? styles.navMobileOpen : '',
|
mobileOpen ? styles.navMobileOpen : '',
|
||||||
|
dividers ? styles.navDividers : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
// На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose
|
// На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose
|
||||||
// обычно не передан — вызов безвреден.
|
// обычно не передан — вызов безвреден.
|
||||||
|
const closeMobile = (): void => {
|
||||||
|
if (onMobileClose) onMobileClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelect = (id: string): void => {
|
const handleSelect = (id: string): void => {
|
||||||
onChange(id);
|
onChange(id);
|
||||||
if (onMobileClose) onMobileClose();
|
closeMobile();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,28 +88,28 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.items}>
|
<div className={styles.items}>
|
||||||
{items.map((item) => {
|
{items.map((item) =>
|
||||||
const isActive = item.id === activeId;
|
item.children ? (
|
||||||
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
<SidebarGroup
|
||||||
const showCount = !!item.count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
item={item}
|
||||||
className={itemClasses}
|
activeId={activeId}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
collapsed={collapsed}
|
||||||
onClick={() => handleSelect(item.id)}
|
onSelect={handleSelect}
|
||||||
title={collapsed ? item.title : undefined}
|
onLinkOpen={closeMobile}
|
||||||
>
|
onExpandRail={onToggleCollapsed}
|
||||||
<span className={styles.itemIcon}>
|
/>
|
||||||
<Icon name={item.icon} size="sm" />
|
) : (
|
||||||
</span>
|
<SidebarEntry
|
||||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
key={item.id}
|
||||||
{showCount && <span className={styles.badge}>{item.count}</span>}
|
item={item}
|
||||||
</button>
|
isActive={item.id === activeId}
|
||||||
);
|
collapsed={collapsed}
|
||||||
})}
|
onSelect={handleSelect}
|
||||||
|
onLinkOpen={closeMobile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+4
-2
@@ -5,9 +5,11 @@
|
|||||||
|
|
||||||
// --- Кнопки ---
|
// --- Кнопки ---
|
||||||
export { Button } from './Button/Button';
|
export { Button } from './Button/Button';
|
||||||
export { PrintButton } from './PrintButton/PrintButton';
|
export { PrintButton, printRoot } from './PrintButton/PrintButton';
|
||||||
export { IconButton } from './IconButton/IconButton';
|
export { IconButton } from './IconButton/IconButton';
|
||||||
export type { IIconButtonProps } from './IconButton/IconButton';
|
export type { IIconButtonProps } from './IconButton/IconButton';
|
||||||
|
export { DropdownButton } from './DropdownButton/DropdownButton';
|
||||||
|
export type { IDropdownButtonProps, IDropdownButtonOption } from './DropdownButton/DropdownButton';
|
||||||
|
|
||||||
// --- Сообщения / плашки ---
|
// --- Сообщения / плашки ---
|
||||||
export { Alert } from './Alert/Alert';
|
export { Alert } from './Alert/Alert';
|
||||||
@@ -71,7 +73,7 @@ export type { ITab, ITabsProps } from './Tabs/Tabs';
|
|||||||
export { Stepper, highestReachableIndex } from './Stepper/Stepper';
|
export { Stepper, highestReachableIndex } from './Stepper/Stepper';
|
||||||
export type { IStepperStep, IStepperProps } from './Stepper/Stepper';
|
export type { IStepperStep, IStepperProps } from './Stepper/Stepper';
|
||||||
export { Sidebar } from './Sidebar/Sidebar';
|
export { Sidebar } from './Sidebar/Sidebar';
|
||||||
export type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar';
|
export type { ISidebarItem, ISidebarSubItem, ISidebarProps } from './Sidebar/Sidebar';
|
||||||
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
||||||
export type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
|
export type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
|
||||||
export { Link } from './Link/Link';
|
export { Link } from './Link/Link';
|
||||||
|
|||||||
Reference in New Issue
Block a user