Compare commits

...
7 Commits
Author SHA1 Message Date
Denozordec dc803bcb34 refactor(web): remove deprecated dashboard components and enhance KPI grid
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard.
- Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality.
- Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions.
- Introduced new properties for KPI cards to support progress tracking and improved visual feedback.

This cleanup aims to enhance performance and maintainability of the dashboard while providing a better user experience.
2026-08-31 10:15:59 +07:00
Denozordec e469c421ca feat(web): enhance status toggle group and filter utilities
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 24s
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m37s
CD / publish (push) Successful in 2m53s
- Introduced new properties for status toggle items, including `icon` and `tone`, to improve visual representation.
- Updated the `StatusToggleGroup` component to utilize these new properties for better user feedback.
- Enhanced filter utilities with additional functions for managing filter queries, including `getPrimaryTextField`, `getExtraFilterFields`, and `setFilterTextValue`.
- Improved the `ResourcePageFiltered` component to integrate search functionality and manage filter states more effectively.

This update aims to streamline the user experience in managing statuses and filters across the application.
2026-08-23 13:16:00 +07:00
DenozordecandCursor 094f75913b fix(web): unify ReUI chrome and stop tab underline overlap
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 1m0s
CD / quality (push) Successful in 1m35s
CD / publish (push) Successful in 2m59s
Секции страниц переведены на segmented Tabs (c-tabs-9), статус грида — ToggleGroup в toolbar. Убраны календарь задач, пустые вкладки мониторинга и demo-деревья ReUI. KPI overflow как в CFDM, DetailPanel на карточке модуля, один FrameSection.

Co-authored-by: Cursor <[email protected]>
2026-08-23 12:46:20 +07:00
Denozordec 764a4e5b4e feat(web): enhance action column functionality in data grid components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m36s
CD / publish (push) Successful in 2m55s
- Introduced `KIT_ACTION_COLUMN_SIZE` for consistent action column sizing across data grids.
- Updated `applyKitActionColumn` to enforce action column properties such as fixed size, no sorting, and no resizing.
- Enhanced `kitColumnPinning` logic to conditionally enable pinning based on horizontal scrolling.
- Added tests for `applyKitActionColumn` and `kitColumnPinning` to ensure expected behavior.
- Adjusted `AccessApiKeysGrid`, `ScheduleModulesGrid`, and `ResourcePageFiltered` components to utilize new action column features.
2026-08-23 02:58:07 +07:00
Denozordec 2d29a892f9 feat(web): integrate shadcn and enhance data grid styles
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 31s
quality / web (push) Successful in 1m7s
quality / go (push) Successful in 1m10s
quality / bird2 (push) Successful in 17s
CD / quality (push) Successful in 3m20s
CD / publish (push) Successful in 4m5s
- Added `shadcn` dependency to the project for improved styling.
- Updated `kitDataGridTableClassNames` to include a new base class for pinned cells.
- Enhanced global styles by importing `shadcn/tailwind.css`.
- Added tests to verify new class names in the data grid component.
2026-08-23 02:33:22 +07:00
Denozordec d9bec85c02 feat(docker): add Docker Hub authentication for CI/CD workflows
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 21s
quality / web (push) Successful in 1m12s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m7s
CD / publish (push) Successful in 3m58s
- Introduced `docker_hub_token` and `docker_hub_username` secrets for Docker Hub login in CI/CD workflows.
- Updated `.gitea/workflows/ci.yaml`, `.gitea/workflows/cd.yaml`, and `.gitea/workflows/quality.yaml` to utilize these secrets for Docker Hub authentication.
- Enhanced README and releasing documentation to clarify the use of Docker Hub credentials for image mirroring and builds.
2026-08-23 01:55:33 +07:00
DenozordecandCursor d56405af7b feat(web): upgrade lists to ReUI Table v9 and EventCalendar
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s
Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках.
Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски.
Удалены settings-7 и самописные DataGridSection/toolbar.

Co-authored-by: Cursor <[email protected]>
2026-08-23 01:38:56 +07:00
235 changed files with 28598 additions and 8741 deletions
+11 -8
View File
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|--------|----------|--------| |--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` | | Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` | | Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*`обёртка `DataGridCard` | | Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*``reui-kit/ResourcePage` |
| Мультифильтры | `@reui` | `@/components/reui/filters` | | Мультифильтры | `@reui` | `@/components/reui/filters` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` | | Number field со stepper | `@reui` | `@/components/reui/number-field` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete``AutoCompleteInput` | | Autocomplete | `@reui` | `@/components/reui/autocomplete``AutoCompleteInput` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` | | Date selector / range | `@reui` | `@/components/reui/date-selector` |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` | | Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table. **Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
## Уже установленные shared-обёртки ## Уже установленные shared-обёртки
В `apps/web/src/components/`: В `apps/web/src/components/`:
- `PageHeader`, `PageShell` — заголовки и обёртки страниц - `PageHeader`, `PageShell` — заголовки и обёртки страниц
- `QueryState` — обёртка loading/error/empty для TanStack Query - `QueryState` — обёртка loading/error/empty для TanStack Query
- `EmptyState` — пустые списки - `EmptyState` — пустые списки (empty-state-14 DNA)
- `ConfirmDialog` — подтверждения (не `window.confirm`) - `ConfirmDialog` — подтверждения (не `window.confirm`)
- `LoadingButton` — кнопка с loading-состоянием - `LoadingButton` SoT кнопка с loading (`Button` + `Spinner`)
- `StatusBadge` — статусные бейджи - `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
- `SectionCards` — сетка KPI-карточек - `SegmentedTabs` — секции страницы (c-tabs-9)
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны - `StatusToggleGroup` — статус грида (c-toggle-group-5)
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
- `TruncatedText` — текст с тултипом - `TruncatedText` — текст с тултипом
- `ModeToggle` — переключатель темы
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
Перед созданием новой обёртки — проверить существующие через Codegraph. Перед созданием новой обёртки — проверить существующие через Codegraph.
+2 -2
View File
@@ -70,14 +70,14 @@ alwaysApply: true
|------|------|--------| |------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` | | shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` | | ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes | | PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` | | Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web) ## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper` **Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` **Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, … **Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+10 -3
View File
@@ -23,10 +23,10 @@ alwaysApply: false
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) | | shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` | | ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton | | Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell | | ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) | | Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`. **Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`. Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
@@ -48,9 +48,16 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки. **WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form *Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`. **WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`. *Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-06a** | MUST | Переключатели по роли UX:
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
Не `variant="line"` для page/in-grid tabs.
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`. **WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner *Проверка:* https://ui.shadcn.com/docs/components/sonner
+2
View File
@@ -39,6 +39,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages). **`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
### Теги образов ### Теги образов
```text ```text
+8
View File
@@ -17,6 +17,8 @@ jobs:
allow_registry_login: false allow_registry_login: false
secrets: secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }} ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
docker_hub_token: ${{ secrets.docker_hub_token }}
docker_hub_username: ${{ secrets.docker_hub_username }}
publish: publish:
needs: [quality] needs: [quality]
@@ -107,6 +109,12 @@ jobs:
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)" short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.docker_hub_username || gitea.actor }}
password: ${{ secrets.docker_hub_token }}
- name: Log in to Gitea Registry - name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true' if: steps.rel.outputs.released == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+2
View File
@@ -21,3 +21,5 @@ jobs:
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }} allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
secrets: secrets:
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }} ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
docker_hub_token: ${{ secrets.docker_hub_token }}
docker_hub_username: ${{ secrets.docker_hub_username }}
+10
View File
@@ -26,6 +26,10 @@ on:
secrets: secrets:
ACTIONS_PAT: ACTIONS_PAT:
required: false required: false
docker_hub_token:
required: false
docker_hub_username:
required: false
permissions: permissions:
contents: read contents: read
@@ -397,6 +401,12 @@ jobs:
name: evobgp name: evobgp
driver: docker-container driver: docker-container
cleanup: false cleanup: false
- name: Log in to Docker Hub
if: secrets.docker_hub_token != ''
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.docker_hub_username || gitea.actor }}
password: ${{ secrets.docker_hub_token }}
- name: Log in to Gitea Registry - name: Log in to Gitea Registry
if: inputs.allow_registry_login if: inputs.allow_registry_login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+1 -1
View File
@@ -23,7 +23,7 @@
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` | | Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` | | Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
| Точки входа процессов | `cmd/*/` | | Точки входа процессов | `cmd/*/` |
| Веб (SvelteKit) | `web/` | | Веб (React + ReUI) | `apps/web/` |
| Compose, деплой | `deploy/compose/` | | Compose, деплой | `deploy/compose/` |
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md). Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
+4 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals' import globals from 'globals'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] }, { ignores: ['dist', 'src/routeTree.gen.ts'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
@@ -28,6 +28,9 @@ export default tseslint.config(
}, },
{ {
files: ['src/components/reui/**/*.{ts,tsx}'], files: ['src/components/reui/**/*.{ts,tsx}'],
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
rules: { rules: {
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
'react-hooks/exhaustive-deps': 'off', 'react-hooks/exhaustive-deps': 'off',
+4 -2
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.0.0", "@base-ui/react": "^1.0.0",
"@date-fns/tz": "^1.5.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -26,8 +27,8 @@
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2", "@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@tanstack/react-virtual": "^3.14.4", "@tanstack/react-virtual": "^3.14.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
@@ -39,6 +40,7 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"recharts": "3.8.0", "recharts": "3.8.0",
"shadcn": "^4.19.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
@@ -2,9 +2,7 @@ import { useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react' import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid' import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { FrameDataGrid } from '@/components/reui-kit'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog' import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog' import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
@@ -47,46 +45,38 @@ export function AccessApiKeysCard({
return ( return (
<> <>
<FrameDataGrid <QueryState
title="API-ключи" data={items}
description="Полный токен показывается только при создании и ротации." isLoading={isLoading}
className="min-w-0" isError={isError}
actions={ error={error}
<> empty={false}
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}> skeleton={<TableSkeleton rows={4} cols={6} />}
<RefreshCw className={isLoading ? 'animate-spin' : ''} /> onRetry={onRetry}
Обновить
</Button>
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</>
}
> >
<QueryState {(data) => (
data={items} <AccessApiKeysGrid
isLoading={isLoading} items={data}
isError={isError} isLoading={isLoading}
error={error} onRotate={handleRotated}
empty={items.length === 0} onRevoke={(id) => revoke.mutate(id)}
emptyTitle="Нет ключей" rotatePending={rotate.isPending}
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа." revokePending={revoke.isPending}
skeleton={<TableSkeleton rows={4} cols={6} />} actions={
onRetry={onRetry} <>
> <Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
{(data) => ( <RefreshCw className={isLoading ? 'animate-spin' : ''} />
<AccessApiKeysGrid Обновить
items={data} </Button>
isLoading={isLoading} <Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
onRotate={handleRotated} <Plus />
onRevoke={(id) => revoke.mutate(id)} Создать
rotatePending={rotate.isPending} </Button>
revokePending={revoke.isPending} </>
/> }
)} />
</QueryState> )}
</FrameDataGrid> </QueryState>
<ApiKeyCreateDialog <ApiKeyCreateDialog
open={createOpen} open={createOpen}
@@ -1,20 +1,26 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react'
import { RefreshCw, Trash2 } from 'lucide-react' import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createSearchFilterField,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { formatApiKeyDate } from '@/lib/access/api-key-labels' import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api' import type { ApiKey } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
]
export function AccessApiKeysGrid({ export function AccessApiKeysGrid({
items, items,
isLoading = false, isLoading = false,
@@ -22,6 +28,7 @@ export function AccessApiKeysGrid({
onRevoke, onRevoke,
rotatePending = false, rotatePending = false,
revokePending = false, revokePending = false,
actions,
}: { }: {
items: ApiKey[] items: ApiKey[]
isLoading?: boolean isLoading?: boolean
@@ -29,16 +36,21 @@ export function AccessApiKeysGrid({
onRevoke: (id: string) => void onRevoke: (id: string) => void
rotatePending?: boolean rotatePending?: boolean
revokePending?: boolean revokePending?: boolean
actions?: ReactNode
}) { }) {
const columns = useMemo<ColumnDef<ApiKey>[]>( const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
() => [ () => [
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={KeyRound}
title={row.original.name} title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`} subtitle={`${row.original.prefix}`}
/> />
), ),
@@ -86,6 +98,7 @@ export function AccessApiKeysGrid({
{ {
id: 'actions', id: 'actions',
enableSorting: false, enableSorting: false,
size: 88,
header: () => null, header: () => null,
cell: ({ row }) => { cell: ({ row }) => {
const k = row.original const k = row.original
@@ -135,24 +148,24 @@ export function AccessApiKeysGrid({
[onRevoke, onRotate, revokePending, rotatePending], [onRevoke, onRotate, revokePending, rotatePending],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title="API-ключи"
recordCount={filteredCount} description="Полный токен показывается только при создании и ротации."
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ключей" primaryAction={actions}
tableLayout={DATA_GRID_DENSE_LAYOUT} pinLastColumn
searchValue={globalFilter} emptyState={{ title: 'Нет ключей' }}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
/> />
) )
} }
@@ -8,8 +8,12 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@evobgp/ui/components/tooltip' } from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card' import { FrameSection } from '@/components/reui-kit'
/** Chart Frame shell — chart-1 spacing, not Card.
* @see https://reui.io/preview/base/chart-1
* @see https://reui.io/docs/components/base/frame
*/
export function AnalyticsCardShell({ export function AnalyticsCardShell({
title, title,
description, description,
@@ -47,7 +51,7 @@ export function AnalyticsCardShell({
) )
return ( return (
<PanelCard <FrameSection
title={titleNode} title={titleNode}
description={description} description={description}
actions={actions} actions={actions}
@@ -57,6 +61,6 @@ export function AnalyticsCardShell({
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined} footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
> >
{children} {children}
</PanelCard> </FrameSection>
) )
} }
@@ -1,4 +1,4 @@
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
</Button> </Button>
<Button <Button
className="flex-1" className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })} onClick={() => navigate({ to: '/monitoring' })}
> >
Мониторинг Мониторинг
</Button> </Button>
@@ -1,116 +0,0 @@
"use client"
import { useState, type ComponentType } from "react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { BillingTab } from "./billing-tab"
import { SETTINGS_TAB_ITEMS } from "./data"
import { NotificationsTab } from "./notifications-tab"
import { ProfileTab } from "./profile-tab"
import { SecurityTab } from "./security-tab"
const TAB_COMPONENTS: Record<string, ComponentType> = {
profile: ProfileTab,
security: SecurityTab,
notifications: NotificationsTab,
billing: BillingTab,
}
// ── Settings Navigation ──
function SettingsNavigation({
isMobile,
activeValue,
}: {
isMobile: boolean
activeValue: string
}) {
return (
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
export function AccountSettings() {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState("profile")
return (
<div className="w-full max-w-4xl space-y-8">
{/* Header */}
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">
Account Settings
</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Update your profile, access, notifications, and billing preferences.
</p>
</header>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={isMobile ? "horizontal" : "vertical"}
className={cn("w-full gap-4 lg:gap-8")}
>
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
<div className="min-w-0 flex-1">
{SETTINGS_TAB_ITEMS.map((tab) => {
const TabComponent = TAB_COMPONENTS[tab.value]
return (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
<TabComponent />
</TabsContent>
)
})}
</div>
</Tabs>
</div>
)
}
@@ -1,180 +0,0 @@
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { BILLING_PLANS, COUNTRIES } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function BillingTab() {
const [country, setCountry] = useState("us")
const handleCountryChange = createSelectValueHandler(setCountry)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Plan and billing" description="Current subscription">
<SettingsFieldGroup
legend="Plan and billing"
description="Review your current subscription and plan options."
>
{BILLING_PLANS.map((plan, index) => (
<SettingRow
key={plan.id}
title={plan.name}
titleAddon={
plan.current ? (
<Badge variant="info-light" size="sm">
Current
</Badge>
) : null
}
description={
<>
<span className="text-foreground font-medium">
{plan.price}
</span>{" "}
/ {plan.period} · {plan.features.join(", ")}
</>
}
last={index === BILLING_PLANS.length - 1}
>
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
{plan.current ? "Manage" : "Switch"}
</Button>
</SettingRow>
))}
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Billing details"
description="Invoices and payments"
footer={<Button>Update billing</Button>}
>
<SettingsFieldGroup
legend="Billing details"
description="Manage invoice contacts and payment details."
>
<SettingRow
title="Billing email"
description="Receives invoices and renewal notices."
labelFor="settings-7-billing-email"
>
<Input
id="settings-7-billing-email"
defaultValue="[email protected]"
type="email"
/>
</SettingRow>
<SettingRow
title="Invoice profile"
description="Business details used on receipts and tax forms."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
<FieldDescription className="sr-only">
Company identity used for invoices and tax documents.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-company-name">
Company name
</FieldLabel>
<Input
id="settings-7-company-name"
defaultValue="Acme Labs"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-billing-country">
Country
</FieldLabel>
<Select value={country} onValueChange={handleCountryChange}>
<SelectTrigger
id="settings-7-billing-country"
className="w-full"
>
<SelectValue>
{getOptionLabel(COUNTRIES, country)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COUNTRIES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Tax ID"
description="Optional number used for VAT or company invoices."
labelFor="settings-7-tax-id"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>VAT</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-tax-id"
defaultValue="US-2048-ACME"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Card on file"
description="Used for monthly renewals."
last
>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-muted-foreground text-sm">
Visa ending in 4242
</span>
<Button variant="outline">Update</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -1,187 +0,0 @@
"use client"
import { type ReactNode } from "react"
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type BillingPlan = {
id: string
name: string
price: string
period: string
current: boolean
features: string[]
}
export type Session = {
id: string
device: string
browser: string
location: string
lastActive: string
current: boolean
icon: ReactNode
}
export type SettingsTabItem = {
value: string
label: string
icon: ReactNode
}
// ── Data ──
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: "profile",
label: "My Profile",
icon: (
<UserIcon aria-hidden="true" />
),
},
{
value: "security",
label: "Security",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
value: "notifications",
label: "Notifications",
icon: (
<BellIcon aria-hidden="true" />
),
},
{
value: "billing",
label: "Billing",
icon: (
<CreditCardIcon aria-hidden="true" />
),
},
]
export const TIMEZONES: SelectOption[] = [
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
{ value: "utc+0", label: "UTC+0 (London)" },
{ value: "utc+1", label: "UTC+1 (Berlin)" },
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
]
export const ROLES: SelectOption[] = [
{ value: "engineering-lead", label: "Engineering Lead" },
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
{ value: "product-manager", label: "Product Manager" },
]
export const COUNTRIES: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "uz", label: "Uzbekistan" },
]
export const TIMEOUTS: SelectOption[] = [
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]
export const QUIET_HOURS: SelectOption[] = [
{ value: "18:00", label: "6:00 PM" },
{ value: "20:00", label: "8:00 PM" },
{ value: "22:00", label: "10:00 PM" },
{ value: "23:00", label: "11:00 PM" },
]
export const QUIET_HOURS_END: SelectOption[] = [
{ value: "06:00", label: "6:00 AM" },
{ value: "08:00", label: "8:00 AM" },
{ value: "09:00", label: "9:00 AM" },
{ value: "10:00", label: "10:00 AM" },
]
export const DIGEST_CADENCE: SelectOption[] = [
{ value: "daily", label: "Daily summary" },
{ value: "weekly", label: "Weekly digest" },
{ value: "mentions", label: "Only mentions" },
]
export const RECOVERY_METHODS: SelectOption[] = [
{ value: "authenticator", label: "Authenticator first" },
{ value: "sms", label: "SMS fallback" },
{ value: "email", label: "Email fallback" },
]
export const BILLING_PLANS: BillingPlan[] = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
current: false,
features: ["1 workspace", "Basic exports", "Community support"],
},
{
id: "pro",
name: "Pro",
price: "$29",
period: "per month",
current: true,
features: ["Unlimited workspaces", "Automations", "Priority support"],
},
{
id: "team",
name: "Team",
price: "$79",
period: "per month",
current: false,
features: ["Everything in Pro", "SSO", "Shared billing"],
},
]
export const SESSIONS: Session[] = [
{
id: "sess-1",
device: "macOS",
browser: "Chrome",
location: "San Francisco, CA",
lastActive: "Active now",
current: true,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
{
id: "sess-2",
device: "iPhone",
browser: "Safari",
location: "San Francisco, CA",
lastActive: "2 hours ago",
current: false,
icon: (
<SmartphoneIcon aria-hidden="true" />
),
},
{
id: "sess-3",
device: "Windows",
browser: "Firefox",
location: "New York, NY",
lastActive: "3 days ago",
current: false,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
]
@@ -1,223 +0,0 @@
import { useState } from "react"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function NotificationsTab() {
const [autoDismiss, setAutoDismiss] = useState("10")
const [quietStart, setQuietStart] = useState("20:00")
const [quietEnd, setQuietEnd] = useState("08:00")
const [digestCadence, setDigestCadence] = useState("weekly")
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Activity alerts" description="Desktop and badge">
<SettingsFieldGroup
legend="Activity alerts"
description="Control desktop, badge, and sound alerts."
>
<SettingRow
title="Desktop notifications"
description="Show alerts for mentions and approvals."
labelFor="settings-7-desktop-notifications"
>
<Switch id="settings-7-desktop-notifications" defaultChecked />
</SettingRow>
<SettingRow
title="Quiet hours"
description="Hold non-urgent alerts outside your workday."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
<FieldDescription className="sr-only">
Define when non-urgent activity should stay muted.
</FieldDescription>
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="settings-7-quiet-start">
Start
</FieldLabel>
<Select
value={quietStart}
onValueChange={handleQuietStartChange}
>
<SelectTrigger
id="settings-7-quiet-start"
className="w-full"
>
<SelectValue>
{getOptionLabel(QUIET_HOURS, quietStart)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
<SelectTrigger id="settings-7-quiet-end" className="w-full">
<SelectValue>
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS_END.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Unread badge"
description="Display a badge when new activity arrives."
labelFor="settings-7-unread-badge"
>
<Switch id="settings-7-unread-badge" defaultChecked />
</SettingRow>
<SettingRow
title="Auto dismiss"
description="Choose how long alerts stay visible."
labelFor="settings-7-auto-dismiss"
last
>
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
<SelectValue>
{getOptionLabel(TIMEOUTS, autoDismiss)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEOUTS.map((timeout) => (
<SelectItem key={timeout.value} value={timeout.value}>
{timeout.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Email updates"
description="Inbox preferences"
footer={
<>
<Button variant="outline">Reset</Button>
<Button>Save preferences</Button>
</>
}
>
<SettingsFieldGroup
legend="Email updates"
description="Control which emails reach your inbox."
>
<SettingRow
title="Team communication"
description="Messages, approvals, and activity summaries."
labelFor="settings-7-team-communication"
>
<Switch id="settings-7-team-communication" defaultChecked />
</SettingRow>
<SettingRow
title="Product announcements"
description="Releases, improvements, and launches."
labelFor="settings-7-product-announcements"
>
<Switch id="settings-7-product-announcements" />
</SettingRow>
<SettingRow
title="Weekly digest"
description="A recap of workspaces, mentions, and tasks."
labelFor="settings-7-weekly-digest"
>
<Switch id="settings-7-weekly-digest" defaultChecked />
</SettingRow>
<SettingRow
title="Digest cadence"
description="Set how often summary emails arrive."
labelFor="settings-7-digest-cadence"
last
>
<Select
value={digestCadence}
onValueChange={handleDigestCadenceChange}
>
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
<SelectValue>
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DIGEST_CADENCE.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -1,103 +0,0 @@
"use client"
import { useState } from "react"
import { useFileUpload } from "@/hooks/use-file-upload"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
interface ProfileAvatarUploadProps {
defaultAvatar: string
alt: string
inputId?: string
}
export function ProfileAvatarUpload({
defaultAvatar,
alt,
inputId,
}: ProfileAvatarUploadProps) {
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const currentFile = files[0] ?? null
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
const previewUrl =
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
const hasPhoto = Boolean(previewUrl)
const handleCancelUpload = () => {
if (!currentFile) {
return
}
removeFile(currentFile.id)
}
const handleRemovePhoto = () => {
if (currentFile) {
removeFile(currentFile.id)
}
setRemovedCurrentPhoto(true)
}
return (
<div className="flex grow flex-wrap items-center justify-start gap-2">
{/* Actions */}
<div className="relative">
<Avatar className="size-10">
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
<AvatarFallback className="text-muted-foreground bg-muted">
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
</AvatarFallback>
</Avatar>
{currentFile ? (
<Button
variant="outline"
size="icon-xs"
onClick={handleCancelUpload}
className="absolute -top-1 -right-1 size-4 rounded-full"
aria-label={`Cancel ${currentFile.file.name}`}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<div className="relative inline-flex">
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
aria-haspopup="dialog"
>
<UploadIcon aria-hidden="true" />
{hasPhoto ? "Change" : "Upload"}
</Button>
<input
{...getInputProps({ id: inputId })}
className="sr-only"
aria-label="Upload profile image"
tabIndex={-1}
/>
</div>
{hasPhoto ? (
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
<XIcon aria-hidden="true" />
Remove
</Button>
) : null}
</div>
)
}
@@ -1,217 +0,0 @@
import { useState } from "react"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Textarea } from "@evobgp/ui/components/textarea"
import { ROLES, TIMEZONES } from "./data"
import { ProfileAvatarUpload } from "./profile-avatar-upload"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { UserIcon } from "lucide-react"
export function ProfileTab() {
const [role, setRole] = useState("engineering-lead")
const [timezone, setTimezone] = useState("utc-8")
const handleRoleChange = createSelectValueHandler(setRole)
const handleTimezoneChange = createSelectValueHandler(setTimezone)
return (
<div className="space-y-6">
<Alert variant="warning">
<UserIcon aria-hidden="true" />
<AlertTitle>Complete your profile.</AlertTitle>
<AlertDescription>
Add a photo and keep your role and timezone current.
</AlertDescription>
<AlertAction>
<Button type="button" variant="outline" size="xs">
Dismiss
</Button>
<Button type="button" size="xs">
Update
</Button>
</AlertAction>
</Alert>
{/* Card */}
<SettingsCard
title="My profile"
description="Public account details"
footer={
<>
<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>
</>
}
>
<SettingsFieldGroup
legend="Profile fields"
description="Update your personal profile details."
>
<SettingRow
title="Photo"
description="Shown in comments and mentions."
>
<ProfileAvatarUpload
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
alt="Alex Morgan"
inputId="settings-7-profile-photo"
/>
</SettingRow>
<SettingRow
title="Full name"
description="Used across the workspace."
labelFor="settings-7-full-name"
>
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
</SettingRow>
<SettingRow
title="Email address"
description="Primary sign-in email."
labelFor="settings-7-email"
titleAddon={<Badge variant="success-light">Verified</Badge>}
>
<Input
id="settings-7-email"
defaultValue="[email protected]"
type="email"
/>
</SettingRow>
<SettingRow
title="Username"
description="Visible in mentions and links."
labelFor="settings-7-username"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-username"
defaultValue="alexmorgan"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Profile details"
description="Public details shared across the workspace."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Profile details</FieldLegend>
<FieldDescription className="sr-only">
Public profile and workspace defaults.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
<Select value={role} onValueChange={handleRoleChange}>
<SelectTrigger id="settings-7-role" className="w-full">
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-timezone">
Time zone
</FieldLabel>
<Select value={timezone} onValueChange={handleTimezoneChange}>
<SelectTrigger id="settings-7-timezone" className="w-full">
<SelectValue>
{getOptionLabel(TIMEZONES, timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEZONES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-website"
defaultValue="alexmorgan.dev"
/>
</InputGroup>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Bio"
description="Short profile summary."
labelFor="settings-7-bio"
last
>
<Textarea
id="settings-7-bio"
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
rows={4}
className="min-h-24 resize-none"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -1,257 +0,0 @@
"use client"
import { Fragment, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { RECOVERY_METHODS, SESSIONS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { LogOutIcon } from "lucide-react"
export function SecurityTab() {
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard
title="Change password"
description="Access protection"
footer={<Button>Update password</Button>}
>
<SettingsFieldGroup
legend="Password settings"
description="Update your password and keep your account protected."
>
<SettingRow
title="Current password"
description="Verify your identity before making changes."
labelFor="settings-7-current-password"
>
<Input
id="settings-7-current-password"
type="password"
placeholder="Enter current password"
/>
</SettingRow>
<SettingRow
title="New password"
description="Use at least 12 characters and a unique phrase."
contentClassName="@md/field-group:w-[22rem]"
last
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">New password</FieldLegend>
<FieldDescription className="sr-only">
Create and confirm the next password for this account.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-new-password">
New password
</FieldLabel>
<Input
id="settings-7-new-password"
type="password"
placeholder="Create a new password"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-confirm-password">
Confirm password
</FieldLabel>
<Input
id="settings-7-confirm-password"
type="password"
placeholder="Confirm the new password"
/>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Two-step verification"
description="Extra sign-in checks"
>
<SettingsFieldGroup
legend="Two-step verification"
description="Add backup checks for future sign-ins."
>
<SettingRow
title="Authenticator app"
description="Use one-time codes from an app."
labelFor="settings-7-authenticator-app"
titleAddon={
<Badge variant="info-light" size="sm">
Recommended
</Badge>
}
>
<Switch id="settings-7-authenticator-app" defaultChecked />
</SettingRow>
<SettingRow
title="Recovery phone"
description="Used if you lose access to your authenticator app."
labelFor="settings-7-recovery-phone"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>+1</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-recovery-phone"
defaultValue="415 555 0148"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Delivery method"
description="Choose how backup verification requests are delivered."
labelFor="settings-7-recovery-method"
>
<Select
value={recoveryMethod}
onValueChange={handleRecoveryMethodChange}
>
<SelectTrigger id="settings-7-recovery-method" className="w-full">
<SelectValue>
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RECOVERY_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
title="Trusted devices"
description="Skip repeat prompts on known devices."
labelFor="settings-7-trusted-devices"
last
>
<Switch id="settings-7-trusted-devices" defaultChecked />
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard title="Active sessions" description="Signed-in devices">
<ItemGroup className="gap-0">
{SESSIONS.map((session, index) => (
<Fragment key={session.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{session.icon}
</Item>
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
{session.browser} on {session.device}
{session.current ? (
<Badge variant="success-light" size="xs">
Current
</Badge>
) : null}
</ItemTitle>
<ItemDescription className="leading-5">
{session.location} · {session.lastActive}
</ItemDescription>
</ItemContent>
<ItemActions className="w-28 shrink-0 justify-end self-center">
{session.current ? (
<Button variant="outline" size="sm">
This device
</Button>
) : (
<Button variant="ghost" size="sm">
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
Revoke
</Button>
)}
</ItemActions>
</Item>
</Fragment>
))}
</ItemGroup>
</SettingsCard>
<SettingsCard title="Delete account" description="Irreversible changes">
<SettingsFieldGroup
legend="Delete account"
description="Review irreversible actions before deleting your account."
>
<SettingRow
title="Close account"
description="Permanently remove your account, sessions, and recovery settings."
last
contentClassName="@md/field-group:w-[22rem]"
>
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
<p className="text-muted-foreground text-xs leading-5">
This action cannot be undone.
</p>
<Button variant="destructive">Delete account</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -1,2 +0,0 @@
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
@@ -1,2 +0,0 @@
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'
@@ -1,2 +0,0 @@
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
@@ -1,22 +0,0 @@
"use client"
import { type Dispatch, type SetStateAction } from "react"
import type { SelectOption } from "./data"
export function getOptionLabel<T extends SelectOption>(
options: T[],
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
export function createSelectValueHandler(
setValue: Dispatch<SetStateAction<string>>
) {
return (value: string | null) => {
if (value !== null) {
setValue(value)
}
}
}
@@ -1,9 +0,0 @@
import { AccountSettings } from "./components/account-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
<AccountSettings />
</div>
)
}
+1 -50
View File
@@ -1,50 +1 @@
import type { ComponentProps, ReactNode } from 'react' export { CategoryBadge, ModeBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
@@ -1,50 +0,0 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -1,102 +0,0 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@evobgp/ui/lib/utils'
import { recentPlatformActivity } from '@/lib/metrics'
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
const KIND_META = {
job: { icon: Info, className: 'text-info' },
revision: { icon: CheckCircle, className: 'text-success' },
network: { icon: AlertTriangle, className: 'text-warning' },
} as const
function statusBadgeVariant(status: string) {
const s = status.toLowerCase()
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
return 'outline' as const
}
export function DashboardActivityTimeline({
jobs,
revisions,
peers,
speakers,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
return (
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
) : items.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
) : (
<div className="px-4 py-4">
<Timeline defaultValue={items.length}>
{items.map((item, index) => {
const meta = KIND_META[item.kind]
const Icon = meta.icon
return (
<TimelineItem
key={item.id}
step={index + 1}
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
>
<TimelineHeader>
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
<span
className={cn(
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
<Badge variant={statusBadgeVariant(item.status)} size="sm">
{item.statusLabel ?? item.status}
</Badge>
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</div>
)}
</DashboardFramePanel>
)
}
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react'
import {
PanelCard,
panelCardContentFlushClassName,
} from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
export function DashboardFramePanel({
title,
description,
actions,
children,
className,
contentClassName,
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={cn('h-full', className)}
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
>
{children}
</PanelCard>
)
}
@@ -3,12 +3,10 @@ import {
Boxes, Boxes,
ListChecks, ListChecks,
Network, Network,
ServerCog,
Share2,
} from 'lucide-react' } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview' import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
type KpiCard = KpiStatItem & { icon: ReactNode } type KpiCard = KpiStatItem & { icon: ReactNode }
function ratioPercent(part: number, total: number): number | undefined {
if (total <= 0) return undefined
return Math.round((part / total) * 100)
}
function buildKpis({ function buildKpis({
modules, modules,
peers, peers,
@@ -40,17 +43,34 @@ function buildKpis({
).length ).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline) const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
const disabledModules = Math.max(0, modules.length - enabledModules)
return [ return [
{ {
id: 'modules', id: 'modules',
icon: <Boxes aria-hidden />, icon: <Boxes aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`, value: loading ? '—' : enabledModules,
label: 'Модули активны', label: 'Модули активны',
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
footer: ( footer: (
<Badge variant="primary-light" size="sm"> <Badge
{loading ? '…' : `${modules.length} всего`} variant={
loading || modules.length === 0
? 'outline'
: disabledModules === 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading
? '…'
: modules.length === 0
? 'нет модулей'
: disabledModules === 0
? 'все активны'
: `${disabledModules} выкл`}
</Badge> </Badge>
), ),
}, },
@@ -60,6 +80,7 @@ function buildKpis({
iconClassName: 'text-info', iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`, value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность', label: 'BGP готовность',
progress: loading || bgpPct === null ? undefined : bgpPct,
footer: ( footer: (
<Badge <Badge
variant={ variant={
@@ -73,34 +94,9 @@ function buildKpis({
> >
{loading || bgpPct === null {loading || bgpPct === null
? 'нет включённых пиров' ? 'нет включённых пиров'
: `${network.peersEstablished} установлено`} : bgpPct >= 90
</Badge> ? 'сессии в норме'
), : `${network.peersEstablished} установлено`}
},
{
id: 'peers',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
</Badge>
),
},
{
id: 'speakers',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
}, },
@@ -112,7 +108,7 @@ function buildKpis({
label: 'Активные задачи', label: 'Активные задачи',
footer: ( footer: (
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm"> <Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`} {loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
</Badge> </Badge>
), ),
}, },
@@ -1,333 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { Link, useNavigate } from '@tanstack/react-router'
import {
ArrowUpDownIcon,
BoxesIcon,
ChevronDownIcon,
FilterIcon,
PlusIcon,
SearchIcon,
XIcon,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableHeader,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: false }]
}
export function DashboardModulesGrid({
modules,
isLoading = false,
}: {
modules: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sortBy, setSortBy] = useState<ModuleSort>('name')
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
const filteredModules = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
q.length === 0 ||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
const matchesEnabled =
enabledFilter === 'all' ||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
return matchesSearch && matchesEnabled
})
}, [modules, searchQuery, enabledFilter])
const resetPagination = useCallback(() => {
setPagination((current) => ({ ...current, pageIndex: 0 }))
}, [])
const handleSortChange = useCallback(
(value: ModuleSort) => {
setSortBy(value)
setSorting(buildSorting(value))
resetPagination()
},
[resetPagination],
)
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" />
</div>
),
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
id: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
id: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
meta: { headerTitle: 'Приоритет' },
},
],
[],
)
const table = useReactTable({
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
return (
<DashboardFramePanel
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
}
>
<DataGrid
table={table}
recordCount={filteredModules.length}
isLoading={isLoading}
emptyMessage={EMPTY_MESSAGE}
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'auto',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
resetPagination()
}}
placeholder="Поиск модулей…"
aria-label="Поиск модулей"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Очистить поиск"
onClick={() => {
setSearchQuery('')
resetPagination()
}}
>
<XIcon className="size-4" aria-hidden />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<div className="flex flex-wrap items-center gap-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
{sortLabels[value]}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<FilterIcon data-icon="inline-start" aria-hidden />
Фильтры
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'enabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'enabled' : 'all')
resetPagination()
}}
>
Только включённые
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'disabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'disabled' : 'all')
resetPagination()
}}
>
Только выключенные
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setEnabledFilter('all')
resetPagination()
}}
>
Сбросить фильтры
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{filteredModules.length > 0 ? (
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
) : (
<>
<DataGridScrollArea>
<DataGridTableHeader />
</DataGridScrollArea>
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
{EMPTY_MESSAGE}
</div>
</>
)}
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
{...DATA_GRID_PAGINATION_RU}
sizes={[10, 15, 20]}
className="py-0"
/>
) : (
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
)}
</div>
</div>
</DataGrid>
</DashboardFramePanel>
)
}
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети', label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization, percent: loading ? 0 : utilization,
badge: ( badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]"> <Badge
{loading ? '…' : `${established}/${total}`} variant={
loading || total === 0
? 'outline'
: offline === 0
? 'success-light'
: 'warning-light'
}
size="sm"
radius="full"
>
{loading
? '…'
: total === 0
? 'нет данных'
: offline === 0
? mode === 'peers'
? 'все установлены'
: 'все в сети'
: mode === 'peers'
? `${offline} не установлены`
: `${offline} офлайн`}
</Badge> </Badge>
), ),
}} }}
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
title: 'Мониторинг', title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.', description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring', to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />, icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive', iconClassName: 'text-destructive',
}, },
@@ -1,74 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { jobKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api'
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '')
: ''
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
},
getRowId: (row) => row.job_id,
pageSize: 8,
})
return (
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
/>
)
}
@@ -1,63 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}`} accent="mono" />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
const { table, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => row.id,
getRowId: (row) => row.id,
pageSize: 8,
})
return (
<DataGridShell
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
/>
)
}
+35 -14
View File
@@ -1,30 +1,38 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
const ACCENT_CLASS = { /**
primary: 'font-medium text-primary', * Name cell DNA — IconTile elevated size-10.5 + truncate.
default: 'font-medium text-foreground', * Preview: https://reui.io/preview/base/stats-12
mono: 'font-mono text-sm text-primary', * Docs: https://reui.io/docs/components/base/icon-tile
} as const */
export function DataGridNameCell({
export function DataGridPrimaryCell({ icon: Icon,
title, title,
subtitle, subtitle,
accent = 'default', iconClassName = 'text-muted-foreground',
className, className,
}: { }: {
icon: LucideIcon
title: ReactNode title: ReactNode
subtitle?: ReactNode subtitle?: ReactNode
accent?: keyof typeof ACCENT_CLASS iconClassName?: string
className?: string className?: string
}) { }) {
return ( return (
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}> <div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span> <IconTile variant="elevated" className="size-10.5">
{subtitle ? ( <Icon className={iconClassName} aria-hidden />
<span className="truncate text-xs text-muted-foreground">{subtitle}</span> </IconTile>
) : null} <div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
</div> </div>
) )
} }
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
</span> </span>
) )
} }
/** Mono / secondary cell without semantic primary color. */
export function DataGridMonoCell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<span className={cn('truncate font-mono text-sm', className)}>{children}</span>
)
}
-125
View File
@@ -1,125 +0,0 @@
import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import {
panelCardContentFlushClassName,
panelCardFooterClassName,
} from '@/components/panel-card'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { FrameFooter } from '@/components/reui/frame'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
DATA_GRID_TABLE_CLASS_NAMES,
DATA_GRID_TABLE_LAYOUT,
} from '@/lib/data-grid-defaults'
interface DataGridShellProps<TData extends object> {
table: Table<TData>
recordCount: number
isLoading?: boolean
emptyMessage?: ReactNode
showPagination?: boolean
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
className?: string
onRowClick?: (row: TData) => void
}
export function DataGridShell<TData extends object>({
table,
recordCount,
isLoading = false,
emptyMessage,
showPagination = true,
tableLayout = DATA_GRID_TABLE_LAYOUT,
className,
onRowClick,
}: DataGridShellProps<TData>) {
return (
<DataGrid
table={table}
recordCount={recordCount}
isLoading={isLoading}
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={cn('min-w-0', className)}
onRowClick={onRowClick}
>
<DataGridContainer border={false}>
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<FrameFooter className={cn(panelCardFooterClassName)}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</FrameFooter>
) : null}
</DataGrid>
)
}
/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
export function DataGridCard({
title,
description,
actions,
children,
className,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
}) {
return (
<FrameDataGrid
title={title}
description={description}
actions={actions}
className={className}
>
<div className={panelCardContentFlushClassName}>{children}</div>
</FrameDataGrid>
)
}
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
toolbarFilters?: ReactNode
toolbarActions?: ReactNode
beforeGrid?: ReactNode
}
export function DataGridSection<TData extends object>({
searchValue,
onSearchChange,
searchPlaceholder,
toolbarFilters,
toolbarActions,
beforeGrid,
...shellProps
}: DataGridSectionProps<TData>) {
return (
<>
<DataGridToolbar
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filters={toolbarFilters}
actions={toolbarActions}
/>
{beforeGrid}
<DataGridShell {...shellProps} />
</>
)
}
@@ -1,77 +0,0 @@
import type { ReactNode } from 'react'
import { Field } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
import { panelCardInsetClassName } from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search + optional ReUI Filters row for Frame data grids. */
export function DataGridToolbar({
searchValue,
onSearchChange,
searchPlaceholder = 'Поиск…',
filters,
actions,
className,
}: DataGridToolbarProps) {
return (
<div className={cn('flex flex-wrap items-center gap-3 border-b', panelCardInsetClassName, className)}>
<Field className="min-w-[200px] flex-1">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchPlaceholder}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchValue.length > 0 ? (
<InputGroupButton
aria-label="Очистить поиск"
size="icon-xs"
variant="ghost"
onClick={() => onSearchChange('')}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
{filters ? (
filters
) : (
<InputGroupButton
aria-label="Фильтры"
size="icon-xs"
variant="ghost"
disabled
title="Передайте filters={<Filters … />} из @/components/reui/filters"
>
<ListFilterIcon aria-hidden="true" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -1,39 +1,52 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react'
import { useMemo } from 'react' import { Tags } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions' import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createSearchFilterField,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import type { BgpCommunity } from '@/types/api' import type { BgpCommunity } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск community…'),
]
export function DirectoriesCommunitiesGrid({ export function DirectoriesCommunitiesGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false, canWrite = false,
onEdit, onEdit,
actions,
}: { }: {
items: BgpCommunity[] items: BgpCommunity[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean canWrite?: boolean
onEdit?: (row: BgpCommunity) => void onEdit?: (row: BgpCommunity) => void
actions?: ReactNode
}) { }) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => { const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
const cols: ColumnDef<BgpCommunity>[] = [ createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<BgpCommunity>[]>(() => {
const cols: DataGridColumnDef<BgpCommunity>[] = [
{ {
accessorKey: 'title', accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />, cell: ({ row }) => <DataGridNameCell icon={Tags} title={row.original.title} />,
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
}, },
{ {
accessorKey: 'community', accessorKey: 'community',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => ( cell: ({ row }) => <DataGridMonoCell>{row.original.community}</DataGridMonoCell>,
<DataGridPrimaryCell title={row.original.community} accent="mono" />
),
meta: { headerTitle: 'Значение' }, meta: { headerTitle: 'Значение' },
}, },
{ {
@@ -51,9 +64,7 @@ export function DirectoriesCommunitiesGrid({
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
header: () => <span className="sr-only">Действия</span>, header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => ( cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' }, meta: { headerTitle: 'Действия' },
}) })
} }
@@ -61,22 +72,22 @@ export function DirectoriesCommunitiesGrid({
return cols return cols
}, [canWrite, onEdit]) }, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.title} ${row.community}`,
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title="BGP community"
recordCount={filteredCount} description="Теги для префиксов в фильтрах BIRD"
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(row) => `${row.title} ${row.community}`}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет community" primaryAction={actions}
searchValue={globalFilter} pinLastColumn={Boolean(canWrite && onEdit)}
onSearchChange={setGlobalFilter} emptyState={{ title: 'Нет community', action: actions }}
searchPlaceholder="Поиск community…"
/> />
) )
} }
@@ -1,36 +1,51 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react'
import { useMemo } from 'react' import { Globe } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions' import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createSearchFilterField,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import type { DohProfile } from '@/types/api' import type { DohProfile } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
]
export function DirectoriesDohGrid({ export function DirectoriesDohGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false, canWrite = false,
onEdit, onEdit,
actions,
}: { }: {
items: DohProfile[] items: DohProfile[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean canWrite?: boolean
onEdit?: (row: DohProfile) => void onEdit?: (row: DohProfile) => void
actions?: ReactNode
}) { }) {
const columns = useMemo<ColumnDef<DohProfile>[]>(() => { const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
const cols: ColumnDef<DohProfile>[] = [ createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<DohProfile>[]>(() => {
const cols: DataGridColumnDef<DohProfile>[] = [
{ {
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.url, accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={Globe}
title={row.original.name ?? row.original.url} title={row.original.name ?? row.original.url}
subtitle={row.original.name ? row.original.url : undefined} subtitle={row.original.name ? row.original.url : undefined}
accent="primary"
/> />
), ),
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
@@ -38,7 +53,7 @@ export function DirectoriesDohGrid({
{ {
accessorKey: 'url', accessorKey: 'url',
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />, header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />, cell: ({ row }) => <DataGridMonoCell>{row.original.url}</DataGridMonoCell>,
meta: { headerTitle: 'URL' }, meta: { headerTitle: 'URL' },
}, },
{ {
@@ -56,9 +71,7 @@ export function DirectoriesDohGrid({
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
header: () => <span className="sr-only">Действия</span>, header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => ( cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' }, meta: { headerTitle: 'Действия' },
}) })
} }
@@ -66,22 +79,22 @@ export function DirectoriesDohGrid({
return cols return cols
}, [canWrite, onEdit]) }, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title="DoH профили"
recordCount={filteredCount} description="Резолверы DNS-over-HTTPS для доменных модулей"
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(row) => `${row.name ?? ''} ${row.url}`}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет DoH профилей" primaryAction={actions}
searchValue={globalFilter} pinLastColumn={Boolean(canWrite && onEdit)}
onSearchChange={setGlobalFilter} emptyState={{ title: 'Нет DoH профилей', action: actions }}
searchPlaceholder="Поиск DoH профилей…"
/> />
) )
} }
+19 -2
View File
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack' import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -25,6 +29,8 @@ interface EmptyStateProps {
className?: string className?: string
stackedIcon?: boolean stackedIcon?: boolean
centered?: boolean centered?: boolean
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
framed?: boolean
} }
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon { function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
@@ -44,15 +50,16 @@ export function EmptyState({
className, className,
stackedIcon = true, stackedIcon = true,
centered = true, centered = true,
framed = false,
}: EmptyStateProps) { }: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = ( const empty = (
<Empty <Empty
className={cn( className={cn(
'max-w-md flex-none border-0 bg-transparent p-0', 'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className, !centered && !framed && className,
)} )}
> >
<EmptyHeader className="gap-5 text-center"> <EmptyHeader className="gap-5 text-center">
@@ -88,6 +95,16 @@ export function EmptyState({
</Empty> </Empty>
) )
const body = framed ? (
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{empty}
</FramePanel>
</Frame>
) : (
empty
)
if (!centered) return body if (!centered) return body
return ( return (
@@ -1,107 +0,0 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -1,19 +0,0 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -1,129 +0,0 @@
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import { Input } from "@evobgp/ui/components/input"
import { Label } from "@evobgp/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="[email protected]"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="space-y-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,46 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,61 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+13 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { FieldGroup } from '@evobgp/ui/components/field'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -20,7 +22,11 @@ interface FormDrawerProps {
className?: string className?: string
} }
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */ /**
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
* @see https://reui.io/preview/base/sheet-8
* @see https://reui.io/preview/base/form-7
*/
export function FormDrawer({ export function FormDrawer({
open, open,
onOpenChange, onOpenChange,
@@ -34,13 +40,17 @@ export function FormDrawer({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)} className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
> >
<SheetHeader className="shrink-0 border-b border-border/50"> <SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null} {description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader> </SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div> <div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<FieldGroup className="p-4">{children}</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50"> <SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end"> <div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer} {footer}
-14
View File
@@ -1,14 +0,0 @@
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
+1 -1
View File
@@ -89,7 +89,7 @@ const NAV_GROUPS: NavGroup[] = [
items: [ items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } }, { to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' }, { to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } }, { to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
], ],
}, },
{ {
+5 -6
View File
@@ -1,18 +1,17 @@
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react' import { Spinner } from '@evobgp/ui/components/spinner'
import type { ButtonHTMLAttributes, ReactNode } from 'react' import type { ComponentProps, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { type LoadingButtonProps = ComponentProps<typeof Button> & {
loading?: boolean loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode children: ReactNode
} }
/** SoT for async actions — Button + Spinner. */
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) { export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return ( return (
<Button disabled={disabled || loading} {...props}> <Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null} {loading ? <Spinner data-icon="inline-start" /> : null}
{children} {children}
</Button> </Button>
) )
@@ -5,14 +5,20 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field' import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { CommunitySelect } from '@/components/modules/community-select'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { import {
Alert, Alert,
AlertDescription, AlertDescription,
AlertTitle, AlertTitle,
} from '@/components/reui/alert' } from '@/components/reui/alert'
import { SelectMenu } from '@/components/select-field' import {
Cascader,
CascaderContent,
CascaderPanel,
CascaderTrigger,
} from '@/components/reui/cascader/cascader'
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
import type { import type {
BgpCommunity, BgpCommunity,
@@ -22,8 +28,10 @@ import type {
} from '@/types/api' } from '@/types/api'
/** /**
* Lookup wizard step 3 — module + community (bare content for single Frame). * Lookup wizard step 3 — cascader module community (wizard-2).
* @see https://reui.io/preview/base/wizard-2 * @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/cascader
* @see https://reui.io/docs/components/base/stepper
*/ */
function hostPrefixFromIp(ip: string): string { function hostPrefixFromIp(ip: string): string {
@@ -34,6 +42,31 @@ function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES' return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
} }
type LookupPick = { kind: 'module' } | { kind: 'community'; communityId: string }
function encodeModuleValue(moduleId: string): string {
return `m:${moduleId}`
}
function encodeCommunityValue(moduleId: string, communityId: string): string {
return `c:${moduleId}:${communityId}`
}
function parsePick(
value: string,
): { moduleId: string; communityId: string | null } | null {
if (value.startsWith('m:')) {
return { moduleId: value.slice(2), communityId: null }
}
if (value.startsWith('c:')) {
const rest = value.slice(2)
const sep = rest.indexOf(':')
if (sep <= 0) return null
return { moduleId: rest.slice(0, sep), communityId: rest.slice(sep + 1) }
}
return null
}
export function LookupAddStep({ export function LookupAddStep({
data, data,
modules, modules,
@@ -48,49 +81,58 @@ export function LookupAddStep({
onAdded: () => void | Promise<void> onAdded: () => void | Promise<void>
}) { }) {
const wantedType = moduleTypeForKind(data.query_kind) const wantedType = moduleTypeForKind(data.query_kind)
const allowEmptyCommunity = data.query_kind === 'domain'
const eligible = useMemo( const eligible = useMemo(
() => modules.filter((m) => m.type === wantedType), () => modules.filter((m) => m.type === wantedType),
[modules, wantedType], [modules, wantedType],
) )
const [moduleId, setModuleId] = useState('') const [pick, setPick] = useState('')
const [communityId, setCommunityId] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const cascaderItems = useMemo<CascaderNode<LookupPick>[]>(
() =>
eligible.map((mod) => ({
value: encodeModuleValue(mod.id),
label: mod.name,
description: mod.id,
data: { kind: 'module' },
children: communities.map((community) => ({
value: encodeCommunityValue(mod.id, community.id),
label: community.title,
description: community.community,
data: { kind: 'community', communityId: community.id },
})),
})),
[eligible, communities],
)
useEffect(() => { useEffect(() => {
if (eligible.length === 0) { if (eligible.length === 0) {
setModuleId('') setPick('')
return return
} }
setModuleId((prev) => const first = eligible[0]!
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id, const defaultCommunity = first.default_community_id
) setPick((prev) => {
if (prev && parsePick(prev)?.moduleId && eligible.some((m) => m.id === parsePick(prev)?.moduleId)) {
return prev
}
if (defaultCommunity) return encodeCommunityValue(first.id, defaultCommunity)
return encodeModuleValue(first.id)
})
}, [eligible]) }, [eligible])
useEffect(() => { const parsed = pick ? parsePick(pick) : null
const mod = eligible.find((m) => m.id === moduleId) const moduleId = parsed?.moduleId ?? ''
if (!mod) { const communityId = parsed?.communityId ?? null
setCommunityId(null)
return
}
setCommunityId(mod.default_community_id ?? null)
}, [moduleId, eligible])
const moduleItems = useMemo(
() =>
eligible.map((m) => ({
value: m.id,
label: m.name,
})),
[eligible],
)
async function handleAdd() { async function handleAdd() {
if (!moduleId) { if (!moduleId) {
toast.error('Выберите модуль') toast.error('Выберите модуль')
return return
} }
if (data.query_kind !== 'domain' && !communityId) { if (!allowEmptyCommunity && !communityId) {
toast.error('Укажите community') toast.error('Укажите community')
return return
} }
@@ -151,28 +193,34 @@ export function LookupAddStep({
«{valueLabel}» отсутствует в списках. Выберите модуль и community. «{valueLabel}» отсутствует в списках. Выберите модуль и community.
</p> </p>
</div> </div>
<div className="flex flex-col gap-4"> <Field>
<Field> <FieldLabel htmlFor="lookup-add-cascader">Модуль и community</FieldLabel>
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel> <Cascader
<SelectMenu items={cascaderItems}
id="lookup-add-module" value={pick}
items={moduleItems} onValueChange={setPick}
value={moduleId} selectable={allowEmptyCommunity ? 'any' : 'leaf'}
placeholder="Выберите модуль" mode="columns"
onValueChange={(v) => { labels={{
if (v) setModuleId(v) search: 'Поиск',
}} empty: 'Ничего не найдено',
/> back: 'Назад',
</Field> rootLevel: 'Модули',
<CommunitySelect }}
id="lookup-add-comm" >
label="Community" <CascaderTrigger
value={communityId} id="lookup-add-cascader"
onValueChange={setCommunityId} className="w-full"
communities={communities} aria-label="Модуль и community"
nullable={data.query_kind === 'domain'} >
/> <CascaderValue placeholder="Выберите модуль и community" display="path" />
</div> </CascaderTrigger>
<CascaderContent>
<CascaderInput />
<CascaderPanel />
</CascaderContent>
</Cascader>
</Field>
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4"> <div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
Назад Назад
@@ -180,7 +228,7 @@ export function LookupAddStep({
<LoadingButton <LoadingButton
loading={saving} loading={saving}
onClick={() => void handleAdd()} onClick={() => void handleAdd()}
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)} disabled={!moduleId || (!allowEmptyCommunity && !communityId)}
> >
Добавить Добавить
</LoadingButton> </LoadingButton>
@@ -1,21 +1,35 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Boxes, SearchIcon } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import type { LookupMatch } from '@/types/api' import type { LookupMatch } from '@/types/api'
/** /**
* Lookup matches grid — bare section for wizard-2 single Frame. * Lookup matches grid — ResourcePage hideHeader inside wizard-2 Frame.
* @see https://reui.io/preview/base/wizard-2 * @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/preview/base/data-grid-filtering-2 * @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/badge * @see https://reui.io/docs/components/base/badge
*/ */
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Фильтр совпадений…',
},
]
export function LookupMatchesGrid({ export function LookupMatchesGrid({
items, items,
isLoading = false, isLoading = false,
@@ -24,8 +38,11 @@ export function LookupMatchesGrid({
isLoading?: boolean isLoading?: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<ColumnDef<LookupMatch>[]>( const columns = useMemo<DataGridColumnDef<LookupMatch>[]>(
() => [ () => [
{ {
accessorKey: 'layer', accessorKey: 'layer',
@@ -44,10 +61,10 @@ export function LookupMatchesGrid({
accessorKey: 'module_name', accessorKey: 'module_name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={Boxes}
title={row.original.module_name} title={row.original.module_name}
subtitle={row.original.module_type} subtitle={row.original.module_type}
accent="primary"
/> />
), ),
meta: { headerTitle: 'Модуль' }, meta: { headerTitle: 'Модуль' },
@@ -56,15 +73,14 @@ export function LookupMatchesGrid({
accessorKey: 'matched_value', accessorKey: 'matched_value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <div className="flex min-w-0 flex-col gap-0.5">
title={row.original.matched_value} <DataGridMonoCell>{row.original.matched_value}</DataGridMonoCell>
subtitle={ <DataGridMutedCell>
row.original.resolved_ip {row.original.resolved_ip
? `${row.original.match_kind} · via ${row.original.resolved_ip}` ? `${row.original.match_kind} · via ${row.original.resolved_ip}`
: row.original.match_kind : row.original.match_kind}
} </DataGridMutedCell>
accent="mono" </div>
/>
), ),
meta: { headerTitle: 'Совпадение' }, meta: { headerTitle: 'Совпадение' },
}, },
@@ -76,13 +92,15 @@ export function LookupMatchesGrid({
const title = row.original.community_title?.trim() const title = row.original.community_title?.trim()
const value = row.original.community?.trim() const value = row.original.community?.trim()
if (!title && !value) { if (!title && !value) {
return <span className="text-muted-foreground text-sm"></span> return <DataGridMutedCell></DataGridMutedCell>
} }
return ( return (
<DataGridPrimaryCell <div className="flex min-w-0 flex-col gap-0.5">
title={title || value || '—'} <DataGridMonoCell>{title || value || '—'}</DataGridMonoCell>
subtitle={title && value && title !== value ? value : undefined} {title && value && title !== value ? (
/> <DataGridMutedCell>{value}</DataGridMutedCell>
) : null}
</div>
) )
}, },
meta: { headerTitle: 'Community' }, meta: { headerTitle: 'Community' },
@@ -95,7 +113,7 @@ export function LookupMatchesGrid({
row.original.source ? ( row.original.source ? (
<CategoryBadge>{row.original.source}</CategoryBadge> <CategoryBadge>{row.original.source}</CategoryBadge>
) : ( ) : (
<span className="text-muted-foreground text-sm"></span> <DataGridMutedCell></DataGridMutedCell>
), ),
meta: { headerTitle: 'Источник' }, meta: { headerTitle: 'Источник' },
}, },
@@ -103,35 +121,29 @@ export function LookupMatchesGrid({
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
getRowId: (row) =>
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
})
return ( return (
<div className="flex flex-col gap-3"> <ResourcePage
<div className="flex flex-col gap-px"> title="Совпадения"
<h3 className="text-sm font-semibold">Совпадения</h3> description="Entries и snapshots · клик по строке открывает модуль"
<p className="text-muted-foreground text-sm"> hideHeader
Entries и snapshots · клик по строке открывает модуль filterFields={filterFields}
</p> filterQuery={filterQuery}
</div> onFilterQueryChange={setFilterQuery}
<DataGridSection onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
table={table} getFilterFieldValue={(item, field) => {
recordCount={filteredCount} if (field !== 'search') return undefined
isLoading={isLoading} return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
emptyMessage="Нет совпадений" }}
searchValue={globalFilter} columns={columns}
onSearchChange={setGlobalFilter} data={items}
searchPlaceholder="Фильтр совпадений…" getRowId={(row) =>
onRowClick={(row) => `${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } }) }
} isLoading={isLoading}
/> onRowClick={(row) =>
</div> void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
emptyState={{ title: 'Нет совпадений' }}
/>
) )
} }
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox' import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer' import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
}} }}
/> />
<div className="flex flex-col gap-2"> <Field>
<Label htmlFor="mod-create-name">Название</Label> <FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
<Input <Input
id="mod-create-name" id="mod-create-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля" placeholder="Имя модуля"
/> />
</div> </Field>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"> <Field orientation="horizontal">
<div className="grid min-w-0 flex-1 gap-1 pr-2"> <div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label> <FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
<p className="text-xs text-muted-foreground"> <FieldDescription>
Выключенный модуль не участвует в обновлении и применении. Выключенный модуль не участвует в обновлении и применении.
</p> </FieldDescription>
</div> </div>
<Checkbox <Checkbox
id="mod-create-enabled" id="mod-create-enabled"
checked={enabled} checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)} onCheckedChange={(v) => setEnabled(v === true)}
/> />
</div> </Field>
<div className="flex flex-col gap-2"> <Field>
<Label htmlFor="mod-create-priority">Приоритет</Label> <FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
<Input <Input
id="mod-create-priority" id="mod-create-priority"
type="number" type="number"
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
/> />
</div> </Field>
<div className="flex flex-col gap-2"> <Field>
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label> <FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
<Input <Input
id="mod-create-interval" id="mod-create-interval"
type="number" type="number"
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
value={refreshIntervalSec} value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)} onChange={(e) => setRefreshIntervalSec(e.target.value)}
/> />
</div> </Field>
<div className="flex flex-col gap-2"> <Field>
<Label htmlFor="mod-create-cron">Cron (опционально)</Label> <FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
<Input <Input
id="mod-create-cron" id="mod-create-cron"
placeholder="0 * * * *" placeholder="0 * * * *"
value={cronExpr} value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)} onChange={(e) => setCronExpr(e.target.value)}
/> />
</div> </Field>
<CommunitySelect <CommunitySelect
id="mod-create-community" id="mod-create-community"
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
}} }}
/> />
<div className="flex flex-col gap-2"> <Field>
<Label>DoH профили</Label> <FieldLabel>DoH профили</FieldLabel>
{dohProfiles.length === 0 ? ( {dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p> <p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : ( ) : (
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
})} })}
</div> </div>
)} )}
</div> </Field>
</> </>
) : null} ) : null}
</FormDrawer> </FormDrawer>
@@ -1,17 +1,20 @@
import { ColumnDef } from '@tanstack/react-table' import { Pencil, SearchIcon, Trash2 } from 'lucide-react'
import { Pencil, Trash2 } from 'lucide-react' import { useMemo, useState, type ReactNode } from 'react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridMutedCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { formatDateTime } from '@/lib/modules/display' import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers' import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels' import { cdnSourceKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { import type {
AsEntry, AsEntry,
BgpCommunity, BgpCommunity,
@@ -27,6 +30,18 @@ type DeleteTarget =
| { kind: 'cdn'; entry: CdnSource } | { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry } | { kind: 'as'; entry: AsEntry }
type EntryRow = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск записей…',
},
]
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return ( return (
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@@ -47,6 +62,12 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () =>
) )
} }
function searchText(row: EntryRow): string {
return Object.values(row as Record<string, unknown>)
.filter((value) => typeof value === 'string' || typeof value === 'number')
.join(' ')
}
export function ModuleEntriesGrid({ export function ModuleEntriesGrid({
mod, mod,
rows, rows,
@@ -54,6 +75,14 @@ export function ModuleEntriesGrid({
onEdit, onEdit,
onDelete, onDelete,
isLoading = false, isLoading = false,
isError = false,
error = null,
onRetry,
title,
description,
actions,
emptyTitle = 'Нет записей',
emptyDescription,
}: { }: {
mod: ModuleRow mod: ModuleRow
rows: Record<string, unknown>[] rows: Record<string, unknown>[]
@@ -61,198 +90,207 @@ export function ModuleEntriesGrid({
onEdit: (target: DeleteTarget) => void onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void onDelete: (target: DeleteTarget) => void
isLoading?: boolean isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
title: string
description?: string
actions?: ReactNode
emptyTitle?: string
emptyDescription?: string
}) { }) {
const columns = useMemo(() => { const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<EntryRow>[]>(() => {
if (mod.type === 'DOMAINS') { if (mod.type === 'DOMAINS') {
return [ return [
{ {
accessorKey: 'fqdn', accessorKey: 'fqdn',
header: ({ column }: { column: { id: string } }) => ( header: ({ column }) => <DataGridColumnHeader column={column} title="FQDN" />,
<DataGridColumnHeader column={column as never} title="FQDN" /> cell: ({ row }) => (
), <DataGridMonoCell>{(row.original as DomainEntry).fqdn}</DataGridMonoCell>
cell: ({ row }: { row: { original: DomainEntry } }) => (
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
), ),
meta: { headerTitle: 'FQDN' },
}, },
{ {
id: 'community', id: 'community',
header: 'Community', header: 'Community',
cell: ({ row }: { row: { original: DomainEntry } }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>
{communityLabel(row.original.community_id, communities)} {communityLabel((row.original as DomainEntry).community_id, communities)}
</span> </DataGridMutedCell>
), ),
}, },
{ {
id: 'actions', id: 'actions',
enableSorting: false, enableSorting: false,
header: () => null, header: () => null,
cell: ({ row }: { row: { original: DomainEntry } }) => ( cell: ({ row }) => (
<RowActions <RowActions
onEdit={() => onEdit({ kind: 'domain', entry: row.original })} onEdit={() => onEdit({ kind: 'domain', entry: row.original as DomainEntry })}
onDelete={() => onDelete({ kind: 'domain', entry: row.original })} onDelete={() => onDelete({ kind: 'domain', entry: row.original as DomainEntry })}
/> />
), ),
}, },
] as ColumnDef<DomainEntry>[] ]
} }
if (mod.type === 'IP_RANGES') { if (mod.type === 'IP_RANGES') {
return [ return [
{ {
accessorKey: 'prefix', accessorKey: 'prefix',
header: ({ column }: { column: { id: string } }) => ( header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс (CIDR)" />,
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" /> cell: ({ row }) => (
), <DataGridMonoCell>{(row.original as IpRangeEntry).prefix}</DataGridMonoCell>
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
), ),
meta: { headerTitle: 'Префикс (CIDR)' },
}, },
{ {
id: 'community', id: 'community',
header: 'Community', header: 'Community',
cell: ({ row }: { row: { original: IpRangeEntry } }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>
{communityLabel(row.original.community_id, communities)} {communityLabel((row.original as IpRangeEntry).community_id, communities)}
</span> </DataGridMutedCell>
), ),
}, },
{ {
id: 'actions', id: 'actions',
enableSorting: false, enableSorting: false,
header: () => null, header: () => null,
cell: ({ row }: { row: { original: IpRangeEntry } }) => ( cell: ({ row }) => (
<RowActions <RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })} onEdit={() => onEdit({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })} onDelete={() => onDelete({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
/> />
), ),
}, },
] as ColumnDef<IpRangeEntry>[] ]
} }
if (mod.type === 'CDN_CIDRS') { if (mod.type === 'CDN_CIDRS') {
return [ return [
{ {
accessorKey: 'url', accessorKey: 'url',
header: ({ column }: { column: { id: string } }) => ( header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
<DataGridColumnHeader column={column as never} title="URL" /> cell: ({ row }) => (
), <DataGridMonoCell className="max-w-xs">
cell: ({ row }: { row: { original: CdnSource } }) => ( {(row.original as CdnSource).url}
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" /> </DataGridMonoCell>
), ),
meta: { headerTitle: 'URL' },
}, },
{ {
accessorKey: 'source_kind', accessorKey: 'source_kind',
header: 'Тип', header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }) => (
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge> <CategoryBadge>{cdnSourceKindRu((row.original as CdnSource).source_kind)}</CategoryBadge>
), ),
}, },
{ {
id: 'community', id: 'community',
header: 'Community', header: 'Community',
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>
{communityLabel(row.original.community_id, communities)} {communityLabel((row.original as CdnSource).community_id, communities)}
</span> </DataGridMutedCell>
), ),
}, },
{ {
id: 'last_refreshed_at', id: 'last_refreshed_at',
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '', accessorFn: (row) => (row as CdnSource).last_refreshed_at ?? '',
header: ({ column }: { column: { id: string } }) => ( header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
<DataGridColumnHeader column={column as never} title="Обновлено" /> cell: ({ row }) => (
), <DataGridMutedCell>
cell: ({ row }: { row: { original: CdnSource } }) => ( {formatDateTime((row.original as CdnSource).last_refreshed_at)}
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Обновлено' },
}, },
{ {
id: 'actions', id: 'actions',
enableSorting: false, enableSorting: false,
header: () => null, header: () => null,
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }) => (
<RowActions <RowActions
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })} onEdit={() => onEdit({ kind: 'cdn', entry: row.original as CdnSource })}
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })} onDelete={() => onDelete({ kind: 'cdn', entry: row.original as CdnSource })}
/> />
), ),
}, },
] as ColumnDef<CdnSource>[] ]
} }
return [ return [
{ {
accessorKey: 'asn', accessorKey: 'asn',
header: ({ column }: { column: { id: string } }) => ( header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
<DataGridColumnHeader column={column as never} title="ASN" /> cell: ({ row }) => <DataGridMonoCell>{(row.original as AsEntry).asn}</DataGridMonoCell>,
), meta: { headerTitle: 'ASN' },
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.asn}</span>
),
}, },
{ {
accessorKey: 'asn_name', accessorKey: 'asn_name',
header: 'Имя', header: 'Имя',
cell: ({ row }: { row: { original: AsEntry } }) => ( cell: ({ row }) => (
<span className="text-sm">{row.original.asn_name ?? '—'}</span> <span className="text-sm">{(row.original as AsEntry).asn_name ?? '—'}</span>
), ),
}, },
{ {
accessorKey: 'prefix_count', accessorKey: 'prefix_count',
header: 'Префиксов', header: 'Префиксов',
cell: ({ row }: { row: { original: AsEntry } }) => ( cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span> <DataGridMonoCell>{(row.original as AsEntry).prefix_count ?? '—'}</DataGridMonoCell>
), ),
}, },
{ {
id: 'community', id: 'community',
header: 'Community', header: 'Community',
cell: ({ row }: { row: { original: AsEntry } }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>
{communityLabel(row.original.community_id, communities)} {communityLabel((row.original as AsEntry).community_id, communities)}
</span> </DataGridMutedCell>
), ),
}, },
{ {
id: 'actions', id: 'actions',
enableSorting: false, enableSorting: false,
header: () => null, header: () => null,
cell: ({ row }: { row: { original: AsEntry } }) => ( cell: ({ row }) => (
<RowActions <RowActions
onEdit={() => onEdit({ kind: 'as', entry: row.original })} onEdit={() => onEdit({ kind: 'as', entry: row.original as AsEntry })}
onDelete={() => onDelete({ kind: 'as', entry: row.original })} onDelete={() => onDelete({ kind: 'as', entry: row.original as AsEntry })}
/> />
), ),
}, },
] as ColumnDef<AsEntry>[] ]
}, [communities, mod.type, onDelete, onEdit]) }, [communities, mod.type, onDelete, onEdit])
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry const data = rows as unknown as EntryRow[]
const data = rows as unknown as RowType[]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns: columns as ColumnDef<RowType>[],
getSearchText: (row) => {
const r = row as Record<string, unknown>
return Object.values(r)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(' ')
},
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title={title}
recordCount={filteredCount} description={description}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item, field) => {
if (field !== 'search') return undefined
return searchText(item)
}}
columns={columns}
data={data}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет записей" isError={isError}
searchValue={globalFilter} error={error}
onSearchChange={setGlobalFilter} onRetry={onRetry}
searchPlaceholder="Поиск записей…" primaryAction={actions}
pinLastColumn
emptyState={{ title: emptyTitle, description: emptyDescription, action: actions }}
/> />
) )
} }
@@ -5,9 +5,6 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { FrameDataGrid } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid' import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog' import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog' import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
@@ -126,47 +123,37 @@ export function ModuleEntriesSection({
} }
} }
const addButton = (
<Button size="sm" type="button" onClick={openCreate}>
<Plus />
Добавить
</Button>
)
return ( return (
<> <>
<FrameDataGrid <ModuleEntriesGrid
mod={mod}
rows={items}
communities={communities}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={onRetry}
title={meta.title} title={meta.title}
description={meta.description} description={meta.description}
actions={ actions={addButton}
<Button size="sm" type="button" onClick={openCreate}> emptyTitle={meta.emptyTitle}
<Plus /> emptyDescription={meta.emptyDescription}
Добавить onEdit={(target) => {
</Button> if (target.kind === 'domain') setEditDomain(target.entry)
} if (target.kind === 'ip-range') setEditIpRange(target.entry)
> if (target.kind === 'cdn') setEditCdn(target.entry)
<QueryState if (target.kind === 'as') setEditAs(target.entry)
data={items} setDialogOpen(true)
isLoading={isLoading} }}
isError={isError} onDelete={setDeleteTarget}
error={error} />
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<ModuleEntriesGrid
mod={mod}
rows={rows}
communities={communities}
isLoading={isLoading}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</FrameDataGrid>
{mod.type === 'DOMAINS' ? ( {mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog <ModuleDomainEntryDialog
@@ -1,6 +1,6 @@
import { Clock, Gauge, Globe, Tags } from 'lucide-react' import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { KpiStatGridSkeleton } from '@/components/skeletons' import { KpiStatGridSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display' import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
@@ -1,35 +1,91 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react' import { Boxes, SearchIcon } from 'lucide-react'
import { useMemo } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge' import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
renderSingleSelectedLabel,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { moduleTypeRu } from '@/lib/ui-labels' import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
]
const TYPE_OPTIONS = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
]
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Название или тип…',
},
{
id: 'type',
label: 'Тип',
type: 'select',
searchable: false,
options: TYPE_OPTIONS,
renderValue: ({ values }) => renderSingleSelectedLabel(values, TYPE_OPTIONS),
},
]
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
switch (field) {
case 'search':
return `${item.name} ${item.type} ${moduleTypeRu(item.type)} ${item.enabled ? 'включён' : 'выключен'}`
case 'type':
return item.type
default:
return undefined
}
}
function tabFilter(item: ModuleRow, tabId: string): boolean {
if (tabId === 'enabled') return item.enabled !== false
if (tabId === 'disabled') return item.enabled === false
return true
}
export function ModulesListGrid({ export function ModulesListGrid({
items, items,
isLoading = false, isLoading = false,
title = 'Все модули',
actions,
}: { }: {
items: ModuleRow[] items: ModuleRow[]
isLoading?: boolean isLoading?: boolean
title?: string
actions?: ReactNode
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<ColumnDef<ModuleRow>[]>( const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
() => [ () => [
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <DataGridNameCell icon={Boxes} title={row.original.name} className="max-w-[280px]" />
<Boxes className="size-4 shrink-0 text-muted-foreground" />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
</div>
), ),
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
}, },
@@ -65,34 +121,32 @@ export function ModulesListGrid({
: '—'} : '—'}
</DataGridMutedCell> </DataGridMutedCell>
), ),
sortingFn: (a, b) => {
const av = a.original.last_refreshed_at ?? ''
const bv = b.original.last_refreshed_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Обновлено' }, meta: { headerTitle: 'Обновлено' },
}, },
], ],
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title={title}
recordCount={filteredCount} description="Маршрутные списки"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет модулей" primaryAction={actions}
searchValue={globalFilter} onRowClick={(row) =>
onSearchChange={setGlobalFilter} void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
searchPlaceholder="Поиск модулей…" }
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} emptyState={{ title: 'Нет модулей' }}
/> />
) )
} }
@@ -1,12 +1,10 @@
import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react' import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import { import {
isReadyCheckOk, isReadyCheckOk,
isSystemReady, isSystemReady,
@@ -14,8 +12,6 @@ import {
} from '@/lib/metrics' } from '@/lib/metrics'
import { readyCheckRu } from '@/lib/ui-labels' import { readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring' import type { ReadyStatus } from '@/queries/monitoring'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = { const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database, postgres: Database,
@@ -79,32 +75,19 @@ export function MonitoringReadyGrid({
return rows return rows
}, [health?.ok, ready.checks, ready.status]) }, [health?.ok, ready.checks, ready.status])
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>( const columns = useMemo<DataGridColumnDef<ReadyCheckRow>[]>(
() => [ () => [
{ {
accessorKey: 'label', accessorKey: 'label',
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
cell: ({ row }) => { cell: ({ row }) => (
const Icon = row.original.icon <DataGridNameCell
return ( icon={row.original.icon}
<div className="flex min-w-0 items-center gap-2.5 py-0.5"> title={row.original.label}
<Item subtitle={row.original.subtitle}
className={cn( iconClassName={row.original.iconClassName}
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5', />
row.original.iconClassName, ),
)}
>
<ItemMedia variant="icon" className="size-auto">
<Icon />
</ItemMedia>
</Item>
<DataGridPrimaryCell
title={row.original.label}
subtitle={row.original.subtitle}
/>
</div>
)
},
meta: { headerTitle: 'Проверка' }, meta: { headerTitle: 'Проверка' },
}, },
{ {
@@ -112,9 +95,7 @@ export function MonitoringReadyGrid({
enableSorting: false, enableSorting: false,
header: 'Статус', header: 'Статус',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-start"> <StatusBadge status={row.original.status} label={row.original.statusLabel} />
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
</div>
), ),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
@@ -122,23 +103,15 @@ export function MonitoringReadyGrid({
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
getRowId: (row) => row.id,
pageSize: 20,
})
return ( return (
<DataGridSection <FrameDataGrid
table={table} title="Доступность и готовность"
recordCount={filteredCount} description="Проверки живучести и готовности (/v1/health, /v1/ready)"
showPagination={false} columns={columns}
emptyMessage="Нет проверок" data={data}
searchValue={globalFilter} rowId={(row) => row.id}
onSearchChange={setGlobalFilter} emptyTitle="Нет проверок"
searchPlaceholder="Поиск проверок…" pagination={false}
/> />
) )
} }
@@ -1,24 +1,23 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { FormDrawer } from '@/components/form-drawer' import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field' import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import { import {
createFilter, ResourcePage,
type Filter, createTextFilterQuery,
type FilterFieldConfig, type DataGridColumnDef,
} from '@/components/reui/filters' } from '@/components/reui-kit'
import { ResourcePage } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels' import { bgpSessionStateRu } from '@/lib/ui-labels'
import { import {
useApproveDiscoveredPeerMutation, useApproveDiscoveredPeerMutation,
@@ -40,17 +39,12 @@ function speakerLabel(s: SpeakerRow): string {
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}` return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
} }
function createDefaultFilters(): Filter[] { const filterFields: FilterField[] = [
return [createFilter('neighbor', 'contains', [''])]
}
const filterFields: FilterFieldConfig[] = [
{ {
key: 'neighbor', id: 'neighbor',
label: 'Сосед', label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-48',
placeholder: 'IP или ID соседа…', placeholder: 'IP или ID соседа…',
}, },
] ]
@@ -74,7 +68,9 @@ export function NetworkDiscoveredPeersCard({
}: NetworkDiscoveredPeersCardProps) { }: NetworkDiscoveredPeersCardProps) {
const approveMutation = useApproveDiscoveredPeerMutation() const approveMutation = useApproveDiscoveredPeerMutation()
const rejectMutation = useRejectDiscoveredPeerMutation() const rejectMutation = useRejectDiscoveredPeerMutation()
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters) const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('neighbor'),
)
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null) const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null) const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -88,7 +84,7 @@ export function NetworkDiscoveredPeersCard({
[speakers], [speakers],
) )
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>( const columns = useMemo<DataGridColumnDef<PeerDiscoveryRow>[]>(
() => [ () => [
{ {
id: 'neighbor_id', id: 'neighbor_id',
@@ -97,10 +93,10 @@ export function NetworkDiscoveredPeersCard({
<DataGridColumnHeader column={column} title="ID соседа" /> <DataGridColumnHeader column={column} title="ID соседа" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={Network}
title={row.original.neighbor_id || '—'} title={row.original.neighbor_id || '—'}
subtitle={row.original.neighbor} subtitle={row.original.neighbor}
accent="mono"
/> />
), ),
meta: { headerTitle: 'ID соседа' }, meta: { headerTitle: 'ID соседа' },
@@ -109,7 +105,7 @@ export function NetworkDiscoveredPeersCard({
accessorKey: 'remote_asn', accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span> <DataGridMonoCell className="text-xs">{row.original.remote_asn || '—'}</DataGridMonoCell>
), ),
meta: { headerTitle: 'ASN' }, meta: { headerTitle: 'ASN' },
}, },
@@ -186,9 +182,9 @@ export function NetworkDiscoveredPeersCard({
title="На одобрение" title="На одобрение"
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)" description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
filterFields={filterFields} filterFields={filterFields}
filters={filters} filterQuery={filterQuery}
onFiltersChange={setFilters} onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilters(createDefaultFilters())} onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
columns={columns} columns={columns}
data={items} data={items}
@@ -197,6 +193,7 @@ export function NetworkDiscoveredPeersCard({
isError={isError} isError={isError}
error={error instanceof Error ? error : null} error={error instanceof Error ? error : null}
onRetry={onRetry} onRetry={onRetry}
pinLastColumn
emptyState={{ emptyState={{
title: 'Нет ожидающих пиров', title: 'Нет ожидающих пиров',
description: description:
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react' import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
@@ -11,12 +10,13 @@ import {
peerColumns, peerColumns,
peerTabFilter, peerTabFilter,
} from '@/components/network/network-peers-grid' } from '@/components/network/network-peers-grid'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import { import {
createFilter, ResourcePage,
type Filter, createTextFilterQuery,
type FilterFieldConfig, renderSingleSelectedLabel,
} from '@/components/reui/filters' type DataGridColumnDef,
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit' } from '@/components/reui-kit'
import { useDeletePeerMutation } from '@/queries/network' import { useDeletePeerMutation } from '@/queries/network'
import type { PeerRow, SpeakerRow } from '@/types/api' import type { PeerRow, SpeakerRow } from '@/types/api'
@@ -46,37 +46,29 @@ const SESSION_STATE_OPTIONS = [
{ value: 'OpenConfirm', label: 'Open подтверждён' }, { value: 'OpenConfirm', label: 'Open подтверждён' },
] ]
function createDefaultPeerFilters(): Filter[] { const peerFilterFields: FilterField[] = [
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{ {
key: 'name', id: 'name',
label: 'Имя', label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…', placeholder: 'Поиск по имени…',
}, },
{ {
key: 'neighbor', id: 'neighbor',
label: 'Сосед', label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…', placeholder: 'Адрес соседа…',
}, },
{ {
key: 'session_state', id: 'session_state',
label: 'Состояние', label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />, icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select', type: 'select',
searchable: false, searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS, options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) => renderValue: ({ values }) => renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
}, },
] ]
@@ -100,7 +92,7 @@ export function NetworkPeersCard({
const deleteMutation = useDeletePeerMutation() const deleteMutation = useDeletePeerMutation()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null) const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters) const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createTextFilterQuery('name'))
const addButton = useMemo( const addButton = useMemo(
() => ( () => (
@@ -112,7 +104,7 @@ export function NetworkPeersCard({
[], [],
) )
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>( const columns = useMemo<DataGridColumnDef<PeerRow>[]>(
() => [ () => [
...peerColumns, ...peerColumns,
{ {
@@ -147,9 +139,9 @@ export function NetworkPeersCard({
tabs={PEER_TABS} tabs={PEER_TABS}
tabFilter={peerTabFilter} tabFilter={peerTabFilter}
filterFields={peerFilterFields} filterFields={peerFilterFields}
filters={filters} filterQuery={filterQuery}
onFiltersChange={setFilters} onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilters(createDefaultPeerFilters())} onClearFilters={() => setFilterQuery(createTextFilterQuery('name'))}
getFilterFieldValue={getPeerFilterFieldValue} getFilterFieldValue={getPeerFilterFieldValue}
columns={columns} columns={columns}
data={items} data={items}
@@ -159,6 +151,7 @@ export function NetworkPeersCard({
error={error instanceof Error ? error : null} error={error instanceof Error ? error : null}
onRetry={onRetry} onRetry={onRetry}
primaryAction={addButton} primaryAction={addButton}
pinLastColumn
emptyState={{ emptyState={{
title: 'Нет пиров', title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.', description: 'Добавьте первого BGP-соседа.',
@@ -1,22 +1,23 @@
import type { ColumnDef } from '@tanstack/react-table' import { Share2 } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { DataGridColumnDef } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels' import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api' import type { PeerRow } from '@/types/api'
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
{ {
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.neighbor, accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={Share2}
title={row.original.name ?? row.original.neighbor} title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined} subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/> />
), ),
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
@@ -24,16 +25,14 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
{ {
accessorKey: 'neighbor', accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => ( cell: ({ row }) => <DataGridMonoCell>{row.original.neighbor}</DataGridMonoCell>,
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' }, meta: { headerTitle: 'Адрес соседа' },
}, },
{ {
accessorKey: 'remote_asn', accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span> <DataGridMonoCell className="text-xs">{row.original.remote_asn ?? '—'}</DataGridMonoCell>
), ),
meta: { headerTitle: 'ASN' }, meta: { headerTitle: 'ASN' },
}, },
@@ -58,7 +57,7 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) { switch (field) {
case 'name': case 'name':
return item.name ?? item.neighbor return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
case 'neighbor': case 'neighbor':
return item.neighbor return item.neighbor
case 'session_state': case 'session_state':
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react' import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
@@ -11,12 +10,13 @@ import {
speakerColumns, speakerColumns,
speakerTabFilter, speakerTabFilter,
} from '@/components/network/network-speakers-grid' } from '@/components/network/network-speakers-grid'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import { import {
createFilter, ResourcePage,
type Filter, createTextFilterQuery,
type FilterFieldConfig, renderSingleSelectedLabel,
} from '@/components/reui/filters' type DataGridColumnDef,
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit' } from '@/components/reui-kit'
import { useDeleteSpeakerMutation } from '@/queries/network' import { useDeleteSpeakerMutation } from '@/queries/network'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
@@ -40,29 +40,22 @@ const ROLE_OPTIONS = [
{ value: 'master', label: 'Мастер' }, { value: 'master', label: 'Мастер' },
] ]
function createDefaultSpeakerFilters(): Filter[] { const speakerFilterFields: FilterField[] = [
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{ {
key: 'endpoint', id: 'endpoint',
label: 'Конечная точка', label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-52',
placeholder: 'Адрес агента…', placeholder: 'Адрес агента…',
}, },
{ {
key: 'role', id: 'role',
label: 'Роль', label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />, icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select', type: 'select',
searchable: false, searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS, options: ROLE_OPTIONS,
customValueRenderer: (values) => renderValue: ({ values }) => renderSingleSelectedLabel(values, ROLE_OPTIONS),
renderSingleSelectedLabel(values, ROLE_OPTIONS),
}, },
] ]
@@ -84,7 +77,9 @@ export function NetworkSpeakersCard({
const deleteMutation = useDeleteSpeakerMutation() const deleteMutation = useDeleteSpeakerMutation()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null) const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters) const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('endpoint'),
)
const addButton = useMemo( const addButton = useMemo(
() => ( () => (
@@ -96,7 +91,7 @@ export function NetworkSpeakersCard({
[], [],
) )
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>( const columns = useMemo<DataGridColumnDef<SpeakerRow>[]>(
() => [ () => [
...speakerColumns, ...speakerColumns,
{ {
@@ -131,9 +126,9 @@ export function NetworkSpeakersCard({
tabs={SPEAKER_TABS} tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter} tabFilter={speakerTabFilter}
filterFields={speakerFilterFields} filterFields={speakerFilterFields}
filters={filters} filterQuery={filterQuery}
onFiltersChange={setFilters} onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())} onClearFilters={() => setFilterQuery(createTextFilterQuery('endpoint'))}
getFilterFieldValue={getSpeakerFilterFieldValue} getFilterFieldValue={getSpeakerFilterFieldValue}
columns={columns} columns={columns}
data={items} data={items}
@@ -143,6 +138,7 @@ export function NetworkSpeakersCard({
error={error instanceof Error ? error : null} error={error instanceof Error ? error : null}
onRetry={onRetry} onRetry={onRetry}
primaryAction={addButton} primaryAction={addButton}
pinLastColumn
emptyState={{ emptyState={{
title: 'Нет спикеров', title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.', description: 'Добавьте первого BIRD-агента на ноде.',
@@ -1,19 +1,24 @@
import type { ColumnDef } from '@tanstack/react-table' import { ServerCog } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { DataGridColumnDef } from '@/components/reui-kit'
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels' import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
{ {
accessorKey: 'endpoint', accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" /> <DataGridNameCell
icon={ServerCog}
title={row.original.agent_domain ?? row.original.endpoint}
subtitle={row.original.agent_domain ? row.original.endpoint : undefined}
/>
), ),
meta: { headerTitle: 'Конечная точка' }, meta: { headerTitle: 'Конечная точка' },
}, },
@@ -51,9 +56,9 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
const live = row.original.live const live = row.original.live
if (!live) return '—' if (!live) return '—'
return ( return (
<span className="text-xs"> <DataGridMonoCell className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span> </DataGridMonoCell>
) )
}, },
meta: { headerTitle: 'BGP' }, meta: { headerTitle: 'BGP' },
@@ -63,7 +68,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown { export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) { switch (field) {
case 'endpoint': case 'endpoint':
return item.endpoint return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
case 'role': case 'role':
return item.role return item.role
default: default:
@@ -1,34 +1,18 @@
import { useMemo, useState } from 'react' import { useState } from 'react'
import { LayoutGrid, Table2 } from 'lucide-react'
import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid' import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
type JobTab = 'all' | 'active' | 'failed' | 'succeeded' /**
* Jobs ResourcePage + optional Kanban board (не замена грида).
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] { * @see https://reui.io/preview/base/data-grid-filtering-2
if (tab === 'all') return items * @see https://reui.io/docs/components/base/kanban
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued') */
if (tab === 'failed')
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
return items.filter((j) => j.status === 'succeeded')
}
function tabCounts(items: JobRow[]) {
return {
all: items.length,
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.length,
succeeded: items.filter((j) => j.status === 'succeeded').length,
}
}
/** data-grid-filtering-1 style jobs card with status tabs. */
export function OperationsJobsCard({ export function OperationsJobsCard({
jobs, jobs,
nameById, nameById,
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
error: unknown error: unknown
onRetry: () => void onRetry: () => void
}) { }) {
const [tab, setTab] = useState<JobTab>('all') const [view, setView] = useState<'table' | 'board'>('table')
const counts = useMemo(() => tabCounts(jobs), [jobs])
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
return ( return (
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern"> <div className="flex flex-col gap-3">
<div className="px-5 pt-3"> <div className="flex justify-end">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full"> <ToggleGroup
<TabsList variant="line" className="w-full justify-start gap-6"> multiple={false}
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger> value={[view]}
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger> onValueChange={(values) => {
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger> const next = values[0]
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger> if (next === 'table' || next === 'board') setView(next)
</TabsList> }}
</Tabs> variant="outline"
size="sm"
aria-label="Вид задач"
className="w-fit"
>
<ToggleGroupItem value="table" aria-label="Таблица">
<Table2 className="size-4" />
Таблица
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Доска">
<LayoutGrid className="size-4" />
Доска
</ToggleGroupItem>
</ToggleGroup>
</div> </div>
<QueryState <QueryState
data={filtered} data={jobs}
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
error={error} error={error}
empty={filtered.length === 0} empty={false}
emptyTitle="Нет задач в выборке"
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={onRetry} onRetry={onRetry}
> >
{(items) => ( {(items) =>
<OperationsJobsGrid view === 'board' ? (
items={items} <OperationsJobsKanban items={items} nameById={nameById} />
nameById={nameById} ) : (
qc={qc} <OperationsJobsGrid
isLoading={isLoading && items.length > 0} items={items}
/> nameById={nameById}
)} qc={qc}
isLoading={isLoading && items.length > 0}
/>
)
}
</QueryState> </QueryState>
</FrameDataGrid> </div>
) )
} }
@@ -1,20 +1,48 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react' import { ListTodo, SearchIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels' import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
const JOB_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'active', label: 'Активные' },
{ id: 'succeeded', label: 'Успешные' },
{ id: 'failed', label: 'Ошибки' },
]
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск задач…',
},
]
function tabFilter(item: JobRow, tabId: string): boolean {
if (tabId === 'active') return item.status === 'running' || item.status === 'queued'
if (tabId === 'failed')
return ['failed', 'error', 'cancelled'].includes(item.status.toLowerCase())
if (tabId === 'succeeded') return item.status === 'succeeded'
return true
}
export function OperationsJobsGrid({ export function OperationsJobsGrid({
items, items,
nameById, nameById,
@@ -26,6 +54,9 @@ export function OperationsJobsGrid({
qc: QueryClient qc: QueryClient
isLoading?: boolean isLoading?: boolean
}) { }) {
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const cancelMutation = useMutation({ const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}), mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => { onSuccess: () => {
@@ -35,13 +66,14 @@ export function OperationsJobsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'), onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
}) })
const columns = useMemo<ColumnDef<JobRow>[]>( const columns = useMemo<DataGridColumnDef<JobRow>[]>(
() => [ () => [
{ {
accessorKey: 'kind', accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)} title={jobKindRu(row.original.kind)}
subtitle={ subtitle={
row.original.meta?.module_id row.original.meta?.module_id
@@ -112,27 +144,30 @@ export function OperationsJobsGrid({
[cancelMutation, nameById], [cancelMutation, nameById],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: ''
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
},
getRowId: (row) => row.job_id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title="Задачи"
recordCount={filteredCount} description="Фильтр по статусу"
tabs={JOB_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item, field) => {
if (field !== 'search') return undefined
const moduleName = item.meta?.module_id
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
: ''
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
}}
columns={columns}
data={items}
getRowId={(row) => row.job_id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" pinLastColumn
searchValue={globalFilter} virtualization={items.length > 80}
onSearchChange={setGlobalFilter} emptyState={{ title: 'Нет задач' }}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -0,0 +1,133 @@
import { useMemo } from 'react'
import { DataGridMutedCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanItem,
KanbanOverlay,
} from '@/components/reui/kanban'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from '@evobgp/ui/components/item'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* Operations jobs board — ReUI Kanban (read-only columns).
* @see https://reui.io/preview/base/kanban-board-8
* @see https://reui.io/docs/components/base/kanban
* @see https://reui.io/docs/components/base/frame
*/
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
queued: 'Очередь',
running: 'Выполняются',
succeeded: 'Успешные',
failed: 'Ошибки',
}
function columnForStatus(status: string): (typeof COLUMN_ORDER)[number] {
const s = status.toLowerCase()
if (s === 'queued') return 'queued'
if (s === 'running') return 'running'
if (s === 'succeeded') return 'succeeded'
return 'failed'
}
function emptyColumns(): Record<string, JobRow[]> {
return {
queued: [],
running: [],
succeeded: [],
failed: [],
}
}
export function OperationsJobsKanban({
items,
nameById,
}: {
items: JobRow[]
nameById: Map<string, string>
}) {
const columns = useMemo(() => {
const next = emptyColumns()
for (const job of items) {
next[columnForStatus(job.status)].push(job)
}
return next
}, [items])
return (
<Frame dense spacing="sm" className="w-full min-w-0">
<FramePanel className="p-4">
<Kanban
value={columns}
onValueChange={() => undefined}
getItemValue={(job) => job.job_id}
>
<KanbanBoard className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{COLUMN_ORDER.map((columnId) => (
<KanbanColumn
key={columnId}
value={columnId}
disabled
className="bg-muted/30 flex min-h-40 flex-col gap-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
<Badge variant="outline" size="sm">
{columns[columnId]?.length ?? 0}
</Badge>
</div>
<KanbanColumnContent value={columnId}>
{(columns[columnId] ?? []).map((job) => (
<KanbanItem
key={job.job_id}
value={job.job_id}
disabled
className="p-0"
>
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<ItemDescription>
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
</ItemDescription>
) : null}
</ItemContent>
</Item>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay>
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>Задача</ItemTitle>
</ItemContent>
</Item>
</KanbanOverlay>
</Kanban>
</FramePanel>
</Frame>
)
}
@@ -1,20 +1,32 @@
import { ColumnDef } from '@tanstack/react-table' import { useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react' import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
import type { RevisionRow } from '@/types/api' import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск ревизий…',
},
]
export function OperationsRevisionsGrid({ export function OperationsRevisionsGrid({
items, items,
qc, qc,
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
qc: QueryClient qc: QueryClient
isLoading?: boolean isLoading?: boolean
}) { }) {
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const rollbackMutation = useMutation({ const rollbackMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id), apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
@@ -34,14 +49,14 @@ export function OperationsRevisionsGrid({
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'), onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
}) })
const columns = useMemo<ColumnDef<RevisionRow>[]>( const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
() => [ () => [
{ {
id: 'id', id: 'id',
accessorFn: (row) => row.id, accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}`} accent="mono" /> <DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}`} />
), ),
meta: { headerTitle: 'ID' }, meta: { headerTitle: 'ID' },
}, },
@@ -88,22 +103,21 @@ export function OperationsRevisionsGrid({
[rollbackMutation], [rollbackMutation],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id,
})
return ( return (
<DataGridSection <ResourcePage
table={table} title="История ревизий"
recordCount={filteredCount} filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item) => `${item.id} ${item.materialized_prefix_count}`}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" pinLastColumn
searchValue={globalFilter} virtualization={items.length > 80}
onSearchChange={setGlobalFilter} emptyState={{ title: 'Нет ревизий' }}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
+11 -92
View File
@@ -1,92 +1,11 @@
import type { ReactNode } from 'react' export {
FrameSection,
import { FrameSection as PanelCard,
Frame, panelCardClassName,
FrameDescription, panelCardHeaderClassName,
FrameFooter, panelCardContentFlushClassName,
FrameHeader, panelCardInsetClassName,
FramePanel, panelCardFooterClassName,
FrameTitle, type FrameSectionProps,
} from '@/components/reui/frame' type FrameSectionProps as PanelCardProps,
import { cn } from '@evobgp/ui/lib/utils' } from '@/components/reui-kit/frame-section'
/**
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
* @see https://reui.io/docs/components/base/frame
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -1,24 +1,13 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack' import { EmptyState } from '@/components/empty-state'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */ /** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({ export function IllustratedEmptyState({
title, title,
description, description,
icon: Icon, icon,
action, action,
}: { }: {
title: string title: string
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
action?: ReactNode action?: ReactNode
}) { }) {
return ( return (
<Frame dense spacing="sm" className="w-full"> <EmptyState
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10"> framed
{action ? <div className="w-full self-end">{action}</div> : null} centered={false}
<Empty className="max-w-md gap-5 bg-transparent p-0"> icon={icon}
<EmptyHeader className="items-center gap-5 text-center"> title={title}
<EmptyMedia className="mb-0"> description={description}
<IconStack aria-hidden> action={action}
<Icon strokeWidth={1.9} aria-hidden /> />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</FramePanel>
</Frame>
) )
} }
+1 -1
View File
@@ -1,7 +1,7 @@
export { DonutBreakdownCard } from './donut-breakdown-card' export { DonutBreakdownCard } from './donut-breakdown-card'
export { IllustratedEmptyState } from './illustrated-empty-state' export { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card' export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid' export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
export { PanelCorners } from './panel-corners' export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state' export { ProjectsEmptyState } from './projects-empty-state'
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card' export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
@@ -1,9 +1,9 @@
import { Boxes, Plus } from 'lucide-react' import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state' import { EmptyState } from '@/components/empty-state'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */ /** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
export function ProjectsEmptyState({ export function ProjectsEmptyState({
canCreate = true, canCreate = true,
onCreate, onCreate,
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
onCreate?: () => void onCreate?: () => void
}) { }) {
return ( return (
<IllustratedEmptyState <EmptyState
framed
centered={false}
icon={Boxes} icon={Boxes}
title="Создайте первый модуль" title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны." description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
applyKitActionColumn,
KIT_ACTION_COLUMN_SIZE,
kitColumnPinning,
kitDataGridTableClassNames,
kitDataGridTableLayout,
} from './frame-data-grid'
describe('kitDataGridTableLayout', () => {
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
const layout = kitDataGridTableLayout()
expect(layout.dense).toBe(true)
expect(layout.headerBackground).toBe(false)
expect(layout.width).toBe('fixed')
expect(layout.headerSticky).toBe(false)
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
expect(layout.columnsPinnable).toBe(false)
})
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
expect(kitDataGridTableClassNames.base).toBe(
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
)
})
it('именованные opts: auto + pin', () => {
const layout = kitDataGridTableLayout({
width: 'auto',
columnsPinnable: true,
})
expect(layout.width).toBe('auto')
expect(layout.columnsPinnable).toBe(true)
expect(layout.headerBackground).toBe(false)
})
})
describe('applyKitActionColumn', () => {
it('locks filtering-2 size on id=actions', () => {
const [name, actions] = applyKitActionColumn([
{ id: 'name', header: 'Name' },
{ id: 'actions', header: () => null, cell: () => 'x' },
])
expect(name?.id).toBe('name')
expect(actions?.size).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.minSize).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
expect(actions?.enableSorting).toBe(false)
expect(actions?.enableResizing).toBe(false)
})
it('keeps explicit size (data-grid-base-7 text button)', () => {
const [actions] = applyKitActionColumn([
{ id: 'actions', size: 104, cell: () => 'x' },
])
expect(actions?.size).toBe(104)
expect(actions?.minSize).toBe(104)
expect(actions?.maxSize).toBe(104)
})
it('does not rewrite a non-actions last column', () => {
const [col] = applyKitActionColumn([{ id: 'name', header: 'Name' }])
expect(col?.size).toBeUndefined()
expect(col?.enableResizing).toBeUndefined()
})
it('applies DNA when pinLastColumn even without id=actions', () => {
const [col] = applyKitActionColumn([{ id: 'other', cell: () => 'x' }], {
pinLastColumn: true,
})
expect(col?.size).toBe(KIT_ACTION_COLUMN_SIZE)
expect(col?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
})
})
describe('kitColumnPinning', () => {
it('does not end-pin without horizontalScroll', () => {
const result = kitColumnPinning({
pinLastColumn: true,
lastColId: 'actions',
})
expect(result.enablePinning).toBe(false)
expect(result.columnPinning.end).toEqual([])
})
it('end-pins only with horizontalScroll', () => {
const result = kitColumnPinning({
pinLastColumn: true,
horizontalScroll: true,
lastColId: 'actions',
})
expect(result.enablePinning).toBe(true)
expect(result.columnPinning.end).toEqual(['actions'])
})
})
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { FrameDataGrid, type FrameDataGridProps } from './frame-data-grid'
/**
* Frame + DataGrid with expandable rows.
* Preview: https://reui.io/preview/base/components/c-data-grid-8
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function ExpandableResourceGrid<TData extends object>({
expandedContent,
getRowCanExpand,
...props
}: FrameDataGridProps<TData> & {
expandedContent: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
}) {
return (
<FrameDataGrid
{...props}
expandedContent={expandedContent}
getRowCanExpand={getRowCanExpand}
/>
)
}
+198 -44
View File
@@ -1,8 +1,26 @@
import type { Filter } from '@/components/reui/filters' import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
flattenFilterRules,
isFilterRule,
updateFilterRule,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type {
FilterField,
FilterGroupNode,
FilterNode,
FilterQuery,
} from '@/components/reui/filters/filters-types'
export function getActiveFilters(filters: Filter[]) { /** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
export function getActiveFilters(filters: FilterCondition[]) {
return filters.filter((filter) => { return filters.filter((filter) => {
const { values } = filter const { operator, values } = filter
if (VALUELESS_OPERATORS.has(operator)) return true
if (!values || values.length === 0) return false if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) { if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false return false
@@ -17,63 +35,199 @@ export function getActiveFilters(filters: Filter[]) {
}) })
} }
export function applyFiltersToData<T>( function matchesFilterCondition(
fieldValue: unknown,
operator: string,
values: unknown[],
): boolean {
switch (operator) {
case 'is':
case 'eq':
return values.includes(fieldValue)
case 'is_not':
case 'neq':
return !values.includes(fieldValue)
case 'is_any_of':
case 'has_any_of':
return values.some((value) => fieldValue === value)
case 'is_none_of':
case 'is_not_any_of':
case 'has_none_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values.map((value) => String(value).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
}
export function applyFilterConditionsToData<T>(
data: T[], data: T[],
filters: Filter[], filters: FilterCondition[],
getFieldValue: (item: T, field: string) => unknown, getFieldValue: (item: T, field: string) => unknown,
): T[] { ): T[] {
const active = getActiveFilters(filters) const active = getActiveFilters(filters)
let result = [...data] let result = [...data]
for (const filter of active) { for (const filter of active) {
const { field, operator, values } = filter const { field, operator, values, negated } = filter
result = result.filter((item) => { result = result.filter((item) => {
const raw = getFieldValue(item, field) const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : '' const fieldValue = raw != null ? raw : ''
const matches = matchesFilterCondition(fieldValue, operator, values)
switch (operator) { return negated ? !matches : matches
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
}) })
} }
return result return result
} }
export function applyFiltersToData<T>(
data: T[],
query: FilterQuery,
getFieldValue: (item: T, field: string) => unknown,
): T[] {
return applyFilterConditionsToData(data, flattenFilterConditions(query), getFieldValue)
}
export function createEmptyFilterQuery(): FilterQuery {
return createFilterQuery()
}
export function createTextFilterQuery(fieldId: string, id = `${fieldId}-1`): FilterQuery {
return createFilterQuery([
createFilterRule({
id,
path: [fieldId],
operator: 'contains',
value: '',
}),
])
}
export function createSearchFilterField(
id: string,
label: string,
placeholder: string,
): FilterField {
return {
id,
label,
type: 'text',
placeholder,
}
}
export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined {
return fields.find((field) => field.type === 'text' && !field.fields)
}
export function getExtraFilterFields(
fields: FilterField[],
primaryId?: string,
): FilterField[] {
if (!primaryId) return fields
return fields.filter((field) => field.id !== primaryId)
}
function ruleFieldId(path: string[]): string | undefined {
return path[0]
}
export function getFilterTextValue(query: FilterQuery, fieldId: string): string {
const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (rule?.value == null) return ''
if (Array.isArray(rule.value)) return rule.value.map(String).join(' ')
return String(rule.value)
}
export function setFilterTextValue(
query: FilterQuery,
fieldId: string,
text: string,
): FilterQuery {
const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (existing) {
return updateFilterRule(query, existing.id, {
operator: 'contains',
value: text,
})
}
return {
...query,
rules: [
createFilterRule({
id: `${fieldId}-1`,
path: [fieldId],
operator: 'contains',
value: text,
}),
...query.rules,
],
}
}
function mapGroupRules<V>(
group: FilterGroupNode<V>,
map: (node: FilterNode<V>) => FilterNode<V> | null,
): FilterGroupNode<V> {
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (isFilterRule(child)) {
const next = map(child)
if (next) rules.push(next)
continue
}
const nested = mapGroupRules(child, map)
if (nested.rules.length > 0) rules.push(nested)
}
return { ...group, rules }
}
export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery {
return mapGroupRules(query, (node) => {
if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null
return node
})
}
export function mergeFieldRules(
extraQuery: FilterQuery,
sourceQuery: FilterQuery,
fieldId: string,
): FilterQuery {
const searchRules = flattenFilterRules(sourceQuery).filter(
(rule) => ruleFieldId(rule.path) === fieldId,
)
const strippedExtra = stripFieldFromQuery(extraQuery, fieldId)
if (searchRules.length === 0) return strippedExtra
return {
...strippedExtra,
rules: [...searchRules, ...strippedExtra.rules],
}
}
export function renderSelectedCount(values: unknown[]) { export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…' if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано` if (values.length > 1) return `${values.length} выбрано`
@@ -1,52 +1,540 @@
import type { ReactNode } from 'react' import {
cloneElement,
isValidElement,
useEffect,
useState,
type ReactElement,
type ReactNode,
} from 'react'
import {
useTable,
type ColumnDef,
type ColumnVisibilityState,
type ExpandedState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Separator } from '@evobgp/ui/components/separator'
import { cn } from '@evobgp/ui/lib/utils'
import {
DataGrid,
dataGridFeatures,
type DataGridFeatures,
type DataGridTableInstance,
} from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableRowExpand,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameFooter,
FrameHeader, FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils' import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
/** /**
* Frame shell for list/grid sections (replaces DataGridCard Card-named API). * Единый tableLayout для всех ops-гридов.
* @see https://reui.io/preview/base/data-grid-filtering-2 * Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
* @see https://reui.io/docs/components/base/frame * Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid
*/ */
export function FrameDataGrid({ export function kitDataGridTableLayout(
opts: {
dense?: boolean
width?: 'fixed' | 'auto'
columnsPinnable?: boolean
columnsVisibility?: boolean
} = {},
) {
return {
dense: opts.dense ?? true,
rowBorder: true,
headerSticky: false,
headerBackground: false,
headerBorder: true,
width: opts.width ?? ('fixed' as const),
columnsVisibility: opts.columnsVisibility ?? false,
columnsResizable: false,
columnsPinnable: opts.columnsPinnable ?? false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}
}
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
export const kitDataGridTableClassNames = {
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
edgeCell: 'first:ps-3 last:pe-3',
} as const
/**
* Compact action column size from data-grid-filtering-2.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export const KIT_ACTION_COLUMN_SIZE = 56
const ACTION_CELL_ALIGN = 'flex items-center justify-end'
function lastColumnId<T extends object>(columns: DataGridColumnDef<T>[]): string {
const last = columns[columns.length - 1]
if (!last) return ''
if (last.id) return last.id
if ('accessorKey' in last && typeof last.accessorKey === 'string') return last.accessorKey
return ''
}
function wrapActionCell<T extends object>(
cell: DataGridColumnDef<T>['cell'],
): DataGridColumnDef<T>['cell'] {
if (typeof cell !== 'function') {
return () => <div className={ACTION_CELL_ALIGN}>{cell as ReactNode}</div>
}
return (ctx) => <div className={ACTION_CELL_ALIGN}>{cell(ctx)}</div>
}
/**
* filtering-2 action column DNA: locked width, no sort/resize, inner justify-end
* (flex on the cell wrapper, never on `td` — that breaks rowBorder alignment).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function applyKitActionColumn<T extends object>(
columns: DataGridColumnDef<T>[],
opts: { pinLastColumn?: boolean } = {},
): DataGridColumnDef<T>[] {
if (columns.length === 0) return columns
const last = columns[columns.length - 1]
const lastId = lastColumnId(columns)
if (lastId !== 'actions' && !opts.pinLastColumn) return columns
const size = last.size ?? KIT_ACTION_COLUMN_SIZE
return [
...columns.slice(0, -1),
{
...last,
size,
minSize: last.minSize ?? size,
maxSize: last.maxSize ?? size,
enableSorting: false,
enableResizing: false,
cell: last.cell ? wrapActionCell(last.cell) : last.cell,
},
]
}
/** End-pin only when the grid actually scrolls horizontally (not for action columns). */
export function kitColumnPinning(opts: {
pinLastColumn?: boolean
horizontalScroll?: boolean
lastColId: string
pinLeftColumnIds?: string[]
}): {
enablePinning: boolean
columnPinning: { start: string[]; end: string[] }
} {
const pinLeft = opts.pinLeftColumnIds ?? []
const pinEnd = Boolean(opts.pinLastColumn && opts.horizontalScroll && opts.lastColId)
return {
enablePinning: pinEnd || pinLeft.length > 0,
columnPinning: {
start: pinLeft,
end: pinEnd ? [opts.lastColId] : [],
},
}
}
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
return JSON.parse(raw) as ColumnVisibilityState
} catch {
return undefined
}
}
export { loadStoredColumnVisibility }
function applyColumnPinControls<T extends object>(
columns: DataGridColumnDef<T>[],
columnPinControls: boolean,
): DataGridColumnDef<T>[] {
return columns.map((col) => {
const origHeader = col.header
if (typeof origHeader !== 'function') return col
return {
...col,
header: (ctx) => {
const node = origHeader(ctx)
if (isValidElement(node) && node.type === DataGridColumnHeader) {
return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
pinnable: columnPinControls,
})
}
return node
},
} as DataGridColumnDef<T>
})
}
export interface FrameDataGridProps<TData extends object> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: DataGridColumnDef<TData>[]
data: TData[]
rowId?: (row: TData, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: TData) => void
pagination?: boolean
pageSize?: number
footerContent?: ReactNode
dense?: boolean
pinLastColumn?: boolean
initialSorting?: SortingState
virtualization?: boolean
height?: number
enableRowSelection?: boolean
onRowSelectionChange?: (selectedIds: string[]) => void
enableColumnVisibility?: boolean
columnVisibility?: ColumnVisibilityState
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
columnVisibilityTrigger?: boolean
columnVisibilityStorageKey?: string
initialColumnVisibility?: ColumnVisibilityState
className?: string
expandedContent?: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
pinLeftColumnIds?: string[]
horizontalScroll?: boolean
tableWidth?: 'fixed' | 'auto'
columnPinControls?: boolean
isLoading?: boolean
}
function DataGridSectionHeader({
title, title,
description, description,
actions, actions,
children,
className,
}: { }: {
title?: ReactNode title?: ReactNode
description?: ReactNode description?: ReactNode
actions?: ReactNode actions?: ReactNode
children: ReactNode
className?: string
}) { }) {
const hasHeader = Boolean(title || description || actions) if (!title && !description && !actions) return null
return ( return (
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}> <FrameHeader className="flex-row items-start justify-between gap-3">
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <div className="flex min-w-0 flex-col gap-px">
{hasHeader ? ( {title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between"> {description ? (
<div className="flex min-w-0 flex-1 flex-col gap-px"> <FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null} ) : null}
<div className="p-0">{children}</div> </div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</FrameHeader>
)
}
function FrameDataGridBody<TData extends object>({
table,
data,
emptyTitle,
onRowClick,
dense,
virtualization,
height,
footerContent,
showPagination,
enableColumnVisibility,
columnsPinnable,
tableWidth,
}: {
table: DataGridTableInstance<TData>
data: TData[]
emptyTitle: string
onRowClick?: (row: TData) => void
dense: boolean
virtualization: boolean
height: number
footerContent?: ReactNode
showPagination: boolean
enableColumnVisibility: boolean
columnsPinnable: boolean
tableWidth: 'fixed' | 'auto'
}) {
const tableNode = virtualization ? (
<DataGridTableVirtual height={height} footerContent={footerContent} />
) : (
<DataGridTable footerContent={footerContent} />
)
return (
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={kitDataGridTableLayout({
dense,
width: tableWidth,
columnsPinnable,
columnsVisibility: enableColumnVisibility,
})}
tableClassNames={kitDataGridTableClassNames}
>
{virtualization ? (
<DataGridScrollArea orientation="vertical" style={{ height }}>
{tableNode}
</DataGridScrollArea>
) : (
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
)}
{showPagination ? (
<>
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</FrameFooter>
</>
) : null}
</DataGrid>
)
}
export function FrameDataGrid<TData extends object>({
title,
description,
actions,
columns,
data,
rowId,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
pagination,
pageSize = 10,
footerContent,
dense = true,
pinLastColumn = false,
initialSorting,
virtualization = false,
height = 480,
enableRowSelection = false,
onRowSelectionChange,
enableColumnVisibility = false,
columnVisibility: columnVisibilityProp,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
expandedContent,
getRowCanExpand,
pinLeftColumnIds,
horizontalScroll = false,
tableWidth = 'fixed',
columnPinControls = false,
}: FrameDataGridProps<TData>) {
const showPagination = pagination ?? true
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [paginationState, setPaginationState] = useState<PaginationState>({
pageIndex: 0,
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<ColumnVisibilityState>(
() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined
return { ...initialColumnVisibility, ...stored }
},
)
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
const columnVisibility = isColumnVisibilityControlled
? columnVisibilityProp
: internalColumnVisibility
const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = isColumnVisibilityControlled
? (onColumnVisibilityChange ?? (() => undefined))
: setInternalColumnVisibility
useEffect(() => {
setPaginationState((current) => ({
pageIndex: showPagination ? current.pageIndex : 0,
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
}))
}, [pageSize, showPagination])
useEffect(() => {
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
const selectColumn: DataGridColumnDef<TData> = {
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
enableHiding: false,
size: 40,
meta: { cellClassName: 'w-10' },
}
const expandColumn: DataGridColumnDef<TData> = {
id: 'expand',
header: () => null,
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
enableSorting: false,
enableHiding: false,
size: 40,
meta: {
cellClassName: 'w-10',
expandedContent,
},
}
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
applyKitActionColumn(
[
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
],
{ pinLastColumn },
),
columnPinControls,
)
const { enablePinning, columnPinning } = kitColumnPinning({
pinLastColumn,
horizontalScroll,
lastColId: lastColumnId(tableColumns),
pinLeftColumnIds,
})
const table = useTable({
features: dataGridFeatures,
data,
columns: tableColumns,
state: {
sorting,
pagination: paginationState,
columnVisibility,
expanded,
...(enablePinning ? { columnPinning } : {}),
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onPaginationChange: setPaginationState,
onExpandedChange: setExpanded,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
setRowSelection((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater
if (onRowSelectionChange && rowId) {
const ids = Object.keys(next).filter((k) => next[k])
onRowSelectionChange(ids)
}
return next
})
}
: undefined,
initialState: enablePinning ? { columnPinning } : undefined,
getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined,
enableRowSelection,
enableHiding: enableColumnVisibility,
})
const showColumnVisibilityTrigger =
enableColumnVisibility && (columnVisibilityTrigger ?? true)
const columnVisibilityAction = showColumnVisibilityTrigger ? (
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="ghost" size="sm">
<Columns3Icon data-icon="inline-start" />
Колонки
</Button>
}
/>
) : null
const headerActions = actions ? (
<div className="flex items-center gap-2">
{columnVisibilityAction}
{actions}
</div>
) : (
columnVisibilityAction
)
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
if (data.length === 0) {
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</FramePanel>
</Frame>
)
}
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<FramePanel className="p-0 shadow-none!">
<FrameDataGridBody
table={table}
data={data}
emptyTitle={emptyTitle}
onRowClick={onRowClick}
dense={dense}
virtualization={virtualization}
height={height}
footerContent={footerContent}
showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility}
columnsPinnable={enablePinning}
tableWidth={tableWidth}
/>
</FramePanel> </FramePanel>
</Frame> </Frame>
) )
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison / detail panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
export interface FrameSectionProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function FrameSection({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: FrameSectionProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
+21 -1
View File
@@ -1,5 +1,9 @@
export { export {
applyFiltersToData, applyFiltersToData,
applyFilterConditionsToData,
createEmptyFilterQuery,
createSearchFilterField,
createTextFilterQuery,
getActiveFilters, getActiveFilters,
renderSelectedCount, renderSelectedCount,
renderSingleSelectedLabel, renderSingleSelectedLabel,
@@ -22,6 +26,22 @@ export {
} from './kpi-stat-grid' } from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard' export { OpsDashboard } from './ops-dashboard'
export { FrameDataGrid } from './frame-data-grid' export {
FrameDataGrid,
kitDataGridTableClassNames,
kitDataGridTableLayout,
type DataGridColumnDef,
type FrameDataGridProps,
} from './frame-data-grid'
export { ExpandableResourceGrid } from './expandable-resource-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel' export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell } from './settings-shell' export { SettingsShell } from './settings-shell'
export {
FrameSection,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
} from './frame-section'
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Progress } from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols' import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
@@ -29,6 +30,8 @@ export type KpiStatItem = {
iconClassName?: string iconClassName?: string
variant?: KpiStatVariant variant?: KpiStatVariant
footer?: ReactNode footer?: ReactNode
/** 0100 completion bar under the value (stats-4). Omit to hide. */
progress?: number
} }
/** CFDM-compatible card shape (id required). */ /** CFDM-compatible card shape (id required). */
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive', destructive: 'text-destructive',
} }
const PROGRESS_TONE_CLASS: Record<KpiStatVariant, string> = {
default: '',
warning: '[&_[data-slot=progress-indicator]]:bg-warning',
destructive: '[&_[data-slot=progress-indicator]]:bg-destructive',
}
function clampProgress(value: number): number {
if (Number.isNaN(value)) return 0
return Math.min(100, Math.max(0, value))
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) { function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault() event.preventDefault()
@@ -67,7 +81,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer if (item.footer) return item.footer
if (typeof item.hint === 'string') { if (typeof item.hint === 'string') {
return ( return (
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint} {item.hint}
</Badge> </Badge>
) )
@@ -81,30 +95,53 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default' const valueVariant = item.variant ?? 'default'
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? ( {item.icon ? (
<IconTile <IconTile
variant="elevated" variant="elevated"
aria-hidden="true" aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)} className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
> >
{item.icon} {item.icon}
</IconTile> </IconTile>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div> <div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{footer ? <div className="shrink-0">{footer}</div> : null} {item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div> </div>
<div <div
className={cn( className={cn(
'text-2xl leading-none font-bold tabular-nums', 'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant], VALUE_VARIANT_CLASS[valueVariant],
)} )}
> >
{item.value} {item.value}
</div> </div>
{item.progress !== undefined ? (
<Progress
value={clampProgress(item.progress)}
className={cn(
'mt-1.5 w-full gap-0 **:data-[slot=progress-track]:h-1.5',
PROGRESS_TONE_CLASS[valueVariant],
)}
aria-label={
typeof item.label === 'string'
? `${item.label}: ${clampProgress(item.progress)}%`
: undefined
}
/>
) : null}
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div> </div>
</div> </div>
) )
@@ -116,7 +153,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item) const selected = isSelected(item)
return cn( return cn(
'relative isolate flex h-full flex-col', 'relative isolate flex h-full min-w-0 flex-col',
clickable && clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1', selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */ /** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
afterKpi?: ReactNode afterKpi?: ReactNode
charts: ReactNode charts: ReactNode
queue: ReactNode queue?: ReactNode
queueTitle?: string queueTitle?: string
queueDescription?: string queueDescription?: string
headerActions?: ReactNode headerActions?: ReactNode
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
</header> </header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} /> <KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="flex min-w-0 flex-col gap-4"> <div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <div className="grid min-w-0 grid-cols-1 gap-4 @5xl:grid-cols-2">
<Skeleton className="h-56 w-full rounded-xl" />
<Skeleton className="h-56 w-full rounded-xl" />
</div>
</div> </div>
<Skeleton className="h-48 w-full rounded-xl" />
</div> </div>
) )
} }
@@ -76,15 +78,17 @@ export function OpsDashboard({
{charts} {charts}
</section> </section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4"> {queue ? (
<div className="flex min-w-0 flex-col gap-1"> <section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2> <div className="flex min-w-0 flex-col gap-1">
{queueDescription ? ( <h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p> {queueDescription ? (
) : null} <p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
</div> ) : null}
{queue} </div>
</section> {queue}
</section>
) : null}
</div> </div>
) )
} }
+397 -133
View File
@@ -1,27 +1,29 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { import {
getCoreRowModel, useTable,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState, type PaginationState,
type RowSelectionState, type RowSelectionState,
type SortingState, type SortingState,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs' import { EmptyState } from '@/components/empty-state'
import {
StatusToggleGroup,
type StatusToggleTone,
} from '@/components/status-toggle-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid' import {
DataGrid,
dataGridFeatures,
} from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { import { Filters } from '@/components/reui/filters/filters'
Filters, import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
type Filter, import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
type FilterFieldConfig,
} from '@/components/reui/filters'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -30,40 +32,85 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults' import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { FILTERS_I18N_RU } from '@/lib/filters-i18n' import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import { applyFiltersToData } from './filter-utils' import {
applyFiltersToData,
createEmptyFilterQuery,
createTextFilterQuery,
getActiveFilters,
getExtraFilterFields,
getFilterTextValue,
getPrimaryTextField,
mergeFieldRules,
setFilterTextValue,
stripFieldFromQuery,
} from './filter-utils'
import {
FrameDataGrid,
applyKitActionColumn,
kitColumnPinning,
kitDataGridTableClassNames,
kitDataGridTableLayout,
type DataGridColumnDef,
type FrameDataGridProps,
} from './frame-data-grid'
export interface ResourcePageTab { export interface ResourcePageTab {
id: string id: string
label: string label: string
count?: number count?: number
icon?: LucideIcon
tone?: StatusToggleTone
} }
export interface ResourcePageProps<T extends object> { type SimpleGridPassthrough<T extends object> = Pick<
title: string FrameDataGridProps<T>,
| 'onRowClick'
| 'pagination'
| 'footerContent'
| 'dense'
| 'initialSorting'
| 'virtualization'
| 'height'
| 'onRowSelectionChange'
| 'enableColumnVisibility'
| 'columnVisibility'
| 'onColumnVisibilityChange'
| 'columnVisibilityTrigger'
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'className'
| 'tableWidth'
| 'horizontalScroll'
| 'pinLeftColumnIds'
| 'columnPinControls'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
title?: string
description?: string description?: string
tabs?: ResourcePageTab[] tabs?: ResourcePageTab[]
activeTab?: string activeTab?: string
onTabChange?: (tabId: string) => void onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[] filterFields?: FilterField[]
filters: Filter[] filterQuery?: FilterQuery
onFiltersChange: (filters: Filter[]) => void onFilterQueryChange?: (query: FilterQuery) => void
onClearFilters?: () => void onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown getFilterFieldValue?: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[] columns: DataGridColumnDef<T>[]
data: T[] data: T[]
getRowId: (row: T) => string getRowId: (row: T, index?: number) => string
isLoading?: boolean isLoading?: boolean
isError?: boolean isError?: boolean
error?: Error | null error?: Error | null
@@ -79,6 +126,11 @@ export interface ResourcePageProps<T extends object> {
}) => ReactNode }) => ReactNode
toolbarExtra?: ReactNode toolbarExtra?: ReactNode
hideHeader?: boolean hideHeader?: boolean
pinLastColumn?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
actions?: ReactNode
} }
function ResourcePageSkeleton() { function ResourcePageSkeleton() {
@@ -100,18 +152,35 @@ function ResourcePageSkeleton() {
) )
} }
export function ResourcePage<T extends object>({ function ResourceLoadError({
title, error,
onRetry,
}: {
error?: Error | null
onRetry?: () => void
}) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
const noopQueryChange = (_query: FilterQuery) => {}
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
function ResourcePageSimple<T extends object>({
title = '',
description, description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns, columns,
data, data,
getRowId, getRowId,
@@ -120,15 +189,153 @@ export function ResourcePage<T extends object>({
error = null, error = null,
onRetry, onRetry,
primaryAction, primaryAction,
actions,
emptyState,
emptyTitle,
emptyDescription,
emptyAction,
pageSize = 10,
enableRowSelection = false,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
onRowClick,
pagination,
footerContent,
dense,
initialSorting,
virtualization,
height,
onRowSelectionChange,
enableColumnVisibility,
columnVisibility,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
tableWidth,
horizontalScroll,
pinLeftColumnIds,
columnPinControls,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
const headerActions = primaryAction ?? actions
const resolvedEmpty = emptyState ?? (
emptyTitle || emptyDescription || emptyAction
? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
: undefined
)
return (
<div className="w-full">
{toolbarExtra ? (
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
) : null}
<FrameDataGrid
title={hideHeader ? undefined : title || undefined}
description={hideHeader ? undefined : description}
actions={hideHeader ? undefined : headerActions}
columns={columns}
data={data}
rowId={getRowId}
emptyTitle={resolvedEmpty?.title}
emptyDescription={resolvedEmpty?.description}
emptyAction={resolvedEmpty?.action}
pinLastColumn={pinLastColumn}
pageSize={pageSize}
enableRowSelection={enableRowSelection}
onRowClick={onRowClick}
pagination={pagination}
footerContent={footerContent}
dense={dense}
initialSorting={initialSorting}
virtualization={virtualization}
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
className={className}
tableWidth={tableWidth}
horizontalScroll={horizontalScroll}
pinLeftColumnIds={pinLeftColumnIds}
columnPinControls={columnPinControls}
/>
</div>
)
}
function ResourcePageFiltered<T extends object>({
title = '',
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields = [],
filterQuery: controlledQuery,
onFilterQueryChange = noopQueryChange,
onClearFilters,
getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState, emptyState,
pageSize = 10, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
selectionToolbar, selectionToolbar,
toolbarExtra, toolbarExtra,
hideHeader = false, hideHeader = false,
pinLastColumn = false,
enableColumnVisibility = false,
onRowClick,
virtualization = false,
height = 480,
horizontalScroll = false,
}: ResourcePageProps<T>) { }: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
const primaryTextField = useMemo(() => getPrimaryTextField(filterFields), [filterFields])
const extraFilterFields = useMemo(
() => getExtraFilterFields(filterFields, primaryTextField?.id),
[filterFields, primaryTextField],
)
const showSearch = Boolean(primaryTextField)
const showFiltersPopover = extraFilterFields.length > 0
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
const isQueryControlled = controlledQuery !== undefined
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
const searchText = primaryTextField
? getFilterTextValue(filterQuery, primaryTextField.id)
: ''
const extraQuery = useMemo(
() =>
primaryTextField
? stripFieldFromQuery(filterQuery, primaryTextField.id)
: filterQuery,
[filterQuery, primaryTextField],
)
const extraActiveCount = useMemo(
() => getActiveFilters(flattenFilterConditions(extraQuery)).length,
[extraQuery],
)
const hasDirtyFilters = searchText.trim() !== '' || extraActiveCount > 0
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -144,25 +351,23 @@ export function ResourcePage<T extends object>({
}, []) }, [])
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue) let result = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') { if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab)) result = result.filter((item) => tabFilter(item, activeTab))
} }
return result return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab]) }, [data, filterQuery, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
const tabCounts = useMemo(() => { const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {} if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue) const base = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
const counts: Record<string, number> = {} const counts: Record<string, number> = {}
for (const tab of tabs) { for (const tab of tabs) {
counts[tab.id] = counts[tab.id] =
tab.id === 'all' tab.id === 'all' ? base.length : base.filter((item) => tabFilter(item, tab.id)).length
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
} }
return counts return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue]) }, [tabs, tabFilter, data, filterQuery, getFilterFieldValue, showFilters])
const selectedIds = useMemo( const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]), () => Object.keys(rowSelection).filter((id) => rowSelection[id]),
@@ -171,22 +376,36 @@ export function ResourcePage<T extends object>({
const selectedCount = selectedIds.length const selectedCount = selectedIds.length
const tableColumns = useMemo(
() => applyKitActionColumn(columns, { pinLastColumn }),
[columns, pinLastColumn],
)
const { enablePinning, columnPinning } = kitColumnPinning({
pinLastColumn,
horizontalScroll,
lastColId: tableColumns[tableColumns.length - 1]?.id ?? '',
})
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setRowSelection({}) setRowSelection({})
}, []) }, [])
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data: filteredData, data: filteredData,
columns, columns: tableColumns,
getRowId, getRowId: (row, index) => getRowId(row, index),
state: { sorting, rowSelection, pagination }, state: {
sorting,
rowSelection,
pagination,
...(enablePinning ? { columnPinning } : {}),
},
initialState: enablePinning ? { columnPinning } : undefined,
enableRowSelection, enableRowSelection,
onSortingChange: setSorting, onSortingChange: setSorting,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination, onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}) })
const handleTabChange = useCallback( const handleTabChange = useCallback(
@@ -198,18 +417,38 @@ export function ResourcePage<T extends object>({
[onTabChange, resetPagination], [onTabChange, resetPagination],
) )
const handleFiltersChange = useCallback( const handleSearchChange = useCallback(
(next: Filter[]) => { (value: string) => {
onFiltersChange(next) if (!primaryTextField) return
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
resetPagination() resetPagination()
}, },
[onFiltersChange, resetPagination], [filterQuery, primaryTextField, setFilterQuery, resetPagination],
)
const handleFiltersChange = useCallback(
(next: FilterQuery) => {
setFilterQuery(
primaryTextField
? mergeFieldRules(next, filterQuery, primaryTextField.id)
: next,
)
resetPagination()
},
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
) )
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
onClearFilters?.() onClearFilters?.()
if (!isQueryControlled) {
setInternalQuery(
primaryTextField
? createTextFilterQuery(primaryTextField.id)
: createEmptyFilterQuery(),
)
}
resetPagination() resetPagination()
}, [onClearFilters, resetPagination]) }, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
const countedTabs = useMemo( const countedTabs = useMemo(
() => () =>
@@ -217,42 +456,30 @@ export function ResourcePage<T extends object>({
id: tab.id, id: tab.id,
label: tab.label, label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0, count: tabCounts[tab.id] ?? tab.count ?? 0,
icon: tab.icon,
tone: tab.tone,
})), })),
[tabs, tabCounts], [tabs, tabCounts],
) )
if (isLoading) { if (isLoading) return <ResourcePageSkeleton />
return <ResourcePageSkeleton /> if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (data.length === 0 && emptyState) { if (data.length === 0 && emptyState) {
return ( return (
<EmptyState <Frame dense spacing="sm" className="w-full">
title={emptyState.title} <FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
description={emptyState.description} <EmptyState
action={emptyState.action} title={emptyState.title}
/> description={emptyState.description}
action={emptyState.action}
/>
</FramePanel>
</Frame>
) )
} }
const emptyMessage = 'Нет записей по выбранным фильтрам.' const tableNode = <DataGridTable />
return ( return (
<div className="w-full"> <div className="w-full">
@@ -266,8 +493,15 @@ export function ResourcePage<T extends object>({
<DataGrid <DataGrid
table={table} table={table}
recordCount={filteredData.length} recordCount={filteredData.length}
emptyMessage={emptyMessage} emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }} onRowClick={onRowClick}
tableLayout={kitDataGridTableLayout({
dense: true,
width: 'fixed',
columnsPinnable: enablePinning,
columnsVisibility: enableColumnVisibility,
})}
tableClassNames={kitDataGridTableClassNames}
> >
<Frame dense variant="default" spacing="sm" className="w-full"> <Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? ( {!hideHeader ? (
@@ -277,69 +511,84 @@ export function ResourcePage<T extends object>({
{description ? ( {description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty"> <FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span> <span>{description}</span>
<span <span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
className="bg-input size-1 shrink-0 rounded-full" <span className="tabular-nums">{filteredData.length} записей</span>
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<> <>
<span <span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span> <span>{selectedCount} выбрано</span>
</> </>
) : null} ) : null}
</FrameDescription> </FrameDescription>
) : null} ) : null}
</div> </div>
{primaryAction ? ( {headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction} {headerActions}
</div> </div>
) : null} ) : null}
</FrameHeader> </FrameHeader>
) : null} ) : null}
<FramePanel className="p-0 shadow-none!"> <FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? ( <div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)"> {showSearch && primaryTextField ? (
<CountedLineTabs <InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
tabs={countedTabs} <InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
<div />
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{countedTabs.length > 0 ? (
<StatusToggleGroup
items={countedTabs}
value={activeTab} value={activeTab}
onValueChange={handleTabChange} onValueChange={handleTabChange}
aria-label="Фильтр по статусу"
/> />
</div> ) : null}
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
i18n={FILTERS_I18N_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra} {toolbarExtra}
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<Badge size="sm" variant="secondary"> <Badge size="sm" variant="secondary">
{selectedCount} выбрано {selectedCount} выбрано
</Badge> </Badge>
) : null} ) : null}
{onClearFilters ? ( {hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}> <Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" /> <FilterXIcon className="size-4" aria-hidden="true" />
Сбросить Сбросить
@@ -350,17 +599,18 @@ export function ResourcePage<T extends object>({
<Separator /> <Separator />
<DataGridScrollArea> {virtualization ? (
<DataGridTable /> <DataGridScrollArea orientation="vertical" style={{ height }}>
</DataGridScrollArea> {tableNode}
</DataGridScrollArea>
) : (
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
)}
<Separator /> <Separator />
<FrameFooter> <FrameFooter>
<DataGridPagination <DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
{...DATA_GRID_PAGINATION_RU}
sizes={[5, 10, 20, 50]}
/>
</FrameFooter> </FrameFooter>
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -368,3 +618,17 @@ export function ResourcePage<T extends object>({
</div> </div>
) )
} }
/**
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
const showFilters = (props.filterFields?.length ?? 0) > 0
const hasTabs = (props.tabs?.length ?? 0) > 0
if (!showFilters && !hasTabs) {
return <ResourcePageSimple {...props} />
}
return <ResourcePageFiltered {...props} />
}
@@ -4,8 +4,9 @@ import { PageShell } from '@/components/page-shell'
/** /**
* Settings layout — page chrome only. * Settings layout — page chrome only.
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings). * Side-tab rail lives in `SettingsPageShell` (settings-3 / settings-16 DNA).
* @see https://reui.io/preview/base/settings-7 * @see https://reui.io/preview/base/settings-3
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/blocks * @see https://reui.io/blocks
*/ */
export function SettingsShell() { export function SettingsShell() {
@@ -163,7 +163,10 @@ function AutocompleteItem({
<AutocompletePrimitive.Item <AutocompletePrimitive.Item
data-slot="autocomplete-item" data-slot="autocomplete-item"
className={cn( className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60", "text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5",
"rounded-md",
"data-highlighted:before:rounded-md",
"px-1.5 py-1 text-sm ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className className
)} )}
{...props} {...props}
@@ -0,0 +1,864 @@
import * as React from "react"
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ROOT_KEY,
isCascaderMoreNode,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderIndex,
CascaderLoadContext,
CascaderLoadReason,
CascaderLoadResult,
CascaderLoadState,
CascaderNode,
CascaderSearchContext,
} from "@/components/reui/cascader/cascader-types"
/**
* Async data for the cascader. Loaded pages live in their own store and are
* MERGED onto the index built from `items`, never folded into that build:
* `items` changes identity on any parent re-render, and folding would discard
* every level the user drilled into. Map membership is the load discriminator:
* no `states` entry means never fetched, while an entry with no `loading`, no
* `error` and no children means empty for real.
*/
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
/** Fetches one level. `node` is `null` for the root level. */
export type CascaderGetChildren<T = unknown> = (
node: CascaderNode<T> | null,
context: CascaderLoadContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Server-side search, replacing the local index scan while the query is set. */
export type CascaderOnSearch<T = unknown> = (
query: string,
context: CascaderSearchContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Resolves a selected value to its ancestor chain, root first, node last. */
export type CascaderResolveValue<T = unknown> = (
value: string,
context: CascaderLoadContext
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
export interface CascaderLoaderStore<T = unknown> {
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
pages: Map<string, CascaderNode<T>[]>
states: Map<string, CascaderLoadState>
/** Keyed by node value: search hits and resolved selections, level-less. */
detached: Map<string, CascaderNode<T>>
}
export interface UseCascaderLoaderOptions<T = unknown> {
/** The index built from `items`, before any pages are merged in. */
base: CascaderIndex<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
/** Milliseconds of quiet before `onSearch` fires. */
searchDebounce?: number
/** Changing this drops every cached page, state and detached node. */
loadKey?: unknown
/** Speculatively fetch a branch's children when it is highlighted. */
prefetch?: boolean
/** Called when a request fails. Never for an aborted or superseded one. */
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
/** Whether the panel is live: the popup is open, or the cascader is inline. */
enabled: boolean
query: string
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
levels: string[]
/** The navigation path, handed to `onSearch` as its scope. */
path: string[]
/** Current selection, for `resolveValue`. */
values: string[]
}
export interface CascaderLoader<T = unknown> {
/** Whether a `getChildren` loader is configured at all. */
active: boolean
store: CascaderLoaderStore<T>
states: ReadonlyMap<string, CascaderLoadState>
/**
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
* first request is in flight, so the level behind is not shown as the answer.
*/
searchResults: CascaderNode<T>[] | null
searchState: CascaderLoadState | null
/**
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
* loaded or failed) or when `items` fills the level; pages are never
* consulted, so a `resolveValue` chain still fetches. The level effect fires
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
*/
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
/** Fetches the next page of a level. No-ops unless one is available. */
loadMore: (parentKey: string) => void
/** Refires a failed level. No-ops unless that level is in an error state. */
retryLevel: (parentKey: string) => void
/** Schedules a speculative fetch. Safe to call on every highlight move. */
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
/**
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
* and paging latch, so the level effect refetches it. `null` = root level.
*/
invalidateLevel: (value: string | null) => void
}
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
/**
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
* does not fire a request per row, short enough to beat the ArrowRight press.
*/
const PREFETCH_DELAY = 150
/** Request keys for the two non-level requests. Never collide with a value. */
const SEARCH_KEY = "\u0000search"
const RESOLVE_PREFIX = "\u0000resolve:"
const NO_STATE: CascaderLoadState = {
loading: false,
error: false,
hasMore: false,
}
/** Stable empty result, so an idle search never churns the state context. */
const NO_RESULTS: CascaderNode<never>[] = []
function createStore<T>(): CascaderLoaderStore<T> {
return { pages: new Map(), states: new Map(), detached: new Map() }
}
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
return (
a.loading === b.loading &&
a.error === b.error &&
a.hasMore === b.hasMore &&
a.cursor === b.cursor
)
}
/* -------------------------------------------------------------------------- */
/* Store transitions */
/* -------------------------------------------------------------------------- */
/**
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
* on store identity, so a fresh object for an unchanged state would rebuild it.
*/
function withLoadState<T>(
store: CascaderLoaderStore<T>,
key: string,
update: (state: CascaderLoadState) => CascaderLoadState
): CascaderLoaderStore<T> {
const current = store.states.get(key) ?? NO_STATE
const next = update(current)
if (store.states.has(key) && sameLoadState(current, next)) return store
const states = new Map(store.states)
states.set(key, next)
return { pages: store.pages, states, detached: store.detached }
}
function withPage<T>(
store: CascaderLoaderStore<T>,
key: string,
items: readonly CascaderNode<T>[],
options: { append: boolean; hasMore: boolean; cursor?: string }
): CascaderLoaderStore<T> {
// A fresh level REPLACES its page so a `resolveValue` stub can be superseded.
const previous = options.append ? (store.pages.get(key) ?? []) : []
const seen = new Set(previous.map((node) => node.value))
const merged = previous.slice()
for (const item of items) {
if (seen.has(item.value)) continue
seen.add(item.value)
merged.push(item)
}
const pages = new Map(store.pages)
pages.set(key, merged)
const states = new Map(store.states)
states.set(key, {
loading: false,
error: false,
hasMore: options.hasMore,
cursor: options.cursor,
})
return { pages, states, detached: store.detached }
}
function withDetached<T>(
store: CascaderLoaderStore<T>,
items: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
let detached: Map<string, CascaderNode<T>> | null = null
for (const item of items) {
if (store.detached.get(item.value) === item) continue
detached = detached ?? new Map(store.detached)
detached.set(item.value, item)
}
if (!detached) return store
return { pages: store.pages, states: store.states, detached }
}
/**
* Places a resolved ancestor chain into `pages`, root first. Writes no
* `states`, so those levels still read as unloaded and a drill-in still fetches
* for real. Mirrored into `detached` so the trigger keeps its label.
*/
function withChain<T>(
store: CascaderLoaderStore<T>,
chain: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
if (chain.length === 0) return store
const pages = new Map(store.pages)
const detached = new Map(store.detached)
let parentKey = CASCADER_ROOT_KEY
for (const node of chain) {
const bucket = pages.get(parentKey)
if (!bucket) {
pages.set(parentKey, [node])
} else if (!bucket.some((entry) => entry.value === node.value)) {
pages.set(parentKey, [...bucket, node])
}
detached.set(node.value, node)
parentKey = node.value
}
return { pages, states: store.states, detached }
}
/* -------------------------------------------------------------------------- */
/* Hook */
/* -------------------------------------------------------------------------- */
interface CascaderLoaderLatest<T> {
base: CascaderIndex<T>
store: CascaderLoaderStore<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
prefetch: boolean
path: string[]
}
interface CascaderSearchSlice<T> {
query: string
results: CascaderNode<T>[]
loading: boolean
error: boolean
}
/**
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
* build stays pure in `items`, the merge pure in that build plus this store.
*/
export function useCascaderLoader<T = unknown>({
base,
getChildren,
onSearch,
resolveValue,
searchDebounce = 250,
loadKey,
prefetch = false,
onLoadError,
enabled,
query,
levels,
path,
values,
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
null
)
/**
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
* consumers, so closing over it would refire every in-flight request per
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
* FIRST, since effects run in declaration order, so the level effect below
* already sees the current commit.
*/
const latest = React.useRef<CascaderLoaderLatest<T>>({
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
})
React.useEffect(() => {
latest.current = {
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
}
})
/** One AbortController PER KEY: columns mode runs several levels at once. */
const controllers = React.useRef(new Map<string, AbortController>())
/** Monotonic per key. The stale guard for out-of-order responses. */
const requestIds = React.useRef(new Map<string, number>())
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
const inflight = React.useRef(new Map<string, string>())
/**
* The `(child count, cursor)` signature at the last paging fire, per level.
* Guards what `hasMore` cannot: a page of zero new items while the server
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
* page advances it while the count stands still - real progress, which a
* count-only latch would brick forever.
*/
const moreLatch = React.useRef(new Map<string, string>())
/** Values `resolveValue` has already been asked about, so it asks once. */
const attempted = React.useRef(new Set<string>())
/** Every node the loader has seen, so a level key can name its own node. */
const known = React.useRef(new Map<string, CascaderNode<T>>())
const timers = React.useRef<{
prefetch: ReturnType<typeof setTimeout> | null
}>({ prefetch: null })
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
const epoch = React.useRef(0)
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
const map = known.current
const walk = (list: readonly CascaderNode<T>[]) => {
for (const node of list) {
map.set(node.value, node)
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
}, [])
const abortKey = React.useCallback((key: string) => {
const controller = controllers.current.get(key)
if (!controller) return
controllers.current.delete(key)
inflight.current.delete(key)
controller.abort()
}, [])
/* ------------------------------- level load ------------------------------ */
const runLoad = React.useCallback(
(
key: string,
reason: CascaderLoadReason,
cursor: string | undefined,
append: boolean
) => {
const { getChildren: loader, base: currentBase } = latest.current
if (!loader) return
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
if (inflight.current.get(key) === signature) return
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
inflight.current.set(key, signature)
const requestId = (requestIds.current.get(key) ?? 0) + 1
requestIds.current.set(key, requestId)
const startEpoch = epoch.current
const node =
key === CASCADER_ROOT_KEY
? null
: (currentBase.byValue.get(key) ?? known.current.get(key) ?? null)
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: true,
error: false,
}))
)
const settle = () => {
if (inflight.current.get(key) === signature)
inflight.current.delete(key)
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(key)
// `Promise.resolve().then(...)`, not a direct call: it normalises a
// SYNCHRONOUS throw into a rejection instead of taking the render down.
Promise.resolve()
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
const nextCursor = Array.isArray(result)
? undefined
: result.nextCursor
const hasMore = Array.isArray(result)
? false
: (result.hasMore ?? nextCursor != null)
if (!append) moreLatch.current.delete(key)
remember(items)
setStore((prev) =>
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
)
})
.catch((error: unknown) => {
settle()
if (stale()) return
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: false,
error: true,
}))
)
// Behind the stale guard: an abort is navigation, not a failure.
latest.current.onLoadError?.(error, {
parent: key === CASCADER_ROOT_KEY ? null : key,
reason,
})
})
},
[abortKey, remember]
)
const ensureLevel = React.useCallback(
(key: string, reason: CascaderLoadReason) => {
const {
getChildren: loader,
store: current,
base: index,
} = latest.current
if (!loader) return
if (current.states.has(key)) return
// A level `items` already fills is not the loader's business: that is how
// a static root plus `getChildren` for the branches works with no flag.
if (index.childrenOf.has(key)) return
runLoad(key, reason, undefined, false)
},
[runLoad]
)
const loadMore = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state || state.loading || !state.hasMore) return
const loaded = current.pages.get(key)?.length ?? 0
const signature = `${loaded}:${state.cursor ?? ""}`
if (moreLatch.current.get(key) === signature) return
moreLatch.current.set(key, signature)
runLoad(key, "more", state.cursor, true)
},
[runLoad]
)
const retryLevel = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state?.error) return
const loaded = current.pages.get(key)?.length ?? 0
// A retry must be able to re-fire the page the latch just blocked.
moreLatch.current.delete(key)
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
},
[runLoad]
)
/**
* `detached` is deliberately untouched: chains and search hits belong to no
* level, and the trigger needs their labels while the new page is in flight.
*/
const invalidateLevel = React.useCallback(
(value: string | null) => {
const key = value ?? CASCADER_ROOT_KEY
abortKey(key)
// Bump the id too: a response past its signal check must still be stale.
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
moreLatch.current.delete(key)
setStore((prev) => {
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
const states = new Map(prev.states)
states.delete(key)
const pages = new Map(prev.pages)
pages.delete(key)
return { pages, states, detached: prev.detached }
})
},
[abortKey]
)
const prefetchNode = React.useCallback(
(node: CascaderNode<T> | null | undefined) => {
const {
prefetch: on,
getChildren: loader,
store: current,
} = latest.current
if (!on || !loader || !node) return
if (isCascaderMoreNode(node)) return
if (!node.hasChildren) return
if (current.states.has(node.value)) return
const holder = timers.current
if (holder.prefetch) clearTimeout(holder.prefetch)
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
// effect, where a synchronous setState is a render-phase cascade.
holder.prefetch = setTimeout(() => {
holder.prefetch = null
ensureLevel(node.value, "prefetch")
}, PREFETCH_DELAY)
},
[ensureLevel]
)
/* --------------------------------- search -------------------------------- */
const runSearch = React.useCallback(
(text: string) => {
const { onSearch: searcher, path: currentPath } = latest.current
if (!searcher) return
abortKey(SEARCH_KEY)
const controller = new AbortController()
controllers.current.set(SEARCH_KEY, controller)
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
requestIds.current.set(SEARCH_KEY, requestId)
const startEpoch = epoch.current
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: true,
error: false,
}))
const settle = () => {
if (controllers.current.get(SEARCH_KEY) === controller) {
controllers.current.delete(SEARCH_KEY)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(SEARCH_KEY)
Promise.resolve()
.then(() =>
searcher(text, { signal: controller.signal, path: currentPath })
)
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
remember(items)
// Search hits go to `detached`, never a level: a hit lives anywhere in
// the tree, and filing it under the open level would misplace it.
setStore((prev) => withDetached(prev, items))
setSearch({
query: text,
results: items,
loading: false,
error: false,
})
})
.catch((error: unknown) => {
settle()
if (stale()) return
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: false,
error: true,
}))
latest.current.onLoadError?.(error, {
parent: null,
reason: "search",
})
})
},
[abortKey, remember]
)
/* --------------------------------- resolve ------------------------------- */
const runResolve = React.useCallback(
(value: string) => {
const { resolveValue: resolver } = latest.current
if (!resolver) return
const key = `${RESOLVE_PREFIX}${value}`
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
const startEpoch = epoch.current
const settle = () => {
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
Promise.resolve()
.then(() =>
resolver(value, { signal: controller.signal, reason: "resolve" })
)
.then((chain) => {
settle()
if (controller.signal.aborted || startEpoch !== epoch.current) return
if (!chain?.length) return
remember(chain)
setStore((prev) => withChain(prev, chain))
})
.catch((error: unknown) => {
settle()
// Un-attempt on failure: `attempted` is written BEFORE the call, so
// without this a resolver that failed once could never be retried.
attempted.current.delete(value)
if (controller.signal.aborted || startEpoch !== epoch.current) return
latest.current.onLoadError?.(error, {
parent: null,
reason: "resolve",
})
})
},
[abortKey, remember]
)
/* --------------------------------- resets -------------------------------- */
const cancelAll = React.useCallback(() => {
const keys = Array.from(controllers.current.keys())
for (const controller of controllers.current.values()) controller.abort()
controllers.current.clear()
inflight.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
if (keys.length === 0) return
setStore((prev) => {
let states: Map<string, CascaderLoadState> | null = null
for (const key of keys) {
const state = prev.states.get(key)
if (!state?.loading) continue
states = states ?? new Map(prev.states)
if ((prev.pages.get(key)?.length ?? 0) > 0) {
states.set(key, { ...state, loading: false })
} else {
// No entry AT ALL: membership is what says "loaded", so a stranded
// `loading: true` would read as loaded-and-empty forever.
states.delete(key)
}
}
return states ? { ...prev, states } : prev
})
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
}, [])
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
// a reset always lands before the levels are asked for again.
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
React.useEffect(() => {
const previous = loadKeyRef.current
loadKeyRef.current = { key: loadKey }
if (!previous || Object.is(previous.key, loadKey)) return
epoch.current += 1
for (const controller of controllers.current.values()) controller.abort()
controllers.current.clear()
inflight.current.clear()
// `requestIds` is deliberately NOT cleared: the ids are stale-response
// guards, not cache. Reusing an aborted predecessor's id would leave only
// the epoch bump and the abort to reject the old response; monotonic per
// key keeps all three guards independent. The unmount cleanup clears them.
moreLatch.current.clear()
attempted.current.clear()
known.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
setStore(createStore)
setSearch(null)
}, [loadKey])
// Closing the popup aborts every request but does NOT drop the cache:
// reopening onto an already-loaded level is the point of keeping it.
React.useEffect(() => {
if (enabled) return
if (controllers.current.size === 0) return
cancelAll()
}, [enabled, cancelAll])
React.useEffect(() => {
const active = controllers.current
const holder = timers.current
const pending = inflight.current
const ids = requestIds.current
return () => {
for (const controller of active.values()) controller.abort()
active.clear()
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
// inflight signature would make `runLoad` skip the refetch forever. The
// aborted promises are stale-guarded, so emptying the ids is safe.
pending.clear()
ids.clear()
if (holder.prefetch) clearTimeout(holder.prefetch)
}
}, [])
/* ------------------------------- the effects ----------------------------- */
const hasLoader = typeof getChildren === "function"
const hasSearch = typeof onSearch === "function"
const hasResolve = typeof resolveValue === "function"
const trimmed = query.trim()
// Serialised so the effects key on CONTENT, not on the array identity.
const levelsKey = JSON.stringify(levels)
const valuesKey = JSON.stringify(values)
// THE load trigger: one declarative effect on the levels on screen, not a
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
React.useEffect(() => {
if (!enabled || !hasLoader) return
for (const key of levels) ensureLevel(key, "level")
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
React.useEffect(() => {
if (!hasSearch) return undefined
if (!enabled || !trimmed) {
abortKey(SEARCH_KEY)
setSearch((prev) => (prev === null ? prev : null))
return undefined
}
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
return () => {
clearTimeout(timer)
abortKey(SEARCH_KEY)
}
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
React.useEffect(() => {
if (!enabled || !hasResolve) return
for (const value of values) {
if (!value) continue
if (attempted.current.has(value)) continue
if (base.byValue.has(value) || known.current.has(value)) continue
attempted.current.add(value)
runResolve(value)
}
// `values` is depended on through `valuesKey`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasResolve, valuesKey, base, runResolve])
/* -------------------------------- the value ------------------------------ */
const searchResults = React.useMemo(() => {
if (!hasSearch || !trimmed) return null
if (!search || search.query !== trimmed) {
return NO_RESULTS as CascaderNode<T>[]
}
return search.results
}, [hasSearch, trimmed, search])
const searchState = React.useMemo<CascaderLoadState | null>(() => {
if (!hasSearch || !trimmed) return null
const settled = search?.query === trimmed
return {
loading: !settled || !!search?.loading,
error: settled && !!search?.error,
hasMore: false,
}
}, [hasSearch, trimmed, search])
return React.useMemo(
() => ({
active: hasLoader,
store,
states: store.states,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
invalidateLevel,
prefetchNode,
}),
[
hasLoader,
store,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
invalidateLevel,
prefetchNode,
]
)
}
/* -------------------------------------------------------------------------- */
/* Consumers */
/* -------------------------------------------------------------------------- */
/** One level's load state, `null` when never fetched. Omit for the root. */
export function useCascaderLoadState(
parent?: string | null
): CascaderLoadState | null {
const { loadStates } = useCascaderState()
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
}
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_HEIGHT_CLASS,
CASCADER_LIST_PAD_CLASS,
CASCADER_ROOT_KEY,
CASCADER_ROWS_CLASS,
CASCADER_SCROLL_CLASS,
warnCascaderOnce,
} from "@/components/reui/cascader/cascader-lib"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { LoaderCircleIcon } from "lucide-react"
export interface CascaderColumnsProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** Width of each column. */
columnWidth?: number | string
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
maxHeight?: number | string
/** Replaces the default panel; the seam a windowed column plugs into. */
children?: (column: CascaderColumn) => React.ReactNode
}
/**
* Miller columns: the open trail side by side, one panel per level. Only the
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
* behind is plain buttons, which keeps one state machine instead of a second,
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
*/
function CascaderColumns({
className,
columnWidth = 220,
maxHeight: maxHeightProp,
children,
...props
}: CascaderColumnsProps) {
const { maxHeight, mode, labels } = useCascaderActions()
const { columns } = useCascaderState()
// Before the early return, so the hook count is the same in both modes.
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return
if (mode === "columns") return
warnCascaderOnce(
`columns-outside-columns-mode:${mode}`,
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
)
}, [mode])
if (mode !== "columns") return null
const height = maxHeightProp ?? maxHeight
const toCss = (value: number | string) =>
typeof value === "number" ? `${value}px` : value
return (
<div
data-slot="cascader-columns"
role="group"
aria-label={labels.columnsLabel}
style={
{
"--cascader-column-width": toCss(columnWidth),
/* Set only from an EXPLICIT cap: unset means "24rem or what the
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
default here ignored short viewports and wasted tall ones. */
...(height != null
? { "--cascader-max-height": toCss(height) }
: null),
} as React.CSSProperties
}
className={cn(
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
child, and the columns inside it size against this box. */
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
CASCADER_LIST_PAD_CLASS,
className
)}
{...props}
>
{columns.map((column) =>
children ? (
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
) : (
<CascaderColumnPanel key={column.depth} column={column} />
)
)}
</div>
)
}
/**
* One column's box. `columnWidth` is the width of the LIST, not the box: under
* `border-box` the 1px inline-start divider on every column but the first would
* come out of the rows, so bordered columns are widened by that pixel. The box
* is also the BOUND, the same `min(--available-height, cap)` the single list
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
*/
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
export interface CascaderColumnPanelProps {
column: CascaderColumn
/** Replaces the panel's rows; the empty state still wins on an empty column. */
children?: React.ReactNode
/** Containing block for the windowed column's absolutely positioned rows. */
virtualized?: boolean
}
function CascaderColumnPanel({
column,
children,
virtualized,
}: CascaderColumnPanelProps) {
const {
labels,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
retryLevel,
} = useCascaderActions()
const { loadStates } = useCascaderState()
// Keyed per level, not one global flag: columns load and land independently.
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
const loadState = loadStates.get(columnKey)
let emptyBody: React.ReactNode = labels.empty
if (loadState?.error) {
emptyBody = (
<>
{labels.error}{" "}
<button
type="button"
data-slot="cascader-retry"
onClick={() => retryLevel(columnKey)}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
>
{labels.retry}
</button>
</>
)
} else if (loadState?.loading) {
emptyBody = (
<span className="flex items-center gap-1.5">
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
{labels.loading}
</span>
)
}
const rows =
column.items.length === 0 ? (
<p
data-slot="cascader-column-empty"
data-state={
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
}
className="text-muted-foreground px-2 py-1.5 text-sm"
>
{emptyBody}
</p>
) : (
(children ??
column.items.map((node, i) => {
const open = node.value === column.activeValue
return (
<CascaderItem
key={node.value}
node={node}
/* A trail row must not compete for `aria-activedescendant`. */
as={column.active ? "option" : "button"}
depth={column.depth}
/* Answered here, not in the row: the trail rows are memoised too. */
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Set metadata is option-only; a trail row is a `role="button"`. */
{...(column.active
? {
"aria-setsize": column.items.length,
"aria-posinset": i + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
}))
)
const shared = {
"data-slot": "cascader-column",
"data-active": column.active || undefined,
"data-depth": column.depth,
// Addressable so the opening trail row can point `aria-controls` here, and
// named even at the root, which has no parent label to borrow.
id: `${baseId}-column-${column.depth}`,
"aria-label": column.parent?.label ?? labels.rootLevel,
// Conditional spread, never an explicit `undefined`: the active column is a
// Base UI element, and its `mergeProps` iterates own keys.
...(virtualized ? { "data-virtualized": true } : null),
}
// A windowed row is absolutely positioned, so the ROWS' box is the containing
// block, not the scrollport: it carries the padding the geometry is measured
// against.
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
// The active column IS the Combobox list: only rows inside `Combobox.List`
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
const body = column.active ? (
<ComboboxPrimitive.List {...shared} className={rowsClass}>
{rows}
</ComboboxPrimitive.List>
) : (
// A named `group`, not a second listbox competing with the active column.
<div {...shared} role="group" className={rowsClass}>
{rows}
</div>
)
return (
<div
data-slot="cascader-column-bounds"
/* Repeated from `shared`: this box, not the semantic element, owns the
width, the divider and the height, so style hooks must reach it, and
`:first-child` on the semantic element no longer means "first column"
(it is its own scrollport's only child). */
data-active={column.active || undefined}
data-depth={column.depth}
className={PANEL_CLASS}
>
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
</div>
)
}
export { CascaderColumnPanel, CascaderColumns }
@@ -0,0 +1,316 @@
"use client"
import * as React from "react"
import type {
CascaderActionItem,
CascaderChangeReason,
CascaderFlatNode,
CascaderIndex,
CascaderLabels,
CascaderLoadState,
CascaderMode,
CascaderNode,
CascaderSearchScope,
} from "@/components/reui/cascader/cascader-types"
/**
* Four contexts, not one: actions (config and callbacks, near-stable), state
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
* key and pointer move). One combined context re-rendered every row on every
* keystroke, which is what made `React.memo` on the row worth nothing.
*/
/* -------------------------------------------------------------------------- */
/* Shared types */
/* -------------------------------------------------------------------------- */
export interface CascaderColumn<T = unknown> {
parent: CascaderNode<T> | null
items: CascaderNode<T>[]
depth: number
/** The node in this column that is drilled into, if any. */
activeValue: string | null
/** Whether this is the deepest column, the one Base UI owns. */
active: boolean
}
export interface CascaderItemState<T = unknown> {
branch: boolean
selected: boolean
disabled: boolean
depth: number
count: number
/** Ancestor chain, root first. Populated for deep-search rows. */
path: CascaderNode<T>[]
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/**
* Everything derived from the query, the path and the selection. One keystroke
* rebuilds most of it, so never subscribe to it from a row.
*/
export interface CascaderStateContextValue<T = unknown> {
/** Same `useMemo` identity as the actions context's `index`. */
index: CascaderIndex<T>
path: string[]
expanded: ReadonlySet<string>
query: string
currentParent: CascaderNode<T> | null
/** Rows for the current level, already filtered. */
levelItems: CascaderNode<T>[]
deepResults: CascaderNode<T>[] | null
/** What `Combobox.Root` is currently rendering, in render order. */
renderedItems: CascaderNode<T>[]
columns: CascaderColumn<T>[]
treeRows: CascaderFlatNode<T>[]
selectedValues: string[]
/** Selected nodes below each value, at any depth. Absent means zero. */
selectedDescendants: ReadonlyMap<string, number>
/**
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
* discriminator: no entry means never fetched, an entry with no `loading`, no
* `error` and no children means fetched and genuinely empty.
*/
loadStates: ReadonlyMap<string, CascaderLoadState>
/** Async search, `null` when idle. Separate: a search belongs to no level. */
searchState: CascaderLoadState | null
announcement: string
}
const CascaderStateContext = React.createContext<
CascaderStateContextValue | undefined
>(undefined)
/**
* One provider serves every `T`, so the context holds an erased `unknown` value
* and this cast restores it. The primitive never inspects the payload.
*/
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
const context = React.useContext(CascaderStateContext)
if (!context) {
throw new Error("useCascaderState must be used within a Cascader")
}
return context as unknown as CascaderStateContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/**
* Config and callbacks, slow enough that a memoised row can subscribe: the
* mutators are `[]`-dep callbacks over a latest-props ref. The three predicates
* are the exception, read DURING RENDER where a ref written in an effect would
* return the previous commit's answer, so each is memoised on its own input.
*/
export interface CascaderActionsContextValue<T = unknown> {
index: CascaderIndex<T>
mode: CascaderMode
multiple: boolean
/** Multi-select only: a commit propagates over the LOADED subtree. */
cascade: boolean
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
branchesSelectable: boolean
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
indicator: boolean
expandTrigger?: "click" | "hover"
actions: CascaderActionItem[]
searchScope: CascaderSearchScope
maxHeight?: number | string
inline: boolean
invalid: boolean
/**
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
*/
baseId: string
labels: CascaderLabels
/**
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
* makes an explicit row `index` legal: forwarding one while this is false
* makes `aria-activedescendant` resolve to nothing. Latched per level.
*/
virtualized: boolean
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
registerVirtualRenderer: () => () => void
/** The root's `virtualize` prop. `undefined` means "decide by count". */
virtualize?: boolean
virtualizeThreshold: number
estimateRowSize: number
overscan: number
/** Tells "this level is empty" from "not fetched yet" before a load state. */
hasLoader: boolean
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
loadMore: (parentKey: string) => void
retryLevel: (parentKey: string) => void
/**
* Evicts one level's async cache, `null` for the root, so membership reads
* never-loaded. A level that is on screen when evicted refetches at once.
*/
invalidateLevel: (value: string | null) => void
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
getIndex: () => CascaderIndex<T>
getState: () => CascaderStateContextValue<T>
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
getHighlighted: () => CascaderNode<T> | null
setPath: (next: string[] | ((prev: string[]) => string[])) => void
pushLevel: (value: string) => void
popLevel: () => void
goToDepth: (depth: number) => void
toggleExpanded: (value: string) => void
/**
* Registers a footer submenu as open or closed. `Combobox` has no
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
* the root's `onOpenChange` guard cancels the close while any is open, from a
* ref so it reads as of that event without re-rendering the root.
*/
setFlyoutOpen: (key: string, open: boolean) => void
hasOpenFlyout: () => boolean
setQuery: (next: string) => void
/**
* Replaces the selection. `onValueChange` diffs it against the current one
* for its node and reason; pass `reason` only when the caller knows better.
*/
setSelection: (values: string[], reason?: CascaderChangeReason) => void
/** Commits a node, for rows outside the listbox such as ancestor columns. */
commit: (node: CascaderNode<T>) => void
navigate: (node: CascaderNode<T>) => void
/** Into `node` as a child of `depth`, replacing anything deeper. */
navigateAt: (node: CascaderNode<T>, depth: number) => void
/** Never undefined: falls back to a remembered label, then a synthetic node. */
resolveNode: (value: string) => CascaderNode<T>
isBranch: (node: CascaderNode<T>) => boolean
isSelectable: (node: CascaderNode<T>) => boolean
isSelected: (node: CascaderNode<T>) => boolean
/** Always `false` without `cascade`: partial selection needs propagation. */
isIndeterminate: (node: CascaderNode<T>) => boolean
/**
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
* Unlike `isIndeterminate` this answers in every mode.
*/
selectedDescendantCount: (node: CascaderNode<T>) => number
}
const CascaderActionsContext = React.createContext<
CascaderActionsContextValue | undefined
>(undefined)
export function useCascaderActions<
T = unknown,
>(): CascaderActionsContextValue<T> {
const context = React.useContext(CascaderActionsContext)
if (!context) {
throw new Error("useCascaderActions must be used within a Cascader")
}
return context as unknown as CascaderActionsContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Render props */
/* -------------------------------------------------------------------------- */
/**
* Their own context, republished every render. They cannot ride on the actions
* context: an inline closure read off a memoised object is whichever closure
* that object captured, so the row would call a stale prop over stale state.
*/
export interface CascaderRenderContextValue<T = unknown> {
renderItem?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
renderLabel?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
}
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
{}
)
export function useCascaderRender<
T = unknown,
>(): CascaderRenderContextValue<T> {
return React.useContext(
CascaderRenderContext
) as CascaderRenderContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Highlight store */
/* -------------------------------------------------------------------------- */
export interface CascaderHighlight {
index: number
value: string | null
}
/**
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
* every arrow key AND every pointer move over the list, so `setState` would
* re-render the whole root at mousemove rate. A store re-renders subscribers
* only, which inside the primitive is the virtualizer.
*/
export interface CascaderHighlightStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => CascaderHighlight
set: (next: CascaderHighlight) => void
}
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
export function createCascaderHighlightStore(): CascaderHighlightStore {
let snapshot: CascaderHighlight = NO_HIGHLIGHT
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something changes; `useSyncExternalStore` needs it.
getSnapshot() {
return snapshot
},
set(next) {
if (next.index === snapshot.index && next.value === snapshot.value) return
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
FALLBACK_HIGHLIGHT_STORE
)
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
export function useCascaderHighlight(): CascaderHighlight {
const store = React.useContext(CascaderHighlightContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
export {
CascaderActionsContext,
CascaderHighlightContext,
CascaderRenderContext,
CascaderStateContext,
}
@@ -0,0 +1,731 @@
import * as React from "react"
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ACTION_CLASS,
CascaderGroup,
CascaderLabel,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_PAD_CLASS,
getCascaderFooterStops,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
import { Popover as PopoverPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { cn } from "@evobgp/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
/**
* The pinned footer, and the side-anchored flyout a footer row can open. These
* are COMMANDS: nothing here joins the selection, the filter set or the
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
* resolves to the parent portal node, so it is a DOM sibling of the combobox
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
* the outside-press and focus-out whitelists read. `Combobox` builds no
* `FloatingTree`, so the flyout is not consulted first and one Escape would
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
* keeps its own dismissal behaviour.
*/
/* -------------------------------------------------------------------------- */
/* Footer */
/* -------------------------------------------------------------------------- */
/**
* Keys the option list acts on, swallowed at the footer boundary. Escape and
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
*/
const FOOTER_SWALLOWED_KEYS = new Set([
"Enter",
" ",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
])
export type CascaderFooterProps = React.ComponentProps<"div">
/**
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
* over the root's `actions` prop; with neither it renders nothing.
*/
function CascaderFooter({
className,
children,
onKeyDown,
...props
}: CascaderFooterProps) {
const { actions, labels } = useCascaderActions()
const hasChildren = React.Children.count(children) > 0
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
event.stopPropagation()
// The strip's own vertical movement, and the way back from the list's
// hand-off: either end returns focus to the search field, from which
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
// not a command (traps the arrows) or a row (no imperative highlight).
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
const footer = event.currentTarget
const stops = getCascaderFooterStops(footer)
const active = document.activeElement as HTMLElement | null
const index = active ? stops.indexOf(active) : -1
if (index === -1) return
event.preventDefault()
const next =
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
if (next) {
next.focus()
return
}
if (event.key === "ArrowUp" && index > 0) return
footer
.closest<HTMLElement>('[data-slot="cascader-panel"]')
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
?.focus()
},
[onKeyDown]
)
if (!hasChildren && actions.length === 0) return null
return (
<div
data-slot="cascader-footer"
/* Named, not a bare div: without it a screen reader reaches the actions
with nothing to say they are not more options. */
role="group"
aria-label={labels.actionsLabel}
onKeyDown={handleKeyDown}
className={cn(
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
two columns of text were 2px out in luma and sera and 4px out in
lyra. It also gives a separator in here a number to cancel. */
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
</div>
)
}
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
return (
<>
{actions.map((action, i) =>
action.items?.length ? (
<CascaderSubmenu key={actionKey(action, i)}>
<CascaderSubmenuTrigger
icon={action.icon}
disabled={action.disabled}
>
{action.label}
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderActionList items={action.items} />
</CascaderSubmenuContent>
</CascaderSubmenu>
) : (
<CascaderAction
key={actionKey(action, i)}
icon={action.icon}
disabled={action.disabled}
onSelect={action.onSelect}
>
{action.label}
</CascaderAction>
)
)}
</>
)
}
function actionKey(action: CascaderActionItem, index: number): string {
if (action.value != null) return action.value
if (typeof action.label === "string") return action.label
return String(index)
}
/**
* Consecutive entries sharing a `group`, as runs not buckets: two separated
* runs with the same name stay two, so the author's order survives.
*/
function groupActionRuns(
items: CascaderActionItem[]
): { group?: string; items: CascaderActionItem[] }[] {
const runs: { group?: string; items: CascaderActionItem[] }[] = []
for (const item of items) {
const last = runs[runs.length - 1]
if (last && last.group === item.group) last.items.push(item)
else runs.push({ group: item.group, items: [item] })
}
return runs
}
/**
* Flyout body for a data-driven submenu. A named run becomes a real
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
*/
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
const { close } = useCascaderSubmenu()
const runs = React.useMemo(() => groupActionRuns(items), [items])
const renderAction = (item: CascaderActionItem, i: number) => (
<CascaderAction
key={actionKey(item, i)}
icon={item.icon}
disabled={item.disabled}
onSelect={() => {
item.onSelect?.()
/* Closes behind the command, or the entries would read as toggles. */
close()
}}
>
{item.label}
</CascaderAction>
)
return (
<>
{runs.map((run, runIndex) =>
run.group ? (
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
<CascaderLabel>{run.group}</CascaderLabel>
{run.items.map(renderAction)}
</CascaderGroup>
) : (
<React.Fragment key={`run-${runIndex}`}>
{run.items.map(renderAction)}
</React.Fragment>
)
)}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Action */
/* -------------------------------------------------------------------------- */
export interface CascaderActionProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
/** Fires on press, after `onClick`, and not at all when disabled. */
onSelect?: () => void
}
/**
* The cascader popup's panel per style, spelled with `style-<name>:` variants
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
*/
const FLYOUT_SURFACE_CLASS =
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
/**
* One footer command, shaped like a row and deliberately NOT one. A real
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
* appear in `filteredItems`, and vanish the moment a query matched nothing -
* exactly when "Create new attribute" is most useful. Disabled is
* `aria-disabled`, never the native attribute: that one is not a tab stop and
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
* row is a disabled command had no stop after the search field (measured on
* `c-cascader-8`). Staying focusable costs the guards below, plus the
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
* instead of `:disabled`.
*/
function CascaderAction({
className,
icon,
children,
onSelect,
onClick,
onKeyDown,
disabled,
...props
}: CascaderActionProps) {
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
// a roving-focus `menuitem` inside a flyout, never both.
const inMenu = React.useContext(CascaderMenuContext)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Before the consumer's handler: a disabled command runs none of them.
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
if (event.defaultPrevented) return
onSelect?.()
},
[disabled, onClick, onSelect]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
// The two keys a `<button>` activates on, and only those.
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
},
[disabled, onKeyDown]
)
return (
<button
type="button"
data-slot="cascader-action"
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
className={cn(CASCADER_ACTION_CLASS, className)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* Submenu */
/* -------------------------------------------------------------------------- */
interface CascaderSubmenuContextValue {
rowRef: React.RefObject<HTMLButtonElement | null>
open: boolean
setOpen: (open: boolean) => void
close: () => void
/** Names the flyout: a menu is labelled by the control that opens it. */
triggerId: string
/**
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
* calls the opening arrow a POINTER open, because the trigger intercepts it
* and calls `setOpen` (measured: focus landed on the popup, not the first
* entry). A ref, so reading it in the focus phase cannot render.
*/
keyboardRef: React.RefObject<boolean>
}
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
const CascaderMenuContext = React.createContext(false)
/**
* Every entry a menu's roving focus may land on, in DOM order, read from the
* DOM because the entries are whatever the consumer composed. A disabled
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
* attribute, so `:not([disabled])` excludes only a consumer's own natively
* disabled `menuitem`, which cannot take focus.
*/
function menuItems(popup: HTMLElement | null): HTMLElement[] {
if (!popup) return []
return Array.from(
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
)
}
const CascaderSubmenuContext = React.createContext<
CascaderSubmenuContextValue | undefined
>(undefined)
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
const context = React.useContext(CascaderSubmenuContext)
if (!context) {
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
}
return context
}
export interface CascaderSubmenuProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
/**
* A footer row plus the flyout it opens. Registers with the cascader root
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
* flyout still reads as open during the event that closed it.
*/
function CascaderSubmenu({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: CascaderSubmenuProps) {
const { setFlyoutOpen } = useCascaderActions()
const key = React.useId()
const triggerId = React.useId()
const rowRef = React.useRef<HTMLButtonElement | null>(null)
const keyboardRef = React.useRef(false)
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
const open = openProp ?? uncontrolled
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp == null) setUncontrolled(next)
onOpenChange?.(next)
},
[openProp, onOpenChange]
)
const close = React.useCallback(() => setOpen(false), [setOpen])
React.useEffect(() => {
setFlyoutOpen(key, open)
return () => setFlyoutOpen(key, false)
}, [setFlyoutOpen, key, open])
const context = React.useMemo<CascaderSubmenuContextValue>(
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
[open, setOpen, close, triggerId]
)
return (
<CascaderSubmenuContext.Provider value={context}>
<PopoverPrimitive.Root
open={open}
onOpenChange={setOpen}
/* NEVER modal: it would disable the combobox that owns it. */
modal={false}
>
{children}
</PopoverPrimitive.Root>
</CascaderSubmenuContext.Provider>
)
}
export interface CascaderSubmenuTriggerProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
}
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
type CascaderSubmenuTriggerClickEvent = Parameters<
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
>[0]
/**
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
* rather than the `dialog` Base UI would announce: what opens is a list of
* commands with roving focus. `disabled` is intercepted, not forwarded:
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
* attribute for a native `<button>` and takes the row out of the panel's Tab
* ring, the defect `CascaderAction` documents, and this component exposes no
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
* ignores `defaultPrevented`).
*/
function CascaderSubmenuTrigger({
className,
icon,
children,
onKeyDown,
onClick,
disabled,
...props
}: CascaderSubmenuTriggerProps) {
const { labels } = useCascaderActions()
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const handleClick = React.useCallback(
(event: CascaderSubmenuTriggerClickEvent) => {
if (disabled) {
// `mergeProps` runs right to left, so this drops Base UI's own handler.
event.preventDefault()
event.preventBaseUIHandler()
return
}
onClick?.(event)
},
[disabled, onClick]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
if (event.defaultPrevented) return
// The opening key points AT the flyout, so it flips with the writing
// direction; ArrowDown stays "next command". Resolved per keydown, never
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
const openKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowLeft"
: "ArrowRight"
// Enter and Space open through the button's own click, so only FLAGGED.
if (event.key === "Enter" || event.key === " ") {
keyboardRef.current = true
return
}
if (event.key !== openKey) return
event.preventDefault()
keyboardRef.current = true
setOpen(true)
},
[disabled, onKeyDown, direction, setOpen, keyboardRef]
)
return (
<PopoverPrimitive.Trigger
ref={rowRef}
id={triggerId}
data-slot="cascader-submenu-trigger"
aria-haspopup="menu"
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
CASCADER_ACTION_CLASS,
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
popover trigger carries `aria-expanded` in both states. */
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
className
)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
{/* Nothing else says the row opens a MENU, not another tree level. */}
<span className="sr-only">, {labels.submenuAffordance}</span>
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
</PopoverPrimitive.Trigger>
)
}
export interface CascaderSubmenuContentProps
extends
PopoverPrimitive.Popup.Props,
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset"
> {}
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
type CascaderSubmenuKeyEvent = Parameters<
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
>[0]
/**
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
* the parent portal node, the mechanism the file header describes.
*/
function CascaderSubmenuContent({
className,
children,
onKeyDown,
side = "inline-end",
align = "end",
sideOffset = 8,
alignOffset = 0,
...props
}: CascaderSubmenuContentProps) {
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement | null>(null)
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
/**
* Keyboard opens land on the first entry; a pointer open falls through to
* Base UI's default, the popup, so a click paints no focus ring. Not an
* effect of our own: Base UI's focus manager runs on open and wins the race.
*/
const initialFocus = React.useCallback(() => {
const byKeyboard = keyboardRef.current
keyboardRef.current = false
if (!byKeyboard) return true
return menuItems(popupRef.current)[0] ?? true
}, [keyboardRef])
const closeAndReturn = React.useCallback(() => {
close()
rowRef.current?.focus()
}, [close, rowRef])
const handleKeyDown = React.useCallback(
(event: CascaderSubmenuKeyEvent) => {
onKeyDown?.(event)
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
// here reach its handlers unless stopped. Enter would commit a row.
event.stopPropagation()
if (event.defaultPrevented) return
const popup = popupRef.current
const items = menuItems(popup)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const index = active ? items.indexOf(active) : -1
const move = (next: number) => {
event.preventDefault()
items[(next + items.length) % items.length]?.focus()
}
// Mirrors the open key on the SAME per-keydown answer as the trigger.
const closeKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowRight"
: "ArrowLeft"
switch (event.key) {
case "ArrowDown":
return move(index + 1)
case "ArrowUp":
// From the popup itself (a pointer open) Up means the LAST entry.
return move(index === -1 ? items.length - 1 : index - 1)
case "Home":
return move(0)
case "End":
return move(items.length - 1)
case closeKey:
event.preventDefault()
return closeAndReturn()
case "Tab":
// A menu never holds Tab: it closes and focus carries on from its row.
close()
rowRef.current?.focus()
return
default:
break
}
// Typeahead, after the switch so it can never swallow a navigation key.
if (
event.key.length !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return
const now = event.timeStamp
const state = typeaheadRef.current
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
state.at = now
const prefix = state.buffer.toLowerCase()
const from = index === -1 ? 0 : index
// Starts AFTER the current entry so one letter cycles rather than sticks.
const ordered = [
...items.slice(state.buffer.length > 1 ? from : from + 1),
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
]
const hit = ordered.find((item) =>
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
)
if (hit) {
event.preventDefault()
hit.focus()
}
},
[onKeyDown, direction, close, closeAndReturn, rowRef]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
/* The ROW, not whatever Base UI last treated as the trigger. */
anchor={rowRef}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
data-slot="cascader-submenu-content"
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
a pointer open park focus here without adding a Tab stop. */
role="menu"
aria-orientation="vertical"
aria-labelledby={triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
/* Marks a menu surface for the docs design-system picker, which
repaints menus by walking the DOM. An attribute, not a class. */
data-menu-target=""
className={cn(
FLYOUT_SURFACE_CLASS,
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
<CascaderMenuContext.Provider value={true}>
{children}
</CascaderMenuContext.Provider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
/**
* Whether the footer would render anything, for a wrapper (a separator, a grid
* row) that has to make the same call `CascaderFooter` already makes.
*/
export function useCascaderHasActions(): boolean {
const { actions } = useCascaderActions()
return actions.length > 0
}
export {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
}
@@ -0,0 +1,92 @@
import type {
CascaderLabels,
CascaderMode,
} from "@/components/reui/cascader/cascader-types"
/** Name of the root level. Hoisted so the root announcement can reuse it. */
const ROOT_LEVEL = "Top level"
/** Hoisted for the same reason: several defaults end in an item count. */
const itemCount = (count: number) =>
`${count} ${count === 1 ? "item" : "items"}`
/**
* English defaults. Every string the primitive can render lives here, so a
* consumer can translate the whole surface by passing `labels`.
*/
export const CASCADER_LABELS: CascaderLabels = {
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
// proper nouns lose their casing - and a label is already written the way
// its author wants it read.
search: (parentLabel) =>
parentLabel ? `Search ${parentLabel}...` : "Search...",
back: "Back",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load items.",
retry: "Retry",
empty: "No results found.",
selectedCount: (count) => `${count} selected`,
breadcrumbLabel: "Breadcrumb",
chipsLabel: "Selected items",
removeChip: (label) => `Remove ${label}`,
pathSeparator: "/",
rootLevel: ROOT_LEVEL,
itemCount,
branchAffordance: "submenu",
selectedState: "selected",
partiallySelectedState: "partially selected",
columnsLabel: "Levels",
actionsLabel: "Actions",
submenuAffordance: "opens a menu",
panelLabel: "Options",
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
// "Deeper" is the direction the text runs, so the level keys mirror in
// RTL and the hint has to name the mirrored pair there - an LTR-worded
// hint would teach exactly the wrong keys.
const open = dir === "rtl" ? "Left" : "Right"
const back = dir === "rtl" ? "Right" : "Left"
if (mode === "tree") {
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
}
if (mode === "columns") {
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
}
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
},
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
expandedAnnouncement: (label, count) =>
`${label} expanded, ${itemCount(count)}`,
collapsedAnnouncement: (label) => `${label} collapsed`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
searchingAnnouncement: "Searching...",
}
/**
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
* single key without restating the rest.
*/
export function resolveCascaderLabels(
labels?: Partial<CascaderLabels>
): CascaderLabels {
if (!labels) return CASCADER_LABELS
return { ...CASCADER_LABELS, ...labels }
}
/** Resolves the search placeholder, which may be a string or a function. */
export function resolveCascaderSearchLabel(
labels: CascaderLabels,
parentLabel?: string
): string {
return typeof labels.search === "function"
? labels.search(parentLabel)
: labels.search
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,854 @@
import type {
CascaderCollapse,
CascaderFlatNode,
CascaderIndex,
CascaderNode,
CascaderPathSegment,
CascaderSelectable,
} from "@/components/reui/cascader/cascader-types"
/** Root level key in `childrenOf`. NUL-prefixed so no real value collides. */
export const CASCADER_ROOT_KEY = "\u0000root"
/**
* Paging pseudo-node prefix. Written as an escape sequence, not a raw 0x00
* byte: a literal NUL makes this module read as binary and `grep -I` skips it.
*/
export const CASCADER_MORE_PREFIX = "\u0000more:"
/* -------------------------------------------------------------------------- */
/* Scroll layout */
/* -------------------------------------------------------------------------- */
/**
* The four scroll classes. In the lib because `cascader-columns.tsx` needs them
* too and must not import `cascader.tsx`. Shell > bound > scrollport > rows.
*/
/**
* The bound. `min()` over an undefined custom property is an INVALID
* declaration that drops the whole max-height, hence the `100vh` fallback: an
* inline panel has no positioner, so no `--available-height`. `24rem` is the
* default cap, so the common case sets no variable at all.
*/
export const CASCADER_LIST_HEIGHT_CLASS =
"max-h-[min(var(--available-height,100vh),var(--cascader-max-height,24rem))]"
/**
* Each style's list padding: rows take it as `padding`, the scrollport as
* `scroll-padding` floored at 4px so lyra's `0` does not strand a row.
* Mirrored per style from `registry/styles/style-*.css`; keep them in step.
*/
export const CASCADER_LIST_PAD_CLASS =
"[--cascader-list-pad:4px]"
/** The scrollport: a `max-h-*` on the ScrollArea ROOT bounds nothing. */
export const CASCADER_SCROLL_CLASS =
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain **:data-[slot=scroll-area-viewport]:scroll-py-[max(var(--cascader-list-pad,4px),4px)]"
/** The rows' box; `data-empty:p-0` keeps an empty state from double inset. */
export const CASCADER_ROWS_CLASS = "p-(--cascader-list-pad,4px) data-empty:p-0"
/* -------------------------------------------------------------------------- */
/* Tab order */
/* -------------------------------------------------------------------------- */
/**
* What counts as a keyboard stop. Base UI's ScrollArea viewport makes ITSELF
* tabbable when content overflows (`hiddenState.x && hiddenState.y ? -1 : 0`),
* an unnamed stop per level that `ui/scroll-area.tsx` will not let us suppress.
*/
const CASCADER_TAB_STOP_SELECTOR =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
/** The stop to step over: nothing here focuses the scrollport on purpose. */
const CASCADER_TAB_SKIP_SELECTOR = '[data-slot="scroll-area-viewport"]'
/** Out of the tab order whatever they contain; layout is never consulted. */
const CASCADER_TAB_HIDDEN_SELECTOR = "[hidden],[inert],[aria-hidden='true']"
/** The panel's real keyboard stops, in DOM order. */
function getCascaderTabStops(panel: HTMLElement): HTMLElement[] {
const stops = Array.from(
panel.querySelectorAll<HTMLElement>(CASCADER_TAB_STOP_SELECTOR)
)
return stops.filter(
(stop) =>
// Option and trail rows are real `<button>`s with `tabindex="-1"`.
stop.getAttribute("tabindex") !== "-1" &&
!stop.matches(CASCADER_TAB_SKIP_SELECTOR) &&
!stop.closest(CASCADER_TAB_HIDDEN_SELECTOR)
)
}
/** The footer's own stops; an `aria-disabled` command still counts as one. */
export function getCascaderFooterStops(scope: HTMLElement): HTMLElement[] {
const footer = scope.matches('[data-slot="cascader-footer"]')
? scope
: scope.querySelector<HTMLElement>('[data-slot="cascader-footer"]')
if (!footer) return []
return getCascaderTabStops(footer)
}
/** Where Tab lands, or `null` to let the browser out: focus is never trapped. */
export function getCascaderTabTarget(
panel: HTMLElement,
from: Element | null,
backwards: boolean
): HTMLElement | null {
const stops = getCascaderTabStops(panel)
if (stops.length === 0 || !from) return null
const index = stops.indexOf(from as HTMLElement)
if (index !== -1) return stops[index + (backwards ? -1 : 1)] ?? null
if (backwards) {
for (let i = stops.length - 1; i >= 0; i -= 1) {
const position = from.compareDocumentPosition(stops[i])
if (position & Node.DOCUMENT_POSITION_PRECEDING) return stops[i]
}
return null
}
for (const stop of stops) {
const position = from.compareDocumentPosition(stop)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return stop
}
return null
}
/**
* The writing direction at `element`. `DirectionProvider` may only vote yes:
* unmounted it answers `"ltr"`, which would override a real `<html dir="rtl">`.
*/
export function isCascaderRtl(element: Element, provided: string): boolean {
if (provided === "rtl") return true
const explicit = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (explicit === "rtl") return true
if (explicit === "ltr") return false
return (
element.ownerDocument?.defaultView?.getComputedStyle(element).direction ===
"rtl"
)
}
/**
* The paging row as a REAL node, not a DOM-only row after the list: every index
* Base UI hands out indexes the RENDERED array, so a DOM-only row shifts them.
*/
export function createCascaderMoreNode<T = unknown>(
parentKey: string,
loadedCount = 0
): CascaderNode<T> {
return {
value: `${CASCADER_MORE_PREFIX}${parentKey}`,
// Empty on purpose: a typeahead letter must never reach the paging row.
label: "",
// Feeds the row's "Loading more..." wording, with no second prop to thread.
count: loadedCount,
}
}
export function isCascaderMoreNode<T>(node: CascaderNode<T>): boolean {
return node.value.startsWith(CASCADER_MORE_PREFIX)
}
/** The level key a paging pseudo-node belongs to, or `null` for a real node. */
export function getCascaderMoreParent<T>(node: CascaderNode<T>): string | null {
if (!isCascaderMoreNode(node)) return null
return node.value.slice(CASCADER_MORE_PREFIX.length)
}
/** Either input shape into one index. Cycle-guarded depths; first wins. */
export function buildCascaderIndex<T = unknown>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderIndex<T> {
const byValue = new Map<string, CascaderNode<T>>()
const childrenOf = new Map<string, CascaderNode<T>[]>()
const parentOf = new Map<string, string | null>()
const depthOf = new Map<string, number>()
const all: CascaderNode<T>[] = []
const push = (parentKey: string, node: CascaderNode<T>) => {
const bucket = childrenOf.get(parentKey)
if (bucket) {
bucket.push(node)
} else {
childrenOf.set(parentKey, [node])
}
}
if (getParent) {
for (const node of items ?? []) {
// A nullish entry is a data bug: skip it like a duplicate, say so in dev.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
all.push(node)
}
for (const node of all) {
const rawParent = getParent(node)
// An unknown parent makes the node a root rather than dropping the row.
const parent =
rawParent != null && byValue.has(rawParent) ? rawParent : null
parentOf.set(node.value, parent)
push(parent ?? CASCADER_ROOT_KEY, node)
}
for (const node of all) {
let depth = 0
let cursor = parentOf.get(node.value) ?? null
const seen = new Set<string>([node.value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(node.value, depth)
}
} else {
const walk = (
nodes: readonly CascaderNode<T>[] | undefined,
parent: string | null,
depth: number
) => {
for (const node of nodes ?? []) {
// Same degradation as the flat path above.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(node.value, parent)
depthOf.set(node.value, depth)
all.push(node)
push(parent ?? CASCADER_ROOT_KEY, node)
if (node.children?.length) walk(node.children, node.value, depth + 1)
}
}
walk(items, null, 0)
}
return {
byValue,
childrenOf,
parentOf,
depthOf,
roots: childrenOf.get(CASCADER_ROOT_KEY) ?? [],
all,
}
}
/**
* Folds loaded pages into an index built from `items`, so a re-render with a
* new `items` array keeps what the user drilled into. Static `items` wins;
* `detached` is `byValue` ONLY, or deep search doubles it and `childrenOf` lies.
*/
export function mergeCascaderIndex<T = unknown>(
base: CascaderIndex<T>,
pages: ReadonlyMap<string, readonly CascaderNode<T>[]>,
detached?: ReadonlyMap<string, CascaderNode<T>>
): CascaderIndex<T> {
// Identity stability: every downstream `useMemo` is keyed on this index.
if (pages.size === 0 && !detached?.size) return base
const byValue = new Map(base.byValue)
const childrenOf = new Map(base.childrenOf)
const parentOf = new Map(base.parentOf)
const depthOf = new Map(base.depthOf)
// Copy-on-write per bucket: only levels that got a page pay for a new array.
const copied = new Set<string>()
const append = (parentKey: string, node: CascaderNode<T>) => {
let bucket = childrenOf.get(parentKey)
if (!copied.has(parentKey)) {
bucket = bucket ? bucket.slice() : []
childrenOf.set(parentKey, bucket)
copied.add(parentKey)
}
bucket!.push(node)
}
const insert = (parentKey: string, nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(
node.value,
parentKey === CASCADER_ROOT_KEY ? null : parentKey
)
append(parentKey, node)
if (node.children?.length) insert(node.value, node.children)
}
}
for (const [parentKey, nodes] of pages) insert(parentKey, nodes)
for (const value of byValue.keys()) {
if (depthOf.has(value)) continue
let depth = 0
let cursor = parentOf.get(value) ?? null
const seen = new Set<string>([value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(value, depth)
}
const roots = childrenOf.get(CASCADER_ROOT_KEY) ?? []
// Depth first over the MERGED tree, so `all` keeps document order.
const all: CascaderNode<T>[] = []
const visited = new Set<string>()
const walk = (nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (visited.has(node.value)) continue
visited.add(node.value)
all.push(node)
const children = childrenOf.get(node.value)
if (children?.length) walk(children)
}
}
walk(roots)
// A page whose parent never arrived stays searchable rather than vanishing.
for (const [value, node] of byValue) {
if (visited.has(value)) continue
visited.add(value)
all.push(node)
}
if (detached) {
for (const [value, node] of detached) {
if (byValue.has(value)) continue
byValue.set(value, node)
}
}
return { byValue, childrenOf, parentOf, depthOf, roots, all }
}
/** Children of `parent`, or the root level when `parent` is nullish. */
export function getCascaderChildren<T>(
index: CascaderIndex<T>,
parent?: string | null
): CascaderNode<T>[] {
return index.childrenOf.get(parent ?? CASCADER_ROOT_KEY) ?? []
}
/** A branch has known children, or `hasChildren` for an unfetched level. */
export function isCascaderBranch<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): boolean {
if (node.hasChildren) return true
return (index.childrenOf.get(node.value)?.length ?? 0) > 0
}
/** Trailing count for the default row. Explicit `count` wins over the tree. */
export function getCascaderCount<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): number {
if (typeof node.count === "number") return node.count
return index.childrenOf.get(node.value)?.length ?? 0
}
/** Whether a node may be committed as a selection. */
export function isCascaderSelectable<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>,
selectable: CascaderSelectable<T>
): boolean {
// Before the branches a consumer controls: `selectable="any"` says yes to
// every node, and committing the paging row would select a level's name.
if (isCascaderMoreNode(node)) return false
if (node.disabled) return false
// A disabled ANCESTOR refuses the whole subtree: `searchCascaderDeep` still
// surfaces children of a branch the UI will not let anyone open.
{
const seen = new Set<string>([node.value])
let cursor = index.parentOf.get(node.value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
if (index.byValue.get(cursor)?.disabled) return false
cursor = index.parentOf.get(cursor) ?? null
}
}
if (typeof selectable === "function") return selectable(node)
if (selectable === "any") return true
return !isCascaderBranch(index, node)
}
/** Ancestor chain for `value`, root first. Empty while async data loads. */
export function getCascaderPath<T>(
index: CascaderIndex<T>,
value: string | null | undefined
): CascaderNode<T>[] {
if (value == null) return []
const chain: CascaderNode<T>[] = []
const seen = new Set<string>()
let cursor: string | null | undefined = value
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const node = index.byValue.get(cursor)
if (!node) break
chain.push(node)
cursor = index.parentOf.get(cursor) ?? null
}
return chain.reverse()
}
/**
* `toLocaleLowerCase`, not `toLowerCase`: the invariant mapping turns Turkish
* "I" into "i" not "ı", so "ışık" would never match "IŞIK". Both sides fold here.
*/
export function foldCascaderText(text: string): string {
return text.toLocaleLowerCase()
}
/** Folds once so callers can hoist the cost out of a per-node loop. */
export function normalizeCascaderQuery(query: string): string {
return foldCascaderText(query.trim())
}
/** Case-insensitive substring over label and keywords; pre-fold the query. */
export function matchesCascaderQuery<T>(
node: CascaderNode<T>,
normalized: string
): boolean {
if (!normalized) return true
// Coerced, not trusted: a label-less node is malformed data, not a crash.
if (foldCascaderText(node.label ?? "").includes(normalized)) return true
if (node.keywords) {
for (const keyword of node.keywords) {
if (foldCascaderText(keyword).includes(normalized)) return true
}
}
return false
}
/** Filters one level in place-order. Returns the input when the query is empty. */
export function filterCascaderLevel<T>(
nodes: readonly CascaderNode<T>[],
query: string,
matches: (
node: CascaderNode<T>,
normalized: string
) => boolean = matchesCascaderQuery
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return nodes as CascaderNode<T>[]
return nodes.filter((node) => matches(node, normalized))
}
/** Searches every node, optionally under `within`, in one pass over `all`. */
export function searchCascaderDeep<T>(
index: CascaderIndex<T>,
query: string,
options: {
within?: string | null
limit?: number
matches?: (node: CascaderNode<T>, normalized: string) => boolean
} = {}
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return []
const { within, limit = 200, matches = matchesCascaderQuery } = options
const results: CascaderNode<T>[] = []
// One memoised ancestry map per query. The provisional `false` written on the
// way up doubles as the cycle guard; the trail is promoted once `within` hits.
const ancestry = within == null ? null : new Map<string, boolean>()
const isWithin = (node: CascaderNode<T>) => {
if (within == null || !ancestry) return true
const trail: string[] = []
let answer = false
let cursor: string | null | undefined = index.parentOf.get(node.value)
while (cursor != null) {
if (cursor === within) {
answer = true
break
}
const cached = ancestry.get(cursor)
if (cached !== undefined) {
answer = cached
break
}
ancestry.set(cursor, false)
trail.push(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
if (answer) for (const value of trail) ancestry.set(value, true)
return answer
}
for (const node of index.all) {
if (results.length >= limit) break
if (!matches(node, normalized)) continue
if (!isWithin(node)) continue
results.push(node)
}
return results
}
/**
* Flattens the tree into tree mode's visible rows. A `sentinels` paging row
* COUNTS as a sibling in `aria-setsize`: it holds an index of its own.
*/
export function flattenCascaderTree<T>(
index: CascaderIndex<T>,
expanded: ReadonlySet<string>,
sentinels?: ReadonlySet<string>
): CascaderFlatNode<T>[] {
const rows: CascaderFlatNode<T>[] = []
const walk = (
nodes: readonly CascaderNode<T>[],
parentKey: string,
depth: number
) => {
const sentinel = sentinels?.has(parentKey) ?? false
const setSize = nodes.length + (sentinel ? 1 : 0)
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
const branch = isCascaderBranch(index, node)
const isExpanded = branch && expanded.has(node.value)
rows.push({
node,
depth,
branch,
expanded: isExpanded,
setSize,
posInSet: i + 1,
})
if (isExpanded) {
walk(index.childrenOf.get(node.value) ?? [], node.value, depth + 1)
}
}
if (sentinel) {
rows.push({
node: createCascaderMoreNode<T>(parentKey, nodes.length),
depth,
branch: false,
expanded: false,
setSize,
posInSet: setSize,
})
}
}
walk(index.roots, CASCADER_ROOT_KEY, 0)
return rows
}
/** Shortens a path; the ellipsis segment still carries the hidden nodes. */
export function collapseCascaderPath<T>(
path: readonly CascaderNode<T>[],
options: { maxSegments?: number; collapse?: CascaderCollapse } = {}
): CascaderPathSegment<T>[] {
const { maxSegments = 3, collapse = "middle" } = options
const asNodes = (
nodes: readonly CascaderNode<T>[]
): CascaderPathSegment<T>[] =>
nodes.map((node) => ({ type: "node" as const, node }))
if (collapse === "none" || maxSegments <= 0 || path.length <= maxSegments) {
return asNodes(path)
}
if (collapse === "start") {
const tail = path.slice(path.length - maxSegments)
return [
{ type: "ellipsis", hidden: path.slice(0, path.length - maxSegments) },
...asNodes(tail),
]
}
// "middle": keep the root for orientation and as much of the tail as fits.
const head = path.slice(0, 1)
const tailCount = maxSegments - 1
const tail = path.slice(path.length - tailCount)
const hidden = path.slice(1, path.length - tailCount)
if (hidden.length === 0) return asNodes(path)
return [...asNodes(head), { type: "ellipsis", hidden }, ...asNodes(tail)]
}
/* -------------------------------------------------------------------------- */
/* Cascade selection */
/* -------------------------------------------------------------------------- */
/**
* Every LOADED node under `value`, root first. Reaching an unfetched descendant
* would mean a network request per level of the subtree on every selection.
*/
export function collectCascaderSubtree<T>(
index: CascaderIndex<T>,
value: string
): CascaderNode<T>[] {
const root = index.byValue.get(value)
if (!root) return []
const out: CascaderNode<T>[] = []
const seen = new Set<string>()
const walk = (node: CascaderNode<T>) => {
if (seen.has(node.value)) return
seen.add(node.value)
out.push(node)
const children = index.childrenOf.get(node.value)
if (children) for (const child of children) walk(child)
}
walk(root)
return out
}
/**
* Toggles `value` with its loaded subtree, then reconciles ancestors. One flat
* array, no hidden "checked" state: a branch is in the selection exactly when
* every selectable loaded child of it is, which keeps a row's checked state an
* O(1) lookup. Nodes `isSelectable` rejects are skipped down AND ignored up,
* the pressed node excepted.
*/
export function applyCascadeSelection<T>(
index: CascaderIndex<T>,
selected: readonly string[],
value: string,
select: boolean,
isSelectable: (node: CascaderNode<T>) => boolean = () => true
): string[] {
const next = new Set(selected)
const subtree = collectCascaderSubtree(index, value)
// An unknown value still toggles itself, so an early async selection lands.
if (subtree.length === 0) {
if (select) next.add(value)
else next.delete(value)
}
for (const node of subtree) {
// The pressed node is exempt: it was committed, so it is selectable.
if (node.value !== value && !isSelectable(node)) continue
if (select) next.add(node.value)
else next.delete(node.value)
}
// Bottom up: a parent can only answer once its children have.
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const parent = index.byValue.get(cursor)
const children = index.childrenOf.get(cursor) ?? []
const selectable = children.filter((child) => isSelectable(child))
const full =
selectable.length > 0 &&
selectable.every((child) => next.has(child.value)) &&
// Never promote a node the consumer said may not be committed. Under
// `selectable="leaf"` no branch qualifies, hence the `cascade` warning.
!!parent &&
isSelectable(parent)
if (full) next.add(cursor)
else next.delete(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
return Array.from(next)
}
/**
* How many selected nodes each value has BELOW it, itself excluded. Walks UP
* from each selection: a per-row subtree scan would be quadratic and re-paid.
*/
export function getCascaderSelectedDescendants<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const value of selected) {
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
counts.set(cursor, (counts.get(cursor) ?? 0) + 1)
cursor = index.parentOf.get(cursor) ?? null
}
}
return counts
}
/** The PARTIALLY selected values, read off counts the caller already holds. */
export function getCascaderIndeterminateFrom(
counts: ReadonlyMap<string, number>,
selected: readonly string[]
): Set<string> {
const selectedSet = new Set(selected)
const partial = new Set<string>()
for (const [value, count] of counts) {
if (count > 0 && !selectedSet.has(value)) partial.add(value)
}
return partial
}
/** The one-call form; the root uses the two halves above and its own counts. */
export function getCascaderIndeterminate<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Set<string> {
return getCascaderIndeterminateFrom(
getCascaderSelectedDescendants(index, selected),
selected
)
}
/** How `getCascaderCheckedValues` condenses a full-closure selection. */
export type CascaderCheckedStrategy = "all" | "parent" | "child"
/**
* The cascade selection under a reporting strategy. DERIVED OUTPUT ONLY: the
* STORED value stays the full closure, which keeps a checked state an O(1)
* lookup. `"all"` is the closure; `"parent"` drops a value whose parent is
* selected; `"child"` drops one with a selected child.
*/
export function getCascaderCheckedValues<T>(
index: CascaderIndex<T>,
selected: readonly string[],
strategy: CascaderCheckedStrategy
): readonly string[] {
if (strategy === "all") return selected
const set = new Set(selected)
if (strategy === "parent") {
return selected.filter((value) => {
const parent = index.parentOf.get(value)
return parent == null || !set.has(parent)
})
}
return selected.filter((value) => {
const children = index.childrenOf.get(value)
if (!children?.length) return true
return !children.some((child) => set.has(child.value))
})
}
/** Values in `nodes` whose label collides, so the chip must show its path. */
export function findAmbiguousCascaderLabels<T>(
nodes: readonly CascaderNode<T>[]
): Set<string> {
const firstByLabel = new Map<string, string>()
const ambiguous = new Set<string>()
for (const node of nodes) {
const first = firstByLabel.get(node.label)
if (first === undefined) {
firstByLabel.set(node.label, node.value)
continue
}
ambiguous.add(node.value)
ambiguous.add(first)
}
return ambiguous
}
/* -------------------------------------------------------------------------- */
/* Development only */
/* -------------------------------------------------------------------------- */
/** Warnings already emitted. Module scoped, or they repeat once per render. */
const CASCADER_WARNED = new Set<string>()
/** Warns once per `key`, never in production, never by throwing. */
export function warnCascaderOnce(key: string, message: string): void {
if (process.env.NODE_ENV === "production") return
if (CASCADER_WARNED.has(key)) return
CASCADER_WARNED.add(key)
console.warn(`[Cascader] ${message}`)
}
/** Empties the ledger. Tests only; a warning is meant to be seen once. */
export function resetCascaderWarnings(): void {
CASCADER_WARNED.clear()
}
/** What a dev-time scan of the consumer's `items` found wrong with it. */
export interface CascaderDataIssues {
/** Values appearing more than once. First wins, so a duplicate drops a row. */
duplicates: string[]
/** Values on a `getParent` cycle. Depth is clamped, so the tree reads wrong. */
cycles: string[]
}
/** Dev-time scan, kept out of `buildCascaderIndex` so the build stays hot. */
export function findCascaderDataIssues<T>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderDataIssues {
const seen = new Set<string>()
const duplicates = new Set<string>()
const flat: CascaderNode<T>[] = []
const visit = (nodes: readonly CascaderNode<T>[] | undefined) => {
for (const node of nodes ?? []) {
// The build skips a nullish entry, and this runs BEFORE its warning.
if (node == null) continue
if (seen.has(node.value)) duplicates.add(node.value)
else seen.add(node.value)
flat.push(node)
// Flat mode walks them too: BOTH `children` and `getParent` is a bug.
if (node.children?.length) visit(node.children)
}
}
visit(items)
const cycles: string[] = []
if (getParent) {
const parentOf = new Map<string, string | null>()
for (const node of flat) {
if (parentOf.has(node.value)) continue
const raw = getParent(node)
parentOf.set(node.value, raw != null && seen.has(raw) ? raw : null)
}
for (const node of flat) {
const walked = new Set<string>([node.value])
let cursor = parentOf.get(node.value) ?? null
while (cursor != null) {
if (walked.has(cursor)) {
cycles.push(node.value)
break
}
walked.add(cursor)
cursor = parentOf.get(cursor) ?? null
}
}
}
return { duplicates: Array.from(duplicates), cycles }
}
@@ -0,0 +1,637 @@
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
import {
collapseCascaderPath,
getCascaderFooterStops,
getCascaderPath,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderCollapse,
CascaderNode,
CascaderValueDisplay,
} from "@/components/reui/cascader/cascader-types"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evobgp/ui/lib/utils"
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
/* -------------------------------------------------------------------------- */
/* Separator */
/* -------------------------------------------------------------------------- */
function PathChevron() {
return (
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
)
}
/* -------------------------------------------------------------------------- */
/* Nav */
/* -------------------------------------------------------------------------- */
export type CascaderNavProps = useRender.ComponentProps<"div">
/**
* Header: back control, search input, and the separator under them. The
* breadcrumb belongs with the list below: it describes the rows, not the field.
*/
function CascaderNav({ className, ...props }: CascaderNavProps) {
const defaultProps = {
"data-slot": "cascader-nav",
className: cn(
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
// even padding read too tall. 6px beside the field, 4px above and below.
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
className
),
}
return useRender({
defaultTagName: "div",
render: props.render,
props: mergeProps<"div">(defaultProps, props),
})
}
/* -------------------------------------------------------------------------- */
/* Back */
/* -------------------------------------------------------------------------- */
export interface CascaderBackProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: React.ReactNode
}
/** Pops one level. Renders nothing at the root, so no dead header space. */
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
const { popLevel, labels, mode } = useCascaderActions()
const { path } = useCascaderState()
// Checked after `useRender`, never before it: an early return would change
// the hook count between the root level and any deeper one.
const hidden = mode !== "drill" || path.length === 0
const defaultProps = {
"data-slot": "cascader-back",
type: "button" as const,
"aria-label": labels.back,
onClick: () => popLevel(),
className: cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
// A notch under the row height: an affordance, not a second field.
"size-6",
className
),
children: children ?? (
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
),
}
const element = useRender({
defaultTagName: "button",
render: props.render,
props: mergeProps<"button">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Breadcrumb */
/* -------------------------------------------------------------------------- */
export interface CascaderBreadcrumbProps extends Omit<
useRender.ComponentProps<"nav">,
"children"
> {
/** Maximum visible node segments before the middle collapses. */
maxSegments?: number
collapse?: CascaderCollapse
/** Clicking a segment navigates back to that level. Defaults to true. */
interactive?: boolean
}
/**
* Compact trail of the current level AND its ancestors, collapsed by the same
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
* only the places you are not.
*/
function CascaderBreadcrumb({
className,
maxSegments = 3,
collapse = "middle",
interactive = true,
...props
}: CascaderBreadcrumbProps) {
const { goToDepth, mode, labels } = useCascaderActions()
const { path, index } = useCascaderState()
// The WHOLE path, current level included; unresolvable entries drop out.
const nodes = path
.map((value) => index.byValue.get(value))
.filter(Boolean) as CascaderNode[]
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
// `collapseCascaderPath` never collapses the last node away, so this is
// always the final segment - derived rather than assumed, so a `collapse`
// mode that ever changes that cannot mark an ancestor as the current page.
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
const hidden = mode !== "drill" || nodes.length === 0
const goTo = (node: CascaderNode) => {
const depth = path.indexOf(node.value)
if (depth < 0) return
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
// clears the query and drops any pending navigation.
goToDepth(depth + 1)
}
const defaultProps = {
"data-slot": "cascader-breadcrumb",
"aria-label": labels.breadcrumbLabel,
className: cn(
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
// Lines up with the ROWS, not the header: each style's list padding plus
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
"px-2.5",
className
),
children: segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? <PathChevron /> : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-breadcrumb-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="shrink-0"
>
&hellip;
</span>
) : interactive && segment.node.value !== currentValue ? (
<button
type="button"
data-slot="cascader-breadcrumb-item"
onClick={() => goTo(segment.node)}
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
>
{segment.node.label}
</button>
) : (
<span
data-slot="cascader-breadcrumb-item"
/* The level on screen: a span because it goes nowhere, plus
`aria-current="page"` so it is not read as one of a flat list. */
{...(segment.node.value === currentValue
? { "aria-current": "page" as const }
: null)}
className={cn(
"max-w-32 truncate",
segment.node.value === currentValue &&
"text-foreground font-medium"
)}
>
{segment.node.label}
</span>
)}
</React.Fragment>
)),
}
const element = useRender({
defaultTagName: "nav",
render: props.render,
props: mergeProps<"nav">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
/** Renders the back control inline, before the field. Defaults to true. */
showBack?: boolean
}
/** Base UI hands its input handlers an event carrying the veto hook. */
type CascaderInputKeyEvent = Parameters<
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
>[0]
/**
* Search field. MUST render inside the positioner: only there does Base UI skip
* the refill from the committed selection that would fight every level swap.
*/
function CascaderInput({
className,
showBack = true,
placeholder,
onKeyDown,
"aria-describedby": ariaDescribedBy,
...props
}: CascaderInputProps) {
const {
labels,
popLevel,
mode,
getHighlighted,
isBranch,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, renderedItems, treeRows } =
useCascaderState()
const resolvedPlaceholder =
placeholder ?? resolveCascaderSearchLabel(labels, currentParent?.label)
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
const backVisible = showBack && mode === "drill" && path.length > 0
const hintId = `${baseId}-hint`
// Base UI names no list while inline; columns mode moves the real listbox to
// the deepest panel, which is the `path.length` one.
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
// The direction the HINT is worded for. `handleKeyDown` re-reads
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
// the same check runs once against the mounted DOM (on the hint span, so no
// second ref into Base UI's input). SSR renders the "ltr" default and
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
// the stylesheet are declarations, not state, so nothing to resubscribe to.
const hintRef = React.useRef<HTMLSpanElement>(null)
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
const hint = hintRef.current
if (!hint) return
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
}, [direction])
/**
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
*/
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
const rowIndex = () => {
const highlighted = getHighlighted()
if (!highlighted) return -1
return treeRows.findIndex((row) => row.node.value === highlighted.value)
}
let current = rowIndex()
for (let step = 0; step < treeRows.length; step += 1) {
if (current === -1 || current === targetIndex) return
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
field.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
)
const next = rowIndex()
const progressed =
key === "ArrowUp"
? next < current && next >= targetIndex
: next > current && next <= targetIndex
if (next === -1 || !progressed) return
current = next
}
}
/**
* The tree pattern's two level keys; Base UI leaves both alone (list
* navigation is vertical-only, the chip handler no-ops without
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
* APG defines them as "toward the children", not as a physical arrow.
*/
const handleTreeKeyDown = (
event: CascaderInputKeyEvent,
field: HTMLInputElement,
caretAtStart: boolean,
caretAtEnd: boolean,
forwardKey: "ArrowLeft" | "ArrowRight",
backKey: "ArrowLeft" | "ArrowRight"
) => {
const forward = event.key === forwardKey
if (!forward && event.key !== backKey) return
if (forward ? !caretAtEnd : !caretAtStart) return
const highlighted = getHighlighted()
if (!highlighted) return
const rowIndex = treeRows.findIndex(
(row) => row.node.value === highlighted.value
)
if (rowIndex < 0) return
const row = treeRows[rowIndex]
if (forward) {
if (!row.branch) return
event.preventDefault()
if (!row.expanded) {
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
// children, so the keyboard path in matches the expander.
navigate(row.node)
return
}
// `flattenCascaderTree` emits children right after their parent, so the
// first child is the next row. The depth check covers an empty branch.
const child = treeRows[rowIndex + 1]
if (child && child.depth === row.depth + 1) {
moveHighlightTo(field, rowIndex + 1)
}
return
}
if (row.branch && row.expanded) {
event.preventDefault()
toggleExpanded(row.node.value)
return
}
const parentValue = index.parentOf.get(row.node.value)
if (!parentValue) return
const parentIndex = treeRows.findIndex(
(entry) => entry.node.value === parentValue
)
if (parentIndex < 0) return
event.preventDefault()
moveHighlightTo(field, parentIndex)
}
const handleKeyDown = (event: CascaderInputKeyEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const field = event.currentTarget
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
const caretAtEnd =
field.selectionStart === query.length &&
field.selectionEnd === query.length
// ArrowDown at the END of the list hands real focus to the pinned footer,
// the key a combobox is actually navigated with; Tab still works and still
// skips the scroll area. An EMPTY list hands off at once, and the footer
// owns the way back. Counted from state, so a windowed list still answers.
if (event.key === "ArrowDown" && !event.altKey) {
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
const lastValue =
mode === "tree"
? treeRows[rowCount - 1]?.node.value
: renderedItems[rowCount - 1]?.value
const highlighted = getHighlighted()
const atEnd =
rowCount === 0 ||
(highlighted != null && highlighted.value === lastValue)
if (atEnd) {
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
if (stop) {
event.preventDefault()
// Base UI's handler is deliberately NOT vetoed: its wrap's first
// press CLEARS the highlight, so no stale row stays active behind the
// focused command, and the emptied highlight completes the ring.
stop.focus()
return
}
}
}
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
// branch and ArrowRight goes back. They act only at the caret edge, and
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
const rtl = isCascaderRtl(field, direction)
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
if (mode === "tree") {
handleTreeKeyDown(
event,
field,
caretAtStart,
caretAtEnd,
forwardKey,
backKey
)
return
}
if (event.key === forwardKey && caretAtEnd) {
const highlighted = getHighlighted()
if (highlighted && isBranch(highlighted)) {
event.preventDefault()
navigate(highlighted)
return
}
}
// Backspace on an empty query pops a level too, so the keyboard way out of
// a level is symmetric with typing into it.
if (
(event.key === backKey && caretAtStart) ||
(event.key === "Backspace" && query.length === 0)
) {
if (path.length > 0) {
event.preventDefault()
popLevel()
}
}
}
return (
<div
data-slot="cascader-input-row"
className={cn(
"flex shrink-0 items-center gap-1",
/* Each style sizes a combobox search field only as a DIRECT child of
the popup; this row is nested, so the ladder is mirrored here rather
than hardcoding one height for all eight styles. */
"h-8"
)}
>
{showBack ? <CascaderBack /> : null}
<ComboboxPrimitive.Input
data-slot="cascader-input"
placeholder={resolvedPlaceholder}
onKeyDown={handleKeyDown}
/* Nothing on screen says the level keys exist. */
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
and an embedded panel is permanently expanded, so supply both. */
{...(inline
? { "aria-expanded": true, "aria-controls": listId }
: null)}
/* No trigger to carry the invalid state in the `inline` case. */
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
className={cn(
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
backVisible && "ps-0",
className
)}
{...props}
/>
<span id={hintId} ref={hintRef} className="sr-only">
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
uses a `dir` attribute instead of `DirectionProvider`. */}
{labels.keyboardHint(mode, hintDir)}
</span>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Value */
/* -------------------------------------------------------------------------- */
export interface CascaderValueProps extends Omit<
useRender.ComponentProps<"span">,
"children"
> {
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
display?: CascaderValueDisplay
maxSegments?: number
collapse?: CascaderCollapse
separator?: React.ReactNode
/** Renders the selected node's icon before the trail. */
showIcon?: boolean
placeholder?: React.ReactNode
/** Replaces the whole rendering. Receives the resolved selection and path. */
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
}
/**
* Trigger display. Defaults to the collapsed selection path rather than a bare
* leaf label, which in a nested picker is frequently ambiguous.
*/
function CascaderValue({
className,
display = "path",
maxSegments = 3,
collapse = "middle",
separator,
showIcon = true,
placeholder,
children,
...props
}: CascaderValueProps) {
const { labels, multiple, resolveNode } = useCascaderActions()
const { index, selectedValues } = useCascaderState()
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
// children, a removed item) must still render a label, not an empty trigger.
const selected = selectedValues.map(resolveNode)
const resolvedPath =
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
const path =
resolvedPath.length > 0
? resolvedPath
: selected.length === 1
? selected
: []
let content: React.ReactNode
if (children) {
content = children(selected, path)
} else if (selectedValues.length === 0) {
content = (
<span className="text-muted-foreground truncate">{placeholder}</span>
)
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
content = labels.selectedCount(selectedValues.length)
} else {
const leaf = path[path.length - 1] ?? selected[0]
const segments =
display === "leaf"
? [{ type: "node" as const, node: leaf }]
: collapseCascaderPath(path, { maxSegments, collapse })
content = (
<>
{showIcon && leaf?.icon ? (
<span
data-slot="cascader-value-icon"
className="text-muted-foreground flex shrink-0 items-center"
>
{leaf.icon}
</span>
) : null}
{segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? (separator ?? <PathChevron />) : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-value-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="text-muted-foreground shrink-0"
>
&hellip;
</span>
) : (
<span
className={cn(
"truncate",
i < segments.length - 1 && "text-muted-foreground"
)}
>
{segment.node?.label}
</span>
)}
</React.Fragment>
))}
</>
)
}
const defaultProps = {
"data-slot": "cascader-value",
className: cn("flex min-w-0 items-center gap-1 truncate", className),
children: content,
}
return useRender({
defaultTagName: "span",
render: props.render,
props: mergeProps<"span">(defaultProps, props),
})
}
export {
CascaderNav,
CascaderBack,
CascaderBreadcrumb,
CascaderInput,
CascaderValue,
}
@@ -0,0 +1,209 @@
import type * as React from "react"
/**
* A node in the cascader tree. Two input shapes normalize to one internal
* index: nested (nodes carry `children`), or flat adjacency plus the root's
* `getParent`, which skips the re-nesting step for large datasets.
*/
export interface CascaderNode<T = unknown> {
/** Stable id. Also the committed selection value. */
value: string
label: string
icon?: React.ReactNode
description?: string
children?: CascaderNode<T>[]
/** Declares a branch before load; async nodes read as leaves without it. */
hasChildren?: boolean
/** Trailing count. Defaults to known children; set it for async nodes. */
count?: number
disabled?: boolean
keywords?: string[]
/**
* Only read on a node nested inside a `getChildren` result. For the level a
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
*/
hasMore?: boolean
data?: T
}
/**
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
* be passed where an option is expected and join the ring, filter or selection.
*/
export interface CascaderActionItem {
/** Stable key. Falls back to the label when it is a string, then the index. */
value?: string
label: React.ReactNode
icon?: React.ReactNode
disabled?: boolean
/** Ignored when `items` is present - a flyout opens instead. */
onSelect?: () => void
/** Turns the row into a submenu trigger. One level deep on purpose. */
items?: CascaderActionItem[]
/** Consecutive entries sharing a heading are drawn under one. */
group?: string
}
/** Panel layout. See the docs for the keyboard map of each. */
export type CascaderMode = "drill" | "columns" | "tree"
export type CascaderSearchScope = "level" | "deep"
/**
* Which nodes may be committed. The predicate arm is generic over the payload
* rather than quantified per call: a per-call `<T>` would force every predicate
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
* never satisfy it.
*/
export type CascaderSelectable<T = unknown> =
| "leaf"
| "any"
| ((node: CascaderNode<T>) => boolean)
export type CascaderCollapse = "middle" | "start" | "none"
export type CascaderValueDisplay = "path" | "leaf" | "count"
export type CascaderChangeReason = "select" | "deselect" | "clear"
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
export interface CascaderChangeDetails<T = unknown> {
/** The node committed or toggled. Null when the selection was cleared. */
node: CascaderNode<T> | null
/** Ancestor chain of `node`, root first, node last. */
path: CascaderNode<T>[]
nodes: CascaderNode<T>[]
reason: CascaderChangeReason
}
/** Normalized tree, built once per `items` identity, shared by every mode. */
export interface CascaderIndex<T = unknown> {
byValue: Map<string, CascaderNode<T>>
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
childrenOf: Map<string, CascaderNode<T>[]>
parentOf: Map<string, string | null>
/** Zero-based depth by node value. */
depthOf: Map<string, number>
/** Top level nodes, in input order. */
roots: CascaderNode<T>[]
/** Every node in a stable, depth-first order. Used by deep search. */
all: CascaderNode<T>[]
}
export interface CascaderFlatNode<T = unknown> {
node: CascaderNode<T>
depth: number
/** Known children, or a declared `hasChildren`. */
branch: boolean
expanded: boolean
/** Sibling count, plus one when the level has a paging row. */
setSize: number
/** One-based index among siblings. */
posInSet: number
}
/** One segment of a rendered path, after collapsing. */
export type CascaderPathSegment<T = unknown> =
| { type: "node"; node: CascaderNode<T> }
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
/**
* Async load state for one node's children. Deliberately WITHOUT a `status`
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
* eventually disagree with the first.
*/
export interface CascaderLoadState {
loading: boolean
error: boolean
hasMore: boolean
/** Opaque cursor handed back to `getChildren` for the next page. */
cursor?: string
}
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
export type CascaderLoadReason =
| "level"
| "more"
| "prefetch"
| "retry"
| "resolve"
/** Argument handed to `getChildren`. */
export interface CascaderLoadContext {
/** Aborted when the request is superseded or the popup closes. */
signal: AbortSignal
cursor?: string
reason?: CascaderLoadReason
}
/** Value returned by `getChildren`. A bare array is also accepted. */
export interface CascaderLoadResult<T = unknown> {
items: CascaderNode<T>[]
nextCursor?: string
/** Defaults to whether `nextCursor` was supplied. */
hasMore?: boolean
}
/** Argument handed to `onSearch`. */
export interface CascaderSearchContext {
signal: AbortSignal
/** The path the user is searching within, deepest last. */
path: string[]
}
/**
* Every user facing string, so the primitive ships no hardcoded copy. The
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
* force this object to carry the item generic. `*Announcement` is live-region.
*/
export interface CascaderLabels {
search: string | ((parentLabel?: string) => string)
back: string
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
loading: string
loadingMore: string
loadMore: string
error: string
retry: string
empty: string
/** Rendered by `CascaderValue` when `display="count"`. */
selectedCount: (count: number) => string
breadcrumbLabel: string
chipsLabel: string
removeChip: (label: string) => string
/** Trail separator. Not every locale writes one with a slash. */
pathSeparator: string
/** Names the root level wherever there is no parent node to name it. */
rootLevel: string
itemCount: (count: number) => string
/** Appended to a branch row's name outside tree mode: it opens another list. */
branchAffordance: string
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
selectedState: string
/** Same for the mixed state, which a `role="button"` row may not carry. */
partiallySelectedState: string
columnsLabel: string
actionsLabel: string
submenuAffordance: string
panelLabel: string
/** Read per mode AND per direction: the level keys mirror in RTL. */
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
rootAnnouncement: (count: number) => string
expandedAnnouncement: (label: string, count: number) => string
collapsedAnnouncement: (label: string) => string
levelAnnouncement: (
parentLabel: string,
depth: number,
count: number
) => string
resultsAnnouncement: (count: number) => string
maxReachedAnnouncement: (max: number) => string
/** `count` is the descendants swept along; `selecting` is the direction. */
cascadeAnnouncement: (
label: string,
count: number,
selecting: boolean
) => string
searchingAnnouncement: string
}
@@ -0,0 +1,457 @@
"use client"
import * as React from "react"
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
import {
useCascaderActions,
useCascaderHighlight,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
CascaderItems,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
defaultRangeExtractor,
useVirtualizer,
type Range,
type Virtualizer,
} from "@tanstack/react-virtual"
/**
* Windowing for the cascader, in its own file so the primitive's own install
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
*
* 1. A windowed row MUST carry an explicit `index`; the fallback
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
* renders empty, dangling `aria-activedescendant`.
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
* skips registration, `CompositeList` truncates `elementsRef` to zero and
* the first arrow key highlights nothing), so rows stay plain until the root
* flips `virtualized`, gated inside `CascaderItem`.
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
* implicitly wraps one in a Collection: a Collection renders every filtered
* item.
*/
/* -------------------------------------------------------------------------- */
/* Virtualizer */
/* -------------------------------------------------------------------------- */
export interface UseCascaderVirtualizerOptions {
count: number
getScrollElement: () => HTMLElement | null
estimateSize?: number
overscan?: number
/** Key rows by node value, never by index: a tree expand shifts every index. */
getItemKey: (index: number) => string | number
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
activeIndex?: number
}
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
/**
* The cascader's virtualizer: TanStack plus the highlight pinning and
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
* the caller: emitted from a layout effect, it can be one commit ahead of a
* level that just shrank and would otherwise scroll to a row that is gone.
*/
export function useCascaderVirtualizer({
count,
getScrollElement,
estimateSize = 32,
overscan = 8,
getItemKey,
activeIndex = -1,
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
const rangeExtractor = React.useCallback(
(range: Range) => {
const indices = new Set(defaultRangeExtractor(range))
if (active !== -1) indices.add(active)
return Array.from(indices).sort((a, b) => a - b)
},
[active]
)
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
count,
getScrollElement,
estimateSize: measureEstimate,
overscan,
getItemKey,
rangeExtractor,
})
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
React.useEffect(() => {
if (active === -1) return
virtualizer.scrollToIndex(active, { align: "auto" })
}, [active, count, virtualizer])
return virtualizer
}
/* -------------------------------------------------------------------------- */
/* Geometry */
/* -------------------------------------------------------------------------- */
interface CascaderVirtualGutter {
block: number
inline: number
}
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
/**
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
* a flat zero). Measured because every style sets its own: a windowed row is
* positioned against this box's PADDING box while the scroll-height spacer is
* an in-flow child of its CONTENT box, so without it rows sit flush.
*/
function useCascaderVirtualGutter(
contentElement: HTMLElement | null
): CascaderVirtualGutter {
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
React.useLayoutEffect(() => {
if (!contentElement) return
const styles = getComputedStyle(contentElement)
const block = Number.parseFloat(styles.paddingTop) || 0
const inline =
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
setGutter((previous) =>
previous.block === block && previous.inline === inline
? previous
: { block, inline }
)
}, [contentElement])
return gutter
}
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
function cascaderVirtualRowStyle(
start: number,
gutter: CascaderVirtualGutter
): React.CSSProperties {
return {
position: "absolute",
top: 0,
insetInlineStart: gutter.inline,
insetInlineEnd: gutter.inline,
transform: `translateY(${start + gutter.block}px)`,
}
}
/**
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
* at it would read a full `clientHeight` and render every row. `closest` rather
* than `parentElement`, plus a fallback to the content element, keeps a
* hand-written `overflow-y-auto` container working.
*/
function cascaderScrollElement(
content: HTMLElement | null
): HTMLElement | null {
if (!content) return null
return (
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
content
)
}
/**
* `role="presentation"` keeps the listbox owning nothing but options, and this
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
*/
function CascaderVirtualSpacer({
height,
onContentElement,
}: {
height: number
onContentElement: (element: HTMLElement | null) => void
}) {
return (
<div
ref={(node) => onContentElement(node?.parentElement ?? null)}
role="presentation"
data-slot="cascader-virtual-spacer"
style={{ height }}
/>
)
}
/* -------------------------------------------------------------------------- */
/* Rows */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualItemsProps {
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
estimateSize?: number
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
overscan?: number
}
/**
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
* registration is a LAYOUT effect because the root derives `virtualized` from
* it and the flip has to land before the browser paints. Until the root has
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
* indexed rows while Base UI still owns the composite list.
*/
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
const { virtualized, registerVirtualRenderer } = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
if (!virtualized) return <CascaderItems />
return <CascaderVirtualRows {...props} />
}
function CascaderVirtualRows({
estimateSize,
overscan,
}: CascaderVirtualItemsProps) {
const {
estimateRowSize,
overscan: rootOverscan,
mode,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { renderedItems, treeRows, deepResults, loadStates } =
useCascaderState()
// Re-renders on every highlight change, and has to: the pinned row and the
// scroll target are both derived from it.
const highlight = useCascaderHighlight()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const tree = mode === "tree"
const count = tree ? treeRows.length : renderedItems.length
const showPath = !tree && deepResults !== null
const getItemKey = React.useCallback(
(index: number) =>
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
index,
[tree, treeRows, renderedItems]
)
const virtualizer = useCascaderVirtualizer({
count,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex: highlight.index,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const flat = tree ? treeRows[row.index] : undefined
const node = tree ? flat?.node : renderedItems[row.index]
// For one commit after a query narrows the level, the window can name
// a row it no longer has; the count recomputes on the same tick.
if (!node) return null
return (
<CascaderItem
key={row.key}
/* Measured, not estimated: rows are two lines with a `description`
and three in deep search, and row height is per style. An
estimate alone would make `scrollToIndex` land on the wrong row. */
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
index={row.index}
depth={flat?.depth}
expanded={flat?.expanded}
branch={flat ? flat.branch : isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
showPath={showPath}
aria-setsize={flat ? flat.setSize : count}
aria-posinset={flat ? flat.posInSet : row.index + 1}
/>
)
})}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Column */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
column: CascaderColumn
}
/**
* Windowed replacement for one Miller column, passed through the
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
* scroll independently, and only the deepest is Base UI's listbox: the trail
* behind is `as="button"` rows outside Base UI, so it windows on its own row
* count rather than waiting for the root to flip.
*/
function CascaderVirtualColumn({
column,
estimateSize,
overscan,
}: CascaderVirtualColumnProps) {
const {
virtualized,
registerVirtualRenderer,
virtualize,
virtualizeThreshold,
} = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
const windowed = column.active
? virtualized
: (virtualize ?? column.items.length >= virtualizeThreshold)
if (!windowed) return <CascaderColumnPanel column={column} />
return (
<CascaderColumnPanel column={column} virtualized>
{column.active ? (
<CascaderVirtualActiveColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
/>
) : (
<CascaderVirtualColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
activeIndex={-1}
/>
)}
</CascaderColumnPanel>
)
}
/** Only the active column subscribes to the highlight; a trail column must not. */
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
const highlight = useCascaderHighlight()
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
}
function CascaderVirtualColumnRows({
column,
estimateSize,
overscan,
activeIndex,
}: CascaderVirtualColumnProps & { activeIndex: number }) {
const {
estimateRowSize,
overscan: rootOverscan,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { loadStates } = useCascaderState()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const items = column.items
const getItemKey = React.useCallback(
(index: number) => items[index]?.value ?? index,
[items]
)
const virtualizer = useCascaderVirtualizer({
count: items.length,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const node = items[row.index]
if (!node) return null
const open = node.value === column.activeValue
return (
<CascaderItem
key={row.key}
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
as={column.active ? "option" : "button"}
depth={column.depth}
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Only the listbox column may carry an index or listbox metadata.
A trail row is a `role="button"`, which allows neither. */
{...(column.active
? {
index: row.index,
"aria-setsize": items.length,
"aria-posinset": row.index + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
})}
</>
)
}
export { CascaderVirtualColumn, CascaderVirtualItems }
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
"use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button" import { Button } from "@evobgp/ui/components/button"
@@ -13,8 +16,8 @@ import {
import { Separator } from "@evobgp/ui/components/separator" import { Separator } from "@evobgp/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react" import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> { interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<TData, TValue> column?: Column<DataGridFeatures, TData, TValue>
title?: string title?: string
options: { options: {
label: string label: string
@@ -23,7 +26,7 @@ interface DataGridColumnFilterProps<TData, TValue> {
}[] }[]
} }
function DataGridColumnFilter<TData, TValue>({ function DataGridColumnFilter<TData extends object, TValue>({
column, column,
title, title,
options, options,
@@ -61,7 +64,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="hidden space-x-1 lg:flex"> <div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? ( {selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal"> <Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} выбрано {selectedValues.size} selected
</Badge> </Badge>
) : ( ) : (
options options
@@ -94,7 +97,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm"> <div className="text-muted-foreground py-6 text-center text-sm">
Ничего не найдено. No results found.
</div> </div>
) : ( ) : (
<div className="p-1"> <div className="p-1">
@@ -170,7 +173,7 @@ function DataGridColumnFilter<TData, TValue>({
}} }}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none" className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
> >
Сбросить фильтры Clear filters
</div> </div>
</div> </div>
</> </>
@@ -1,11 +1,12 @@
"use client" import { memo, useMemo } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import { import {
getColumnHeaderLabel, getColumnHeaderLabel,
useDataGrid, useDataGrid,
} from "@/components/reui/data-grid/data-grid" } from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { Subscribe } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button" import { Button } from "@evobgp/ui/components/button"
@@ -25,10 +26,10 @@ import {
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps< interface DataGridColumnHeaderProps<
TData, TData extends object,
TValue, TValue,
> extends HTMLAttributes<HTMLDivElement> { > extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue> column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string title?: string
icon?: ReactNode icon?: ReactNode
@@ -38,7 +39,7 @@ interface DataGridColumnHeaderProps<
visibility?: boolean visibility?: boolean
} }
function DataGridColumnHeaderInner<TData, TValue>({ function DataGridColumnHeaderInner<TData extends object, TValue>({
column, column,
title, title,
icon, icon,
@@ -46,13 +47,19 @@ function DataGridColumnHeaderInner<TData, TValue>({
filter, filter,
visibility = false, visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) { }: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid() const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column) const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder // TanStack's columnOrder defaults to [] until a consumer seeds it; fall
// back to the definition order so Move Left/Right work out of the box.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey = const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility) ? JSON.stringify(table.state.columnVisibility)
: "" : ""
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned() const isPinned = column.getIsPinned()
@@ -166,21 +173,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
items.push( items.push(
<DropdownMenuItem <DropdownMenuItem
key="pin-left" key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")} onClick={() => column.pin(isPinned === "start" ? false : "start")}
> >
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" /> <ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span> <span className="grow">Pin to left</span>
{isPinned === "left" && ( {isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" /> <CheckIcon className="text-primary size-4 opacity-100!" />
)} )}
</DropdownMenuItem>, </DropdownMenuItem>,
<DropdownMenuItem <DropdownMenuItem
key="pin-right" key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")} onClick={() => column.pin(isPinned === "end" ? false : "end")}
> >
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" /> <ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span> <span className="grow">Pin to right</span>
{isPinned === "right" && ( {isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" /> <CheckIcon className="text-primary size-4 opacity-100!" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@@ -289,7 +296,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button <Button
variant="ghost" variant="ghost"
className={headerButtonClassName} className={headerButtonClassName}
disabled={isLoading || recordCount === 0} disabled={isLoading}
> >
{icon && icon} {icon && icon}
{resolvedTitle} {resolvedTitle}
@@ -323,7 +330,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button <Button
variant="ghost" variant="ghost"
className={headerButtonClassName} className={headerButtonClassName}
disabled={isLoading || recordCount === 0} disabled={isLoading}
onClick={handleSort} onClick={handleSort}
> >
{icon && icon} {icon && icon}
@@ -342,8 +349,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
) )
} }
const DataGridColumnHeader = memo( const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
DataGridColumnHeaderInner TData extends object,
) as typeof DataGridColumnHeaderInner TValue,
>(
props: DataGridColumnHeaderProps<TData, TValue> & {
/** Internal: the state slices the header re-renders on. Not part of the public API. */
subscribedState?: unknown
}
) => ReactNode
/**
* Sort and pin state reaches this header through builder calls on `column`
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
* combination is the one v9's fresh-table-per-state-change does NOT cover:
* React Compiler is free to memoize against the stable column and never
* re-evaluate those reads, which shows up as frozen sort arrows and pin
* controls. The `Subscribe` below turns the slices this header actually reads
* into a real reactive dependency, and threading the selection through as a
* prop is what lets it past the `memo` - which would otherwise see unchanged
* props and skip the render anyway.
*/
function DataGridColumnHeader<TData extends object, TValue>(
props: DataGridColumnHeaderProps<TData, TValue>
) {
const { table } = useDataGrid()
return (
<Subscribe
source={table.store}
selector={(state) => ({
sorting: state.sorting,
columnPinning: state.columnPinning,
columnOrder: state.columnOrder,
columnVisibility: state.columnVisibility,
})}
>
{(subscribed) => (
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
)}
</Subscribe>
)
}
export { DataGridColumnHeader, type DataGridColumnHeaderProps } export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -1,6 +1,9 @@
import { ReactElement } from "react" "use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid" import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
import { import {
DropdownMenu, DropdownMenu,
@@ -11,11 +14,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu" } from "@evobgp/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({ function DataGridColumnVisibility<TData extends object>({
table, table,
trigger, trigger,
}: { }: {
table: Table<TData> table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>> trigger: ReactElement<Record<string, unknown>>
}) { }) {
return ( return (
@@ -1,6 +1,4 @@
"use client" import type { JSX, ReactNode } from "react"
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
@@ -32,18 +30,18 @@ interface DataGridPaginationProps {
ellipsisText?: string ellipsisText?: string
} }
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid() const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = { const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100], sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />, sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5, moreLimit: 5,
info: "{from}{to} из {count}", info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />, infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Строк на странице", rowsPerPageLabel: "Rows per page",
previousPageLabel: "Предыдущая страница", previousPageLabel: "Go to previous page",
nextPageLabel: "Следующая страница", nextPageLabel: "Go to next page",
ellipsisText: "...", ellipsisText: "...",
} }
@@ -51,8 +49,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const btnBaseClasses = "p-0 text-sm" const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180" const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex const pageIndex = table.state.pagination.pageIndex
const pageSize = table.getState().pagination.pageSize const pageSize = table.state.pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1 const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount) const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount() const pageCount = table.getPageCount()
@@ -161,7 +159,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<SelectTrigger className="w-16" size="sm"> <SelectTrigger className="w-16" size="sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" className="min-w-18"> <SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => ( {mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}> <SelectItem key={size} value={`${size}`}>
{size} {size}
@@ -1,11 +1,7 @@
import { "use client"
PointerEvent,
ReactNode, import { useCallback, useEffect, useRef, useState } from "react"
useCallback, import type { PointerEvent, ReactNode } from "react"
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
@@ -94,8 +90,9 @@ function DataGridScrollArea({
orientation = "both", orientation = "both",
...props ...props
}: DataGridScrollAreaProps) { }: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid() const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{ const dragRef = useRef<{
pointerId: number pointerId: number
@@ -114,6 +111,11 @@ function DataGridScrollArea({
const showVertical = orientation !== "horizontal" const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar = const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] = const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false) useState(false)
@@ -123,12 +125,19 @@ function DataGridScrollArea({
document.body.style.webkitUserSelect = "" document.body.style.webkitUserSelect = ""
}, []) }, [])
const resetMetrics = useCallback(() => { // The overlay is mounted one commit after the sync that detected overflow,
const container = containerRef.current // so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) { if (node) applyMetrics(node, metricsRef.current)
applyMetrics(container, INITIAL_METRICS) }, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
} }
setHasCustomVerticalOverflow((prev) => (prev ? false : prev)) setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
@@ -196,8 +205,13 @@ function DataGridScrollArea({
} }
if (!areMetricsEqual(nextMetrics, metricsRef.current)) { if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
} }
setHasCustomVerticalOverflow((prev) => setHasCustomVerticalOverflow((prev) =>
@@ -218,21 +232,6 @@ function DataGridScrollArea({
return return
} }
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0 let frame = 0
const scheduleSync = () => { const scheduleSync = () => {
@@ -240,25 +239,69 @@ function DataGridScrollArea({
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar) frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
} }
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer = const observer =
typeof ResizeObserver === "undefined" typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver(scheduleSync) : new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
observer?.observe(viewport) const observeElement = (element: HTMLElement | null) => {
observedElementsRef.current.header && if (element && observer && !observed.has(element)) {
observer?.observe(observedElementsRef.current.header) observer.observe(element)
observedElementsRef.current.table && observed.add(element)
observer?.observe(observedElementsRef.current.table) }
observedElementsRef.current.tableViewport && }
observer?.observe(observedElementsRef.current.tableViewport)
const resolveObservedElements = () => {
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => { return () => {
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
observer?.disconnect() observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync) viewport.removeEventListener("scroll", scheduleSync)
clearDragState() clearDragState()
} }
@@ -350,6 +393,10 @@ function DataGridScrollArea({
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area" data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
> >
@@ -369,6 +416,14 @@ function DataGridScrollArea({
data-orientation="horizontal" data-orientation="horizontal"
orientation="horizontal" orientation="horizontal"
className={SCROLLBAR_CLASSNAME} className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
> >
<ScrollAreaPrimitive.Thumb <ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb" data-slot="data-grid-thumb"
@@ -394,6 +449,7 @@ function DataGridScrollArea({
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div <div
ref={setOverlayRef}
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)" className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
> >

Some files were not shown because too many files have changed in this diff Show More