Compare commits

..
8 Commits
Author SHA1 Message Date
Denozordec f861d0fa8c fix(ci): restore changed-path detection on multi-commit pushes
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m6s
quality / go (push) Successful in 55s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 2m59s
CD / publish (push) Successful in 5m17s
Push-чекаут в quality-воркфлоу делался с fetch-depth: 2, поэтому
BEFORE_SHA (github.event.before) при пуше нескольких коммитов
отсутствовал в чекауте, и git diff падал с "fatal: bad object"
(exit 128). Чекаут переведён на fetch-depth: 0; перед диффом
добавлен guard git cat-file -e: если before недоступен
(force-push), шаг не падает, а уходит в полный прогон через
empty-diff fallback.
2026-09-25 23:54:48 +07:00
Denozordec 2f914bcc65 fix(web): add horizontal inset to frame section content
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / go (push) Skipped
CD / quality (push) Failing after 11s
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Skipped
Контент карточки «Сравнение ревизий» и шела аналитических карточек
прилипал к рамке FramePanel: в contentClassName передавался только
вертикальный py-, а FrameSection рендерит панель с p-0. Добавлен
px-5 — горизонтальная вставка по константе кита panelCardInsetClassName,
на одной оси с остальными карточками (мониторинг, дашборд).
2026-09-25 22:54:47 +07:00
Denozordec 7a10138990 fix(web): open select popups below trigger instead of over it
Селекты приложения использовали alignItemWithTrigger по умолчанию:
Base UI накладывает выбранный пункт списка на триггер, а у селектов с
плейсхолдером (без значения, например «Ревизия A/B» на странице
операций) попап целиком накрывал триггер — выглядел как сломанный
список вместо выпадающего меню. В обёртке SelectMenu/SelectField
выставлен alignItemWithTrigger=false — список раскрывается под
триггером у всех селектов приложения, как это делает и вендорский
data-grid-pagination.
2026-09-25 22:34:04 +07:00
Denozordec 391f34a53d chore: keep staged-context.ps1 array on single staged file
Пайплайн Sort-Object разворачивал одноэлементный массив в скаляр, из-за
чего под StrictMode падал доступ к .Count и скрипт возвращал exit 1 при
ровно одном застейдженном файле. Обёртка @() сохраняет массив; сообщения
коммитов по одному файлу снова проходят обязательную проверку.
2026-09-25 22:33:32 +07:00
Denozordec ecd48642de fix(web): align grid columns with reui reference and clean labels
Гриды операций и расписания выровнены по эталону data-grid-filtering-2:
явные размеры колонок (size в columnDef) вместо равномерного деления
при table-layout: fixed — колонка названия доминирует, второстепенные
сжаты по содержимому, пропали обрезанные имена («partner-dom…»,
«Обновлен...»). Убраны фейковые многоточия из JS-срезов: ID ревизии и
варианты выбора в сравнении теперь показываются целиком, а усечение
делает CSS-truncate. Подписи «Ревизия A/B» переведены на Field/FieldLabel
вместо самодельных span, шапка блока diff дополнена заголовком
«ревизия A → ревизия B» (CodeBlockTitle) слева от кнопок.
2026-09-25 22:14:02 +07:00
Denozordec 09580bbcab feat(web): add job run timeline and code-block revision diff
Раскрытие строки задачи показывает вертикальный ReUI Timeline:
создана (с триггером) -> взята в работу -> результат с текстом
ошибки. Diff ревизий переведён с самодельных <pre> на официальный
@reui/code-block: единый блок с разметкой добавленных и удалённых
строк (знаки +/- и тонировка), нумерацией, копированием и разворотом
длинных списков. ResourcePage (filtered-вариант) получил поддержку
expandedContent/getRowCanExpand — колонка раскрытия строится так же,
как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в
мониторинге переведён на Progress из кита вместо самодельного бара;
удалены @deprecated-константы ui-surface.ts, мёртвый
data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
2026-09-25 21:46:57 +07:00
Denozordec 4d54dacd05 fix(web): localize reui grids and filters, unify ru terminology
Русификация переведена на официальные i18n-слои ReUI: полный русский
словарь DataGrid (lib/reui-i18n-ru.ts) подключён через data-grid-i18n
во FrameDataGrid и ResourcePage — меню сортировки, «Загрузка…»,
«Нет данных» и пагинация больше не откатываются к английским дефолтам
компонентов. Словарь фильтров дополнен ~30 недостающими ключами,
каскадер в мастере проверки переведён целиком. Исправлены
некорректные и разъехавшиеся лейблы: accept -> «Принять»,
API-триггер -> «через API», «арендатор» вместо «Тенант»,
«Наблюдатель» вместо «Просмотр», «Основной» вместо
«Мастер (плоскость)», «AS-префиксы», «DoH-профили», колонка канбана
«В очереди».
2026-09-25 21:46:24 +07:00
Denozordec a3562cc11e chore(web): update vendored ReUI components to latest registry
Обновлены вендоренные компоненты ReUI (base-nova) до актуальных версий
реестра: семейство data-grid (плюс новые data-grid-i18n и
data-grid-cell-selection), filters, cascader, frame, kanban, stepper,
timeline, badge, alert, icon-tile, icon-stack и новый code-block
(подсветка на shiki). Shadcn-примитивы в packages/ui обновлены тем же
запуском CLI. Импорты "cn" из новых файлов переведены на алиас
@evobgp/ui/lib/utils, пакет cn из dependencies удалён, локальные
eslint-disable в вендоренных файлах поправлены.
2026-09-25 21:45:56 +07:00
67 changed files with 8763 additions and 615 deletions
+11 -2
View File
@@ -52,8 +52,10 @@ jobs:
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
# лежит глубже shallow-среза, и git diff падает с "bad object".
with:
fetch-depth: 2
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
@@ -94,7 +96,14 @@ jobs:
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
FILES="$(git diff --name-only "$before" "$after")"
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
# а сигнал уйти в полный прогон через пустой diff.
if git cat-file -e "$before^{commit}" 2>/dev/null; then
FILES="$(git diff --name-only "$before" "$after")"
else
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
FILES=""
fi
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
+1
View File
@@ -41,6 +41,7 @@
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"shadcn": "^4.19.0",
"shiki": "^4.4.3",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
@@ -57,7 +57,7 @@ export function AnalyticsCardShell({
actions={actions}
footer={footer}
className={cn('overflow-hidden', className)}
contentClassName="flex flex-col gap-5 py-5"
contentClassName="flex flex-col gap-5 px-5 py-5"
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
>
{children}
@@ -1,118 +0,0 @@
import { useMemo } from 'react'
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
import { kpiGridClassName } from '@/lib/ui-surface'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
function syntheticSparkline(seed: number, points = 9): number[] {
const base = Math.max(4, seed)
return Array.from({ length: points }, (_, i) =>
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
)
}
function buildMetrics({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}): KpiSparklineMetric[] {
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const running = runningJobCount(jobs)
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
return [
{
id: 'bgp',
title: 'BGP готовность',
label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
detail: loading ? '' : `${network.peersEstablished} сессий`,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
sparkline: syntheticSparkline(bgpPct || 40),
},
{
id: 'modules',
title: 'Модули',
label: 'Активные списки',
value: loading ? '—' : `${enabledModules}`,
delta: loading ? '…' : `${modules.length} всего`,
deltaVariant: 'primary-light',
detail: loading ? '' : 'маршрутизация',
tone: 'info',
sparkline: syntheticSparkline(enabledModules || 3),
},
{
id: 'speakers',
title: 'Спикеры',
label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все в сети'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
detail: loading ? '' : 'live-снимок',
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
sparkline: syntheticSparkline(network.speakersOnline || 2),
},
{
id: 'jobs',
title: 'Задачи',
label: 'Активные / ошибки',
value: loading ? '—' : String(running),
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
detail: loading ? '' : `${jobs.length} в выборке`,
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
sparkline: syntheticSparkline(running + failedJobs || 1),
},
]
}
export function DashboardKpiSparklineRow({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const metrics = useMemo(
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
[modules, peers, speakers, jobs, loading],
)
return (
<section aria-label="KPI обзора" className={kpiGridClassName}>
{metrics.map((metric) => (
<KpiSparklineCard key={metric.id} metric={metric} />
))}
</section>
)
}
@@ -15,7 +15,7 @@ import {
import type { DohProfile } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
]
export function DirectoriesDohGrid({
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
return (
<ResourcePage
title="DoH профили"
title="DoH-профили"
description="Резолверы DNS-over-HTTPS для доменных модулей"
filterFields={filterFields}
filterQuery={filterQuery}
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
isLoading={isLoading}
primaryAction={actions}
pinLastColumn={Boolean(canWrite && onEdit)}
emptyState={{ title: 'Нет DoH профилей', action: actions }}
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
/>
)
}
@@ -20,6 +20,7 @@ import {
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 { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
import type {
BgpCommunity,
LookupQueryKind,
@@ -202,9 +203,7 @@ export function LookupAddStep({
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
mode="columns"
labels={{
search: 'Поиск',
empty: 'Ничего не найдено',
back: 'Назад',
...CASCADER_LABELS_RU,
rootLevel: 'Модули',
}}
>
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
{ value: 'master', label: 'Основной' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
@@ -0,0 +1,73 @@
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
* Активный шаг выводится из фактических отметок времени задачи.
* @see https://reui.io/docs/components/base/timeline
*/
const ruDateTime = (value: string | null | undefined) =>
value ? new Date(value).toLocaleString('ru-RU') : undefined
function resultSubtitle(job: JobRow): string | undefined {
if (job.error) return job.error
const summary = job.meta?.apply_summary
if (summary && typeof summary === 'object') {
const message = (summary as Record<string, unknown>).message
if (typeof message === 'string' && message !== '') return message
}
return undefined
}
export function JobTimeline({ job }: { job: JobRow }) {
const trigger = jobTriggerRu(job.meta?.trigger)
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
const subtitle = resultSubtitle(job)
return (
<div className="py-4 ps-3 pe-2">
<Timeline defaultValue={completed}>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
</TimelineItem>
<TimelineItem step={2}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
<TimelineTitle>Взята в работу</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
</TimelineItem>
<TimelineItem step={3}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
</TimelineItem>
</Timeline>
</div>
)
}
@@ -7,6 +7,7 @@ import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { JobTimeline } from '@/components/operations/job-timeline'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
@@ -92,6 +93,7 @@ export function OperationsJobsGrid({
)
},
meta: { headerTitle: 'Вид' },
size: 320,
},
{
accessorKey: 'status',
@@ -104,6 +106,7 @@ export function OperationsJobsGrid({
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
size: 150,
},
{
id: 'created_at',
@@ -117,6 +120,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 190,
},
{
id: 'finished_at',
@@ -130,6 +134,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
size: 190,
},
{
id: 'actions',
@@ -175,6 +180,8 @@ export function OperationsJobsGrid({
pinLastColumn
virtualization={items.length > 80}
emptyState={{ title: 'Нет задач' }}
getRowCanExpand={() => true}
expandedContent={(row) => <JobTimeline job={row} />}
/>
)
}
@@ -31,7 +31,7 @@ import type { JobRow } from '@/types/api'
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
queued: 'Очередь',
queued: 'В очереди',
running: 'Выполняются',
succeeded: 'Успешные',
failed: 'Ошибки',
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
),
meta: { headerTitle: 'ID' },
size: 340,
},
{
accessorKey: 'created_at',
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 210,
},
{
accessorKey: 'materialized_prefix_count',
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
</span>
),
meta: { headerTitle: 'Префиксов' },
size: 150,
},
{
id: 'actions',
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
title={`Откатиться к ревизии ${row.original.id}?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить"
destructive
@@ -47,7 +47,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
@@ -298,6 +298,7 @@ function FrameDataGridBody<TData extends object>({
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
i18n={DATA_GRID_I18N_RU}
tableLayout={kitDataGridTableLayout({
dense,
width: tableWidth,
@@ -317,7 +318,7 @@ function FrameDataGridBody<TData extends object>({
<>
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
) : null}
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
useTable,
type ExpandedState,
type PaginationState,
type RowSelectionState,
type SortingState,
@@ -21,6 +22,7 @@ import {
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
import { Filters } from '@/components/reui/filters/filters'
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
@@ -41,7 +43,7 @@ import {
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import {
applyFiltersToData,
@@ -94,6 +96,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
| 'horizontalScroll'
| 'pinLeftColumnIds'
| 'columnPinControls'
| 'expandedContent'
| 'getRowCanExpand'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
@@ -218,6 +222,8 @@ function ResourcePageSimple<T extends object>({
horizontalScroll,
pinLeftColumnIds,
columnPinControls,
expandedContent,
getRowCanExpand,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
@@ -256,6 +262,8 @@ function ResourcePageSimple<T extends object>({
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
expandedContent={expandedContent}
getRowCanExpand={getRowCanExpand}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
@@ -304,6 +312,8 @@ function ResourcePageFiltered<T extends object>({
virtualization = false,
height = 480,
horizontalScroll = false,
expandedContent,
getRowCanExpand,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
@@ -339,6 +349,7 @@ function ResourcePageFiltered<T extends object>({
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
@@ -376,9 +387,28 @@ function ResourcePageFiltered<T extends object>({
const selectedCount = selectedIds.length
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
const tableColumns = useMemo(
() => applyKitActionColumn(columns, { pinLastColumn }),
[columns, pinLastColumn],
() =>
applyKitActionColumn(
expandedContent
? [
{
id: 'expand',
header: () => null,
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
enableSorting: false,
enableHiding: false,
size: 40,
meta: { cellClassName: 'w-10', expandedContent },
},
...columns,
]
: columns,
{ pinLastColumn },
),
[columns, pinLastColumn, expandedContent],
)
const { enablePinning, columnPinning } = kitColumnPinning({
pinLastColumn,
@@ -399,6 +429,7 @@ function ResourcePageFiltered<T extends object>({
sorting,
rowSelection,
pagination,
expanded,
...(enablePinning ? { columnPinning } : {}),
},
initialState: enablePinning ? { columnPinning } : undefined,
@@ -406,6 +437,10 @@ function ResourcePageFiltered<T extends object>({
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onExpandedChange: setExpanded,
getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined,
})
const handleTabChange = useCallback(
@@ -495,6 +530,7 @@ function ResourcePageFiltered<T extends object>({
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
onRowClick={onRowClick}
i18n={DATA_GRID_I18N_RU}
tableLayout={kitDataGridTableLayout({
dense: true,
width: 'fixed',
@@ -610,7 +646,7 @@ function ResourcePageFiltered<T extends object>({
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
<DataGridPagination sizes={[5, 10, 20, 50]} />
</FrameFooter>
</FramePanel>
</Frame>
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
import {
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
// propagate to the components that call this hook. They opt out themselves.
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
count,
getScrollElement,
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
estimateSize,
overscan,
}: CascaderVirtualItemsProps) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
overscan,
activeIndex,
}: CascaderVirtualColumnProps & { activeIndex: number }) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
useCascaderLoader,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const { i18n } = useDataGrid()
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
{i18n.labels.filterNoResults}
</div>
) : (
<div className="p-1">
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, 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"
>
Clear filters
{i18n.labels.filterClear}
</div>
</div>
</>
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props } = useDataGrid()
const { i18n, isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
// 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.
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
// to []), then every leaf it leaves out, in definition order - the same
// completion TanStack applies when rendering, so a rendered neighbour is
// always present to re-seat beside, even after columns are added later.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const definitionOrder = table
.getAllColumns()
.flatMap((topColumn) => topColumn.getLeafColumns())
.map((leafColumn) => leafColumn.id)
const columnOrder = [
...columnOrderState,
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
]
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
// Move neighbours come from what is RENDERED: the column's own pin bucket,
// visible columns only. Stepping through the raw columnOrder would trade
// places with a hidden or pinned column - an enabled click that moves nothing.
// With grouping in TanStack's default "reorder" mode, grouped columns render
// first whatever columnOrder says: they neither move nor serve as a target.
const groupedColumnMode = (
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
).groupedColumnMode
const isHoistedByGrouping = (target: object) =>
groupedColumnMode !== false &&
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
(target as { getIsGrouped: () => boolean }).getIsGrouped()
const renderedPeers = (
isPinned === "start"
? table.getStartVisibleLeafColumns()
: isPinned === "end"
? table.getEndVisibleLeafColumns()
: table.getCenterVisibleLeafColumns()
)
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
.map((leafColumn) => leafColumn.id)
const renderedIndex = renderedPeers.indexOf(column.id)
const leftNeighbour =
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
const rightNeighbour =
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
? renderedPeers[renderedIndex + 1]
: undefined
const canMoveLeft = leftNeighbour !== undefined
const canMoveRight = rightNeighbour !== undefined
/** Re-seats this column beside a rendered neighbour; every other column,
* hidden or pinned ones included, keeps its place in the full order. */
const moveBeside = (neighbourId: string, side: "before" | "after") => {
const newOrder = columnOrder.filter((id) => id !== column.id)
const at = newOrder.indexOf(neighbourId)
if (at === -1) return
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
table.setColumnOrder(newOrder)
}
const handleSort = () => {
if (isSorted === "asc") {
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
<span className="grow">{i18n.labels.sortAscending}</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
<span className="grow">{i18n.labels.sortDescending}</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
onClick={() => column.pin(isPinned === "start" ? false : "start")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
<span className="grow">{i18n.labels.pinColumnStart}</span>
{isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
onClick={() => column.pin(isPinned === "end" ? false : "end")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
<span className="grow">{i18n.labels.pinColumnEnd}</span>
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
if (leftNeighbour) moveBeside(leftNeighbour, "before")
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
<span>{i18n.labels.moveColumnStart}</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
if (rightNeighbour) moveBeside(rightNeighbour, "after")
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
<span>{i18n.labels.moveColumnEnd}</span>
</DropdownMenuItem>
)
hasPreviousSection = true
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
<span>{i18n.labels.columnsMenu}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
canMoveRight,
visibility,
table,
columnIndex,
leftNeighbour,
rightNeighbour,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
@@ -1,7 +1,10 @@
"use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>>
}) {
const { i18n } = useDataGrid()
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
{i18n.labels.toggleColumns}
</DropdownMenuLabel>
{table
.getAllColumns()
@@ -0,0 +1,99 @@
export interface DataGridI18nLabels {
/* The column header menu. */
sortAscending: string
sortDescending: string
pinColumnStart: string
pinColumnEnd: string
moveColumnStart: string
moveColumnEnd: string
columnsMenu: string
unpinColumn: (title: string) => string
toggleColumns: string
/* Row and cell affordances. */
rowCreate: string
pinRow: string
unpinRow: string
selectRow: string
selectAll: string
expandRow: string
collapseRow: string
dragToReorder: string
dragToReorderRow: string
reorderingUnavailable: string
/* Grid states. */
loading: string
empty: string
allRowsLoaded: string
/* Pagination. */
rowsPerPage: string
paginationInfo: (info: { from: number; to: number; count: number }) => string
previousPage: string
nextPage: string
goToPage: (page: number) => string
paginationEllipsis: string
/* The faceted column filter. */
filterSelectedCount: (count: number) => string
filterNoResults: string
filterClear: string
}
export interface DataGridI18nConfig {
labels: DataGridI18nLabels
}
export type DataGridI18nOverrides = {
labels?: Partial<DataGridI18nLabels>
}
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
sortAscending: "Asc",
sortDescending: "Desc",
pinColumnStart: "Pin to left",
pinColumnEnd: "Pin to right",
moveColumnStart: "Move to left",
moveColumnEnd: "Move to right",
columnsMenu: "Columns",
unpinColumn: (title) => `Unpin ${title} column`,
toggleColumns: "Toggle Columns",
rowCreate: "Add row",
pinRow: "Pin row",
unpinRow: "Unpin row",
selectRow: "Select row",
selectAll: "Select all",
expandRow: "Expand row",
collapseRow: "Collapse row",
dragToReorder: "Drag to reorder",
dragToReorderRow: "Drag to reorder row",
reorderingUnavailable: "Reordering unavailable",
loading: "Loading...",
empty: "No data available",
allRowsLoaded: "All records loaded",
rowsPerPage: "Rows per page",
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
previousPage: "Go to previous page",
nextPage: "Go to next page",
goToPage: (page) => `Go to page ${page}`,
paginationEllipsis: "...",
filterSelectedCount: (count) => `${count} selected`,
filterNoResults: "No results found.",
filterClear: "Clear filters",
}
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
})
/**
* A shallow merge per section, deliberately: a deep merge would leak a
* default back into a function-valued label the consumer replaced. With no
* overrides the frozen default is returned as-is, so the merge is free to
* run on every render without producing a new identity.
*/
export function mergeDataGridI18n(
overrides?: DataGridI18nOverrides
): DataGridI18nConfig {
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
return {
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
}
}
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
/** Page buttons the truncated middle shows; the row adds first, last and
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
ellipsisText?: string
}
/**
* One slot in the page row: a page button, or an ellipsis that jumps over the
* pages it stands for.
*/
type DataGridPaginationItem =
| { type: "page"; index: number }
| { type: "ellipsis"; direction: "previous"; target: number }
| { type: "ellipsis"; direction: "next"; target: number }
/**
* The adaptive window: first page, the run around the current one, last page,
* with an ellipsis standing in for each hidden stretch. It replaced fixed
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
* 50-page grid there was no way to reach the end - the primitive wires only
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
*
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
* At the default of 5 this emits exactly the same rows as the block version
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
* An ellipsis never stands for a single page, because the early return covers
* every count a full row could hold.
*/
function getDataGridPaginationItems(
pageIndex: number,
pageCount: number,
limit: number
): DataGridPaginationItem[] {
// Pages flanking the current one, per side. The run is rebuilt from it so
// the count is always odd: an even `limit` would make the head and tail
// rows one slot wider than the middle, and the footer would twitch as the
// user pages through.
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
const pages = sibling * 2 + 3
// Two ellipses cost the width of two pages, so below this there is nothing
// to gain by hiding any.
if (pageCount <= pages + 2) {
return Array.from({ length: pageCount }, (_, index) => ({
type: "page" as const,
index,
}))
}
if (pageIndex <= sibling + 2) {
return [
...Array.from({ length: pages }, (_, index) => ({
type: "page" as const,
index,
})),
{ type: "ellipsis", direction: "next", target: pages },
{ type: "page", index: pageCount - 1 },
]
}
if (pageIndex >= pageCount - sibling - 3) {
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageCount - pages - 1,
},
...Array.from({ length: pages }, (_, offset) => ({
type: "page" as const,
index: pageCount - pages + offset,
})),
]
}
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageIndex - sibling - 1,
},
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
type: "page" as const,
index: pageIndex - sibling + offset,
})),
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
{ type: "page", index: pageCount - 1 },
]
}
/**
* A page button is square at one digit and grows from there, so the row does
* not reflow when the count crosses 10 or 100. The floor is each style's own
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
* and keep the arrows' height. Hardcoding one value would be right in three
* styles and wrong in five.
*/
const PAGE_BUTTON_WIDTH_CLASS =
"min-w-7"
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const { i18n, table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
rowsPerPageLabel: i18n.labels.rowsPerPage,
previousPageLabel: i18n.labels.previousPage,
nextPageLabel: i18n.labels.nextPage,
ellipsisText: i18n.labels.paginationEllipsis,
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
// A supplied `info` keeps its placeholder-template contract; the default
// routes through the i18n label function, where word order is free.
const paginationInfo = mergedProps.info
? mergedProps.info
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
: i18n.labels.paginationInfo({ from, to, count: recordCount })
// Pagination limit logic
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
const paginationItems = getDataGridPaginationItems(
pageIndex,
pageCount,
mergedProps.moreLimit ?? 5
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-16" size="sm">
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
the value "100" by 1px at nova's paddings, while fit-content
grows the trigger for 3-digit sizes and the min keeps the
1-2 digit ones from collapsing narrower than 64px. */}
<SelectTrigger
aria-label={mergedProps.rowsPerPageLabel}
className="w-fit min-w-16"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
<Button
size="icon-sm"
variant="ghost"
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
{paginationItems.map((item) =>
item.type === "page" ? (
<Button
key={`page-${item.index}`}
size="sm"
variant="ghost"
aria-label={i18n.labels.goToPage(item.index + 1)}
aria-current={
pageIndex === item.index ? "page" : undefined
}
className={cn(
PAGE_BUTTON_WIDTH_CLASS,
"px-1.5 text-sm",
"text-muted-foreground",
{
"bg-accent text-accent-foreground":
pageIndex === item.index,
}
)}
onClick={() => {
if (pageIndex !== item.index) {
table.setPageIndex(item.index)
}
}}
>
{item.index + 1}
</Button>
) : (
/* Clickable, unlike the shadcn PaginationEllipsis, which is
an aria-hidden span. This one MOVES the user, so it needs
a name saying where. */
<Button
key={`ellipsis-${item.direction}`}
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={i18n.labels.goToPage(item.target + 1)}
onClick={() => table.setPageIndex(item.target)}
>
{mergedProps.ellipsisText}
</Button>
)
)}
<Button
size="icon-sm"
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
@@ -454,12 +457,12 @@ function DataGridScrollArea({
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"bg-border absolute end-px w-1.5",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
@@ -1,3 +1,5 @@
"use client"
import {
createContext,
memo,
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel = "Reordering unavailable",
disabledLabel,
}: {
className?: string
/**
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const { i18n } = useDataGrid()
const context = useContext(SortableRowContext)
const resolvedDisabledLabel =
disabledLabel ?? i18n.labels.reorderingUnavailable
if (!context || disabled) {
return (
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
disabled && "cursor-not-allowed",
className
)}
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
title={disabled ? disabledLabel : undefined}
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
title={disabled ? resolvedDisabledLabel : undefined}
disabled
>
<GripHorizontalIcon aria-hidden="true" />
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
@@ -1,5 +1,3 @@
"use client"
import {
Fragment,
memo,
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
}: {
header: Header<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { i18n, props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
aria-label={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
@@ -6,6 +6,7 @@ import type {
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableAddRow,
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
interface DataGridTableColumnVirtualizerOptions {
/** Off unless explicitly true; a column window is opt-in. */
enabled?: boolean
overscan?: number
}
type DataGridTableColumnScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
columnId: string | undefined
columnIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableColumnScrollRequest(
previous: DataGridTableColumnScrollRequest | null,
next: DataGridTableColumnScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.columnId === next.columnId &&
previous.columnIndex === next.columnIndex &&
previous.scrollElement === next.scrollElement
)
}
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
/**
* Opt-in horizontal virtualization of the CENTER columns. Activates only
* under a fixed table layout with a single ungrouped header row; pinned
* columns stay mounted, and anything else falls back to full-column
* rendering. `scrollBehavior` is shared with controlled row scrolling.
*/
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
/** Alignment used when revealing a controlled target column. */
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) visible leaf columns to reveal. */
scrollToColumnIndex?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
centerColumnWindow?: { start: number; end: number }
}
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
)
}
/**
* A scroll frame only shifts the window, so every surviving row's inputs are
* identical and its last render is reused; the per-frame cost is the rows
* entering the window, not all mounted rows. Cell-level state (selection,
* focus, row checks) repaints through each cell's own Subscribe, and any real
* data change rebuilds the row wrappers, so identity comparison is safe.
*/
const MemoizedRenderedRow = memo(
DataGridTableRenderedRow,
(prev, next) =>
prev.row === next.row &&
prev.rowIndex === next.rowIndex &&
prev.rowRef === next.rowRef &&
prev.pinnedBoundary === next.pinnedBoundary &&
prev.centerWindow?.start === next.centerWindow?.start &&
prev.centerWindow?.end === next.centerWindow?.end
) as typeof DataGridTableRenderedRow
function DataGridTableVirtualBody<TData extends object>({
table,
topRows,
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
centerColumnWindow,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
centerWindow={centerColumnWindow}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
centerWindow={centerColumnWindow}
/>
)
})
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
} else {
centerRows.forEach((row, rowIndex) => {
renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
<MemoizedRenderedRow
key={row.id}
row={row}
rowIndex={rowIndex}
centerWindow={centerColumnWindow}
/>
)
})
}
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
centerWindow={centerColumnWindow}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
* A cell-selection drag gets the same treatment: painting goes through each
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
* memo boundary, so parent-driven reconciliation during the drag is waste.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
(_prev, next) =>
!!next.table.state.columnResizing.isResizingColumn ||
next.table._isSelectingCells === true
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData extends object>({
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
columnVirtualizerOptions,
scrollToColumnAlign = "auto",
scrollToColumnIndex,
footerContent,
renderHeader = true,
onFetchMore,
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid<TData>()
const { i18n, table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
// Column windows only where the geometry is provable: a fixed table
// layout (undefined defaults to fixed; the colgroup then owns every
// width, so colSpan spacers cannot drift) and a single ungrouped header
// row (a colSpan group cannot be windowed). Anything else falls back to
// full-column rendering silently, the same posture as row virtualization
// toward unsupported shapes.
const columnVirtualizationActive =
columnVirtualizerOptions?.enabled === true &&
props.tableLayout?.width !== "auto" &&
mergedHeaderGroups.length === 1 &&
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
false)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
// Horizontal offsets invert in RTL; the virtualizer must be told.
const isRtl = useMemo(
() =>
viewportElements.containerElement
? getComputedStyle(viewportElements.containerElement).direction ===
"rtl"
: false,
[viewportElements.containerElement]
)
const resolveColumnKey = useCallback(
(index: number) => centerVisibleColumns[index]?.id ?? index,
[centerVisibleColumns]
)
const resolveColumnEstimateSize = useCallback(
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
[centerVisibleColumns]
)
const columnVirtualizer = useVirtualizer({
horizontal: true,
isRtl,
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
getScrollElement: resolveScrollElement,
getItemKey: resolveColumnKey,
estimateSize: resolveColumnEstimateSize,
overscan: columnVirtualizerOptions?.overscan ?? 3,
})
// Column sizes are exact reads of getSize(), never DOM-measured, so a
// resize commit or a visibility/order/pinning change must resync the
// virtualizer's cached sizes by hand.
useEffect(() => {
if (columnVirtualizationActive) columnVirtualizer.measure()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
columnVirtualizationActive,
columnVirtualizer,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning,
])
const virtualColumns = columnVirtualizationActive
? columnVirtualizer.getVirtualItems()
: []
// The window is a contiguous inclusive [start, end] over the center
// columns; before the scroll element resolves the item list is empty and
// the grid renders full-width for that first frame.
const centerColumnWindow =
columnVirtualizationActive && virtualColumns.length > 0
? {
start: virtualColumns[0]!.index,
end: virtualColumns[virtualColumns.length - 1]!.index,
}
: undefined
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
const lastColumnScrollRequestRef =
useRef<DataGridTableColumnScrollRequest | null>(null)
// Controlled column reveal: same dedupe posture as the row request - the
// signature keeps ordinary renders from re-scrolling, and exact column
// sizes mean no post-measure follow-up pass is needed.
useEffect(() => {
if (
!columnVirtualizationActive ||
scrollToColumnIndex === undefined ||
scrollToColumnIndex < 0 ||
scrollToColumnIndex >= centerVisibleColumns.length ||
!viewportElements.scrollElement
) {
return
}
const request: DataGridTableColumnScrollRequest = {
align: scrollToColumnAlign,
behavior: scrollBehavior,
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
columnIndex: scrollToColumnIndex,
scrollElement: viewportElements.scrollElement,
}
if (
isSameDataGridTableColumnScrollRequest(
lastColumnScrollRequestRef.current,
request
)
) {
return
}
lastColumnScrollRequestRef.current = request
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
align: scrollToColumnAlign,
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
})
}, [
columnVirtualizationActive,
columnVirtualizer,
centerVisibleColumns,
scrollToColumnAlign,
scrollToColumnIndex,
scrollBehavior,
viewportElements.scrollElement,
])
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
// The header re-renders only when its real inputs move: the table
// wrapper (any table state change recreates it), the layout props, and
// the column window. A scroll frame changes none of them, so the whole
// sortable-header subtree is reused instead of rebuilt per frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
const headerNode = useMemo(
() =>
renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{/* Under an active column window the single ungrouped header
row is bucketed start / windowed center / end, with each
off-window flank one colSpan spacer sized by the intact
colgroup - the same shape the body rows take. */}
{centerColumnWindow
? headerGroup.headers
.filter((header) => header.column.getIsPinned() === "start")
.map((header) => (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
header.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
))
: null}
{centerColumnWindow && centerColumnWindow.start > 0 ? (
<th
aria-hidden="true"
data-slot="data-grid-table-virtual-col-spacer"
colSpan={centerColumnWindow.start}
className="p-0"
/>
) : null}
{centerColumnWindow
? headerGroup.headers
.filter((header) => !header.column.getIsPinned())
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
.map((header) => (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
header.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
))
: null}
{centerColumnWindow &&
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
<th
aria-hidden="true"
data-slot="data-grid-table-virtual-col-spacer"
colSpan={
centerVisibleColumns.length - 1 - centerColumnWindow.end
}
className="p-0"
/>
) : null}
{!centerColumnWindow &&
headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
@@ -890,38 +1135,66 @@ function DataGridTableVirtual<TData extends object>({
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
renderHeader,
table,
props.tableLayout,
centerColumnWindow,
hasRightPinnedColumns,
]
)
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{headerNode}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
centerColumnWindow={centerColumnWindow}
/>
{/* Same appended region as the standard body, so onRowCreate and
a consumer draft work in the virtual layout too. */}
{props.appendRow}
<DataGridTableAddRow />
</DataGridTableBody>
{footerContent && (
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,19 @@
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
import {
createContext,
useContext,
useEffect,
useId,
useMemo,
useRef,
} from "react"
import type { ReactNode } from "react"
import {
mergeDataGridI18n,
type DataGridI18nConfig,
type DataGridI18nOverrides,
} from "@/components/reui/data-grid/data-grid-i18n"
import {
cellSelectionFeature,
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
@@ -30,6 +43,7 @@ import {
tableFeatures,
} from "@tanstack/react-table"
import type {
Cell,
Column,
ColumnFiltersState,
ReactTable,
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
* installing the data grid no longer widens `ColumnMeta` for every other
* table in the consuming app.
*/
/**
* Per-column write contract for the spreadsheet operations (paste, fill,
* clear, cut). Presence marks the column writable; every write still flows
* through `onCellsChange`, so the consumer's state stays the only data owner.
*/
export interface DataGridColumnCellEdit<TData> {
/**
* Set false for a column that formats clipboard output but is never
* written; a function decides per row, so locked rows (archived, another
* user's, a totals row) reject like any read-only cell. Defaults to true.
*/
editable?: boolean | ((row: TData) => boolean)
/**
* Clipboard or fill string to typed value. Return undefined to reject the
* cell into `rejected`. Without it the raw string passes through unchanged -
* a deliberate non-coercion, so a number column receives strings only when
* the consumer chose not to parse.
*/
parse?: (raw: string, row: TData) => unknown
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
format?: (value: unknown, row: TData) => string
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
clearValue?: unknown
/**
* Opt into the grid's built-in free-text editor: an overlay input (or
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
* typing, or double-click, committing through `onCellsChange` as source
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
* via `onCellEditRequest` or their own cell renderers.
*/
control?: "text" | "textarea"
}
export interface DataGridColumnMeta<TData> {
headerTitle?: string
headerClassName?: string
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
cellEdit?: DataGridColumnCellEdit<TData>
/**
* Under `columnsResizable`, this column absorbs the free space the filler
* strip would otherwise hold, so the grid always reads full-width and the
* built-in editor covers the whole cell. One column per grid; while free
* space remains, manual resizing of this column is visually a no-op (the
* absorbed space compensates), the flex-column trade-off.
*/
fillWidth?: boolean
}
/**
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
rowSelectionFeature,
rowExpandingFeature,
rowPinningFeature,
// Registration alone is inert: it seeds a `cellSelection: []` slice and
// prototype methods but binds no DOM handlers, so grids without
// `tableLayout.cellSelection` render and behave exactly as before.
cellSelectionFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
return String(column.id)
}
/**
* The td contract for spreadsheet selection. Call inside a
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
* builder calls whose state dependency React Compiler cannot see, exactly the
* trap the row-selection checkbox documents.
*/
export function getDataGridCellSelectionCellAttrs<TData extends object>(
cell: Cell<DataGridFeatures, TData, unknown>
): {
"aria-selected": boolean
"data-col-id": string
"data-cell-selected": true | undefined
"data-cell-focused": true | undefined
"data-cell-edge-top": true | undefined
"data-cell-edge-right": true | undefined
"data-cell-edge-bottom": true | undefined
"data-cell-edge-left": true | undefined
} {
const selected = cell.getIsSelected()
const edges = cell.getSelectionEdges()
return {
"aria-selected": selected,
"data-col-id": cell.column.id,
"data-cell-selected": selected || undefined,
"data-cell-focused": cell.getIsFocused() || undefined,
"data-cell-edge-top": edges.top || undefined,
"data-cell-edge-right": edges.right || undefined,
"data-cell-edge-bottom": edges.bottom || undefined,
"data-cell-edge-left": edges.left || undefined,
}
}
/**
* Selection chrome for a body td, activated by the data attributes above plus
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
* outside React like the resize indicator). A ::before overlay, not outline
* or box-shadow: an outline ring hugs the cell box and cannot line up with
* the gridlines the perimeter paints on, and the pinned-column dividers
* already own the cell's shadow slot (tailwind-merge would collapse a second
* shadow-[...] into it).
*/
export const dataGridCellSelectionCellClasses = cn(
"relative select-none",
// A light tint: the selection must read as a range without drowning the
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
// cells must stay OPAQUE (they hide scrolled content), so they get the
// tint pre-mixed over the background, and the focused pinned cell keeps
// a solid background instead of turning transparent.
"data-[cell-selected]:bg-primary/4",
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
"data-[cell-focused]:bg-transparent!",
"data-[cell-focused]:data-pinned:bg-background!",
// Every selection line is one layout-free ::before overlay per cell. It
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
// gridline it replaces: the range perimeter and the interior dividers
// share this geometry, so where they meet they coincide instead of
// stacking into a wider edge, every side stays exactly 1px, and
// selecting never adds real borders that shift layout. At the table's
// own boundary there is no shared gridline and the overshoot leaves the
// scrollable content box, where it is CLIPPED - the perimeter simply
// vanished on the first column, last column and last row. So every
// overshoot that can hit a boundary goes through a variable the
// boundary cells zero out. Longhand inset utilities only, logical
// start/end
// so RTL mirrors for free; a separate override rule cannot do this,
// equal-specificity variants leave shorthand-vs-longhand to sort order.
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
// Interior dividers: selected cells repaint their own end and bottom
// gridlines on the overlay, so gridline-less grids still divide a range.
// The gray color is guarded by not- variants (higher specificity), so a
// side that belongs to the range perimeter deterministically stays
// primary.
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
// The Sheets model: a single selected cell IS its own range, so the four
// edge attributes below already draw its full primary box; inside a
// larger range the anchor reads by its unfilled background alone. The
// explicit ring only covers a focused cell with no selection at all.
// Edge sides are logical (border-e/border-s): the feature derives edges
// from display-order column indexes, which mirror in RTL.
"data-[cell-focused]:not-data-[cell-selected]:before:border",
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
// While a fill drag is live the preview border is the ONE painter for
// the whole pending region, so the source cells' own chrome rests -
// otherwise its lines double against the region border at every shared
// edge. display is the no-cascade-fight switch (see below).
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
// While the built-in editor is open its outline is the one border; the
// edited cell's own overlay chrome hides so nothing peeks around the
// flush editor. display is the switch because no other before: rule
// sets it, so there is no cascade order to lose.
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
// Clamped to the cell edge at the grid's own boundary, the overlay's
// lines sit flush against the container border instead of being clipped
// away outside the scrollable content. A custom property has no cascade
// fight to lose. First/last-child cover the boundary columns: when a
// filler td follows the last data column, that cell's overshoot lands
// INSIDE the table and needs no clamp, and the td:first/last-child of a
// row are its logical start/end in RTL too.
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
// The top overshoot lands under a sticky header, which is positioned and
// paints over it - so the first row's perimeter lost its top line. Clamp
// inside the cell there, exactly like the other three boundaries.
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
"first:[--data-grid-overlay-start:0px]",
"last:[--data-grid-overlay-end:0px]",
// A pinned column is a boundary like the grid's own edges: the sticky
// neighbor would cover an on-gridline overlay line, and re-painting it
// from the pinned side doubles into a 2px blur whenever subpixel
// positions disagree (browser zoom, fractional fill widths). One
// painter instead: the overlay clamps inside its own cell there, the
// same custom-property clamp the first and last cells use.
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
// Tint only: the dashed region outline is one overlay element drawn by
// the fill session, so neighboring target cells never double their edges.
"data-[cell-fill-target]:bg-primary/4",
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
)
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
table: DataGridTableInstance<TData>
recordCount: number
isLoading: boolean
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
gridId: string
/** The merged label set; components read `i18n.labels.<key>`. */
i18n: DataGridI18nConfig
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
autoSize?: DataGridAutoSizeController
}
/**
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
* renderers always agree on the same encoding.
*/
export function toDataGridDomId(value: string): string {
return value.replace(/[^\w-]/g, "_")
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
* Feeds the controller one viewport measurement: the SIGNED free space
* between the scroll container and the table, negative while the table
* overflows. The first sight of a `meta.autoSize` column absorbs the
* free space into its width; after that, every measurement REFLOWS the
* same growth - a wider window widens the column, a narrower one gives
* the space back down to the column's `minSize` (or its starting width
* when none is set) - so the grid tracks its container live. Commits
* are coalesced for performance: sub-pixel deltas are dropped, a
* streaming resize gets one leading commit plus one trailing commit at
* settle, and a width the user dragged is never touched. Safe to call
* from every viewport measurement; returns true when a sizing update
* was dispatched.
*/
apply: (fillWidth: number) => boolean
apply: (freeSpace: number) => boolean
}
type DataGridAutoSizeReflowState = {
applied: { columnId: string; base: number; grown: number } | null
settleTimer: ReturnType<typeof setTimeout> | null
pendingFreeSpace: number
lastCommitAt: number
}
/**
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
* StrictMode double-mounts the tree, so two controller instances serve one
* page and only the first would remember what it absorbed - the second
* reads the grown width as a user drag and freezes the column. The store
* object is the one identity that provably survives that dance (the
* absorbed sizing rides inside it), and the WeakMap lets a discarded
* table take its bookkeeping with it.
*/
const dataGridAutoSizeStates = new WeakMap<
object,
DataGridAutoSizeReflowState
>()
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
let state = dataGridAutoSizeStates.get(store)
if (!state) {
state = {
applied: null,
settleTimer: null,
pendingFreeSpace: 0,
lastCommitAt: 0,
}
dataGridAutoSizeStates.set(store, state)
}
return state
}
function createDataGridAutoSizeController<TData extends object>(
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
*/
getTable: () => DataGridTableInstance<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
// Reflow coalescing: a live window drag streams a measurement per frame,
// and every accepted commit re-renders the table. One leading commit
// keeps single snaps (a maximize) instant; the rest of the stream parks
// its latest value behind one trailing timer, so a continuous drag costs
// two commits instead of sixty a second. The trailing timer may fire
// after the grid unmounts; it re-reads the table then, and a sizing
// write to the consumer's store is inert once nothing renders it.
const SETTLE_MS = 150
const reflow = (freeSpace: number): boolean => {
const table = getTable()
const state = getDataGridAutoSizeState(table.store)
// LIVE state, never the render snapshot: a measurement can run between
// our own sizing commit and React's re-render, and the stale snapshot
// would hide the width this controller just wrote.
const columnSizing =
table.atoms.columnSizing?.get() ?? table.state.columnSizing
if (!state.applied) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
return false
// A width this coordinator did not write belongs to the user's drag;
// reflow stands down until a reset re-arms the column.
const currentSize = columnSizing[state.applied.columnId]
if (currentSize !== undefined && currentSize !== state.applied.grown) {
return false
}
// The free space as it would measure with our growth removed; the
// target re-absorbs exactly that, floored at minSize so a narrow
// window hands space back without ever crushing the column (no
// explicit minSize floors at the starting width instead).
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
const target = Math.max(floor, state.applied.base + freeAtBase)
if (Math.abs(target - state.applied.grown) < 1) return false
state.applied = { ...state.applied, grown: target }
table.setColumnSizing((old) => ({
...old,
[state.applied!.columnId]: target,
}))
return true
}
return {
apply(fillWidth: number) {
apply(freeSpace: number) {
const table = getTable()
const columnSizing = table.state.columnSizing
const state = getDataGridAutoSizeState(table.store)
// Same live read as `reflow`; the snapshot race is what made the
// re-arm check clear the bookkeeping right after the first absorb.
const columnSizing =
table.atoms.columnSizing?.get() ?? table.state.columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
state.applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
state.pendingFreeSpace = freeSpace
const now = Date.now()
if (now - state.lastCommitAt > SETTLE_MS) {
state.lastCommitAt = now
return reflow(freeSpace)
}
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
state.settleTimer = setTimeout(() => {
state.settleTimer = null
state.lastCommitAt = Date.now()
reflow(state.pendingFreeSpace)
}, SETTLE_MS)
return false
}
const fillWidth = Math.max(0, freeSpace)
if (fillWidth <= 0) return false
if (!autoSizeColumn) {
return false
}
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
// un-resizable: the drag committed, the next viewport measurement
// stamped the fill back on top, and the column snapped to its old width.
//
// Deliberately keyed on observed state rather than on `applied`, which
// Deliberately keyed on observed state rather than on `state.applied`, which
// is per-coordinator memory: anything that rebuilds the coordinator
// (a remount, a new table store) forgets what it did, and the guard has
// to survive that. An explicit reset clears the entry and re-arms the
// fill, which is what makes double-click-to-reset still work.
const currentSize = columnSizing[autoSizeColumn.id]
if (currentSize !== undefined && currentSize !== applied?.grown) {
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
return false
}
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
state.applied &&
columnSizing[state.applied.columnId] === state.applied.grown
? state.applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
state.applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
columnFilters?: ColumnFiltersState
}
/** Which spreadsheet operation produced a cell change batch. */
export type DataGridCellsChangeSource =
| "paste"
| "cut"
| "clear"
| "fill"
| "edit"
export interface DataGridCellChange<TData> {
rowId: string
columnId: string
/** The consumer's row object, so immutable write-back needs no second lookup. */
row: TData
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
previousValue: unknown
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
value: unknown
}
/** What onCellsCopy receives after the grid writes the clipboard. */
export interface DataGridCopyDetails {
/** The TSV text that reached the clipboard. */
text: string
/** The same content as a row-major grid of formatted fields. */
grid: string[][]
/** True when the write came from a cut, which clears the region next. */
cut: boolean
}
/** One resolved positive region, in pre-paginated display indexes. */
export interface DataGridCellSelectionBound {
minRowIndex: number
maxRowIndex: number
minColumnIndex: number
maxColumnIndex: number
}
/** What onCellSelectionChange receives, resolved from the live selection. */
export interface DataGridCellSelectionSnapshot {
/** The virtually focused cell, the active range's anchor. Null when clear. */
focused: { rowId: string; columnId: string } | null
/** Every positive region, geometric order. */
bounds: DataGridCellSelectionBound[]
/** The most recent region, the one copy and paste act on. */
activeBound: DataGridCellSelectionBound | null
/**
* Cells of the selection the view can show - the count that matches the
* painted range and what a batch will touch.
*/
visibleCellCount: number
}
export interface DataGridCellRejection {
rowId: string
columnId: string
raw: string
reason: "readonly" | "invalid"
}
/**
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
* consumer's state stays the only owner of what the statuses mean.
*/
export type DataGridRowStatus = "new" | "dirty" | "deleted"
/**
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
* the amber tone for "dirty", the destructive tone for "invalid".
*/
export type DataGridCellStatus = "dirty" | "invalid"
/**
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
* a typed printable character (then `initialText` carries it, the
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
* column is writable via `meta.cellEdit`.
*/
export interface DataGridCellEditRequest<TData> {
rowId: string
columnId: string
row: TData
previousValue: unknown
/** The typed character that started the edit; absent for Enter and F2. */
initialText?: string
}
/**
* One batch per operation, however many cells it spans: a 10k-cell paste is
* one callback, one consumer setState, one table rebuild. The grid never
* writes data itself.
*/
export interface DataGridCellsChangeDetails<TData> {
source: DataGridCellsChangeSource
changes: DataGridCellChange<TData>[]
rejected: DataGridCellRejection[]
}
export interface DataGridProps<
TFeatures extends TableFeatures,
TData extends object,
@@ -300,7 +707,62 @@ export interface DataGridProps<
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
/**
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
* Served through the props getter like `onRowClick`, so an inline identity
* never republishes the context. Absent: copy still works, writes no-op.
*/
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
/**
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
*/
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
/**
* Fires on every cell-selection change (focus moves, ranges grow or
* clear) with a resolved snapshot, so a selection count, formula bar or
* context panel needs no reach into TanStack internals.
*/
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
/**
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
* native copy and cut events), e.g. to confirm with a toast.
*/
onCellsCopy?: (details: DataGridCopyDetails) => void
/**
* Renders an "Add row" affordance as the table body's last row; clicking
* it is the consumer's cue to start creating a row. Absent: no row.
*/
onRowCreate?: () => void
/** Label of the onRowCreate affordance. Defaults to "Add row". */
rowCreateLabel?: ReactNode
/**
* Rendered inside the table body after the data rows: the slot a DRAFT
* row lives in while it is being created, before it joins `data` and the
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
*/
appendRow?: ReactNode
/**
* Optional CRUD indication per row. Return undefined for no indication;
* omit the prop entirely to render none anywhere.
*/
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
/**
* Optional CRUD indication per cell: the classic corner mark on cells the
* consumer tracks as edited or invalid.
*/
getCellStatus?: (
row: TData,
columnId: string
) => DataGridCellStatus | undefined
isLoading?: boolean
/**
* Overrides for every built-in string: menu items, aria labels,
* pagination copy, state messages. Merged over the defaults; a more
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
* pagination label props) still wins over its `i18n` counterpart.
*/
i18n?: DataGridI18nOverrides
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
@@ -325,6 +787,36 @@ export interface DataGridProps<
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
cellSelection?: boolean
/**
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
* grow multi-cell regions. "single": one focused cell only - every
* gesture collapses to it and Ctrl/Cmd+A is inert.
*/
cellSelectionMode?: "single" | "range"
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
cellFillHandle?: boolean
/**
* How a cell opens its editor from the mouse: "dblclick" (default,
* the spreadsheet standard - first click focuses, second edits) or
* "click", opening the editor as soon as a plain click lands on the
* focused cell; modifier clicks and drags still select.
*/
cellEditMode?: "dblclick" | "click"
/**
* When true, committing an edit with Enter moves focus down (Shift+Enter
* up), the Sheets and Excel convention. Off by default: the commit keeps
* focus on the edited cell. Tab always commits and moves across.
*/
cellEditEnterAdvance?: boolean
/**
* The handle's look: "dot" is a solid primary dot kept inside the
* cell corner; "ring" is the Sheets-style hollow circle riding the
* corner point, clamped at the grid's own boundary; "square" is the
* Excel-style solid square inside the corner. Default "dot".
*/
cellFillHandleVariant?: "dot" | "ring" | "square"
}
tableClassNames?: {
base?: string
@@ -335,6 +827,20 @@ export interface DataGridProps<
bodyRow?: string
footer?: string
edgeCell?: string
/** Override the getRowStatus tints; defaults are the warning-muted set. */
rowNew?: string
rowDirty?: string
rowDeleted?: string
/**
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
* that pins a draft row for quick-create wants it to read as an active
* row, not a muted one.
*/
rowPinned?: string
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
rowCreate?: string
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
cellFillHandle?: string
}
}
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
}) {
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
const gridId = useId()
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
const i18n = mergeDataGridI18n(props.i18n)
const i18nRef = useRef(i18n)
i18nRef.current = i18n
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
// every state change. Depending on it directly would republish the context
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
}, [table, resizeMode])
// With the selection UI on, every paste/fill/clear batch replaces `data`,
// and the feature's default autoResetCellSelection would wipe the selection
// after each write. Ranges are row-id based and unresolvable ids drop out
// of the bounds safely, so keeping state across data edits is the correct
// default here.
const cellSelectionOn = !!props.tableLayout?.cellSelection
useEffect(() => {
if (!cellSelectionOn || table.atoms.cellSelection == null) return
if (table.options.autoResetCellSelection === false) return
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
}, [table, cellSelectionOn])
// Rendered row indexes re-base per page, so a range kept across a page flip
// re-lights the same positions over different rows. A page change clears
// the selection instead - the rule Excel-family grids apply to paginated
// range selection.
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
const previousPageKeyRef = useRef(pageKey)
useEffect(() => {
if (previousPageKeyRef.current === pageKey) return
previousPageKeyRef.current = pageKey
if (!cellSelectionOn || table.atoms.cellSelection == null) return
tableRef.current.resetCellSelection(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageKey, cellSelectionOn])
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
// stable for the life of the table, rather than on `table` itself: the
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
// `cellSelection` state is excluded on purpose too: a drag writes it once
// per cell crossed, and nothing reads it through context - cells and the
// selection bar subscribe to `table.atoms.cellSelection` directly.
const value = useMemo(
() => ({
get props() {
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
get table() {
return tableRef.current
},
get i18n() {
return i18nRef.current
},
recordCount: props.recordCount,
isLoading: props.isLoading || false,
gridId,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
cellSelection: false,
cellFillHandle: false,
},
tableClassNames: {
base: "",
@@ -555,7 +1101,9 @@ function DataGridContainer({
return (
<div
data-slot="data-grid"
className={cn("w-full overflow-hidden", className)}
// relative: anchors floating chrome composed inside the container,
// like the fill-drag preview outline.
className={cn("relative w-full overflow-hidden", className)}
>
{children}
</div>
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { FilterFieldPicker } from "@/components/reui/filters/filters-builder"
import {
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
Cascader,
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
filterControlSizes,
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
/**
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import {
Cascader,
@@ -162,6 +162,14 @@ export interface FilterField<V = unknown, O = unknown> {
operators?:
| FilterOperator[]
| ((field: FilterField<V, O>) => FilterOperator[])
/**
* The operator this field FALLS BACK to, not a way to preselect one. Both
* create paths deliberately start a rule on `operator: ""` so the condition
* menu opens with nothing chosen, so this is read when an existing rule
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
* Ignored, with a dev warning, when it names an operator the field does not
* offer; the first visible operator wins instead.
*/
defaultOperator?: string
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { FiltersAdvanced } from "@/components/reui/filters/filters-advanced"
import { FiltersBuilder } from "@/components/reui/filters/filters-builder"
+25 -40
View File
@@ -3,36 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
* So variant="inverse" re-points every panel from one place, and a consumer's
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
// Each rung is the radius that style's own .cn-card resolves through, so a
// Frame and a Card side by side agree. lyra/sera are 0px, NOT
// var(--radius-none): no such token exists, and it only reached 0 by being
// invalid, which also left --frame-radius empty for anything reading it.
"[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
// Luma alone re-times the frame: wider gap and padding, roomier bars.
"",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric inner radius: the panel corner nests smoothly inside the frame
// corner instead of matching it. The panel sits inset from the frame's outer
// edge by the frame's 1px border + --frame-px padding, so its radius is
// reduced by that same gap (radius − gap keeps the two arcs parallel). This
// base value assumes the bordered default/inverse frame; `ghost` drops the
// 1px border term and `dense` pins it back to the frame radius (its panels
// are pulled flush to the edge).
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
// Concentric: the panel nests inside the frame's corner rather than copying
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
// that keeps the two arcs parallel. `ghost` drops the border term (no
// border), `dense` pins it to the frame radius (panels sit flush).
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
@@ -40,17 +34,12 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
// No frame border, so the panel is inset by --frame-px padding only.
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
// Header/footer vertical rhythm is tighter than the panel body's, and
// the gap widens as the frame grows: the bars read as chrome rather than
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
// style-*.css overrides them - so this single ladder drives all shadcn
// styles. `px` is deliberately left level with the body so header,
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
// the practical floor, since anything lower stops reading as padding.
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
// against a body py of 2/3.5/4/5, while px stays level with the body so
// header, content and footer left-align. xs floors at 0.5 (2px), below
// which it stops reading as padding. No style-*.css overrides these.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
@@ -72,9 +61,8 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
// their corners align with the frame radius rather than nesting inside it.
// Parent selectors, not CSS vars: these are positional. Panels are
// pulled flush (-mx-px), so corners align with the frame's own radius.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
@@ -117,13 +105,10 @@ function FramePanel({
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
+52
View File
@@ -13,6 +13,7 @@ import {
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import type {
CollisionDetection,
DragCancelEvent,
DragEndEvent,
DragOverEvent,
@@ -22,12 +23,16 @@ import type {
UniqueIdentifier,
} from "@dnd-kit/core"
import {
closestCenter,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
getFirstCollision,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
pointerWithin,
rectIntersection,
TouchSensor,
useSensor,
useSensors,
@@ -254,6 +259,49 @@ function Kanban<T>({
[columns, columnIds, getItemValue, isColumn]
)
// The droppable under the pointer, not the one the dragged rect overlaps
// most: in the gap between columns that overlap flips with every live-preview
// move, and each flip re-runs dragOver until React bails out.
const lastOverIdRef = useRef<UniqueIdentifier | null>(null)
const collisionDetection = useCallback<CollisionDetection>(
(args) => {
if (isColumn(args.active.id)) {
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((container) =>
isColumn(container.id)
),
})
}
// Keyboard drags carry no pointer.
if (!args.pointerCoordinates) return rectIntersection(args)
let overId = getFirstCollision(pointerWithin(args), "id")
if (overId != null) {
// Over a column's empty space: resolve to its closest item, if any.
if (isColumn(overId)) {
const itemIds = new Set(columns[overId as string].map(getItemValue))
overId =
closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container) => itemIds.has(container.id as string)
),
})[0]?.id ?? overId
}
lastOverIdRef.current = overId
return [{ id: overId }]
}
// Between droppables: hold the last target so the preview stays put.
return lastOverIdRef.current != null
? [{ id: lastOverIdRef.current }]
: rectIntersection(args)
},
[columns, getItemValue, isColumn]
)
const commitChange = useCallback(
(
finalValue: Record<string, T[]>,
@@ -312,6 +360,7 @@ function Kanban<T>({
const handleDragStart = useCallback(
(event: DragStartEvent) => {
lastOverIdRef.current = null
setActiveId(event.active.id)
onDragStart?.(event)
@@ -421,6 +470,7 @@ function Kanban<T>({
}
dragOriginRef.current = null
lastOverIdRef.current = null
setActiveId(null)
onDragCancel?.(event)
},
@@ -437,6 +487,7 @@ function Kanban<T>({
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
lastOverIdRef.current = null
setActiveId(null)
onDragEnd?.(event)
@@ -589,6 +640,7 @@ function Kanban<T>({
modifiers={modifiers}
accessibility={accessibility}
measuring={MEASURING_CONFIG}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import type { HTMLAttributes, ReactElement } from "react"
import {
Children,
@@ -1,5 +1,3 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
@@ -52,12 +52,14 @@ export function ScheduleModulesGrid({
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <DataGridNameCell icon={Boxes} title={row.original.name} />,
meta: { headerTitle: 'Модуль' },
size: 280,
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
size: 150,
},
{
id: 'schedule',
@@ -70,6 +72,7 @@ export function ScheduleModulesGrid({
</DataGridMonoCell>
),
meta: { headerTitle: 'Расписание' },
size: 130,
},
{
id: 'last_refreshed_at',
@@ -83,6 +86,7 @@ export function ScheduleModulesGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Обновлено' },
size: 190,
},
{
id: 'enabled',
@@ -92,6 +96,7 @@ export function ScheduleModulesGrid({
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
),
meta: { headerTitle: 'Статус' },
size: 110,
},
{
id: 'actions',
+5 -1
View File
@@ -58,7 +58,11 @@ export function SelectMenu<T extends string = string>({
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side={side} className={contentClassName}>
{/* alignItemWithTrigger=false: список раскрывается ПОД триггером. Режим
по умолчанию (true) накладывает выбранный пункт на триггер, а у
селектов с плейсхолдером (без значения) попап накрывает сам триггер.
Так же делает вендорский data-grid-pagination. */}
<SelectContent side={side} alignItemWithTrigger={false} className={contentClassName}>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
@@ -18,7 +18,7 @@ import {
} from '@evobgp/ui/components/item'
const ROLE_LABELS: Record<string, string> = {
viewer: 'Просмотр',
viewer: 'Наблюдатель',
editor: 'Редактор',
operator: 'Оператор',
node: 'Нода',
-20
View File
@@ -1,20 +0,0 @@
export const DATA_GRID_PAGINATION_RU = {
sizes: [10, 25, 50] as number[],
sizesLabel: 'Показать',
sizesDescription: 'на странице',
info: '{from}–{to} из {count}',
rowsPerPageLabel: 'Строк на странице',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
}
export const DATA_GRID_MESSAGES_RU = {
emptyMessage: 'Нет данных',
loadingMessage: 'Загрузка…',
fetchingMoreMessage: 'Загрузка…',
selectAllLabel: 'Выбрать все',
selectRowLabel: 'Выбрать строку',
pinRowLabel: 'Закрепить строку',
unpinRowLabel: 'Открепить строку',
allRecordsLoadedMessage: 'Все записи загружены',
}
+40
View File
@@ -53,6 +53,46 @@ export const FILTERS_LABELS_RU: Partial<FilterLabels> = {
noValue: 'нет значения',
selectCondition: 'Выберите условие',
incomplete: 'незавершённый фильтр',
branchAffordance: 'открывает список',
exclusiveHint: 'нельзя сочетать с другими вариантами',
exclusiveAnnouncement: (label, cleared) =>
cleared === 1
? `Выбрано «${label}». 1 другой вариант сброшен.`
: `Выбрано «${label}». Других вариантов сброшено: ${cleared}.`,
itemCount: (count) => `Элементов: ${count}`,
fieldsLabel: 'Поля',
resultsAnnouncement: (count) =>
count === 1 ? '1 результат' : `Результатов: ${count}`,
actionsLabel: 'Действия',
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
groupAnnouncement: (added) => (added ? 'Группа добавлена' : 'Группа удалена'),
reorderAnnouncement: (label, position, total) =>
`${label} перемещён на позицию ${position} из ${total}`,
moveAnnouncement: (label, destination, position, total) =>
`${label} перемещён в ${destination}, позиция ${position} из ${total}`,
combinatorLabel: (word) => `${word}, изменить связку`,
chipMenu: (fieldLabel) => `Параметры фильтра «${fieldLabel}»`,
stepAnnouncement: (step, label) => {
if (step === 'field') return `Выберите поле. ${label}`
if (step === 'operator') return `Выберите условие для ${label}`
return `Введите значение для ${label}`
},
countAnnouncement: (count) =>
count === 1 ? 'Применён 1 фильтр' : `Применено фильтров: ${count}`,
valueCount: (count) => `Выбрано: ${count}`,
valueRange: (from, to) => `${from} – ${to}`,
rangeFrom: (fieldLabel) => `${fieldLabel} от`,
rangeTo: (fieldLabel) => `${fieldLabel} до`,
rangeSeparator: '—',
negated: (operatorLabel) => `не ${operatorLabel}`,
issueOperator: 'Выберите условие',
issueValue: 'Введите значение',
issueRange: 'Заполните оба конца диапазона',
issueRangeOrder: 'Конец диапазона раньше его начала',
issueEmptyGroup: 'В этой группе пока нет условий',
issueSummary: (count) =>
count === 1 ? '1 строка требует внимания' : `Строк требует внимания: ${count}`,
}
export const FILTERS_OPERATOR_LABELS_RU: FilterOperatorLabels = {
@@ -3,7 +3,7 @@ import type { ModuleRow, ModuleType } from '@/types/api'
import type { BreakdownSlice } from './types'
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
AS_PREFIXES: { label: 'AS-префиксы', color: 'var(--color-chart-1)' },
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
+126
View File
@@ -0,0 +1,126 @@
import type {
DataGridI18nLabels,
DataGridI18nOverrides,
} from '@/components/reui/data-grid/data-grid-i18n'
import type { CascaderLabels } from '@/components/reui/cascader/cascader-types'
/**
* Русские формы множественного числа: 1 элемент, 2 элемента, 5 элементов.
*/
function ruPlural(count: number, one: string, few: string, many: string): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few
return many
}
/**
* Единый русский словарь ReUI DataGrid — официальный i18n-слой (@reui/data-grid-i18n).
* Подключается один раз через `i18n={DATA_GRID_I18N_RU}` на <DataGrid>
* (FrameDataGrid и ResourcePage) — наследуют все гриды приложения.
* Полнота словаря проверяется компилятором: тип DataGridI18nLabels требует все ключи.
*
* @see https://reui.io/docs/components/base/data-grid
*/
const DATA_GRID_LABELS_RU: DataGridI18nLabels = {
/* Меню заголовка столбца. */
sortAscending: 'По возрастанию',
sortDescending: 'По убыванию',
pinColumnStart: 'Закрепить слева',
pinColumnEnd: 'Закрепить справа',
moveColumnStart: 'Сдвинуть влево',
moveColumnEnd: 'Сдвинуть вправо',
columnsMenu: 'Столбцы',
unpinColumn: (title) => `Открепить столбец «${title}»`,
toggleColumns: 'Настройка столбцов',
/* Афордансы строк и ячеек. */
rowCreate: 'Добавить строку',
pinRow: 'Закрепить строку',
unpinRow: 'Открепить строку',
selectRow: 'Выбрать строку',
selectAll: 'Выбрать всё',
expandRow: 'Развернуть строку',
collapseRow: 'Свернуть строку',
dragToReorder: 'Перетащите, чтобы изменить порядок',
dragToReorderRow: 'Перетащите, чтобы изменить порядок строк',
reorderingUnavailable: 'Изменение порядка недоступно',
/* Состояния грида. */
loading: 'Загрузка…',
empty: 'Нет данных',
allRowsLoaded: 'Все записи загружены',
/* Пагинация. */
rowsPerPage: 'Строк на странице',
paginationInfo: ({ from, to, count }) => `${from}–${to} из ${count}`,
previousPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
goToPage: (page) => `Перейти на страницу ${page}`,
paginationEllipsis: '…',
/* Фасетный фильтр столбца. */
filterSelectedCount: (count) => `Выбрано: ${count}`,
filterNoResults: 'Ничего не найдено.',
filterClear: 'Сбросить фильтры',
}
export const DATA_GRID_I18N_RU: DataGridI18nOverrides = {
labels: DATA_GRID_LABELS_RU,
}
/**
* Единый русский словарь ReUI Cascader (@reui/cascader-i18n).
* Передаётся в `labels` каскадера; поверх — контекстный `rootLevel`.
*
* @see https://reui.io/docs/components/base/cascader
*/
const CASCADER_ROOT_LEVEL_RU = 'Корневой уровень'
const cascaderItemCount = (count: number) =>
`${count} ${ruPlural(count, 'элемент', 'элемента', 'элементов')}`
export const CASCADER_LABELS_RU: CascaderLabels = {
search: (parentLabel) =>
parentLabel ? `Поиск: ${parentLabel}…` : 'Поиск…',
back: 'Назад',
loading: 'Загрузка…',
loadingMore: 'Загрузка…',
loadMore: 'Ещё',
error: 'Не удалось загрузить элементы.',
retry: 'Повторить',
empty: 'Ничего не найдено.',
selectedCount: (count) => `Выбрано: ${count}`,
breadcrumbLabel: 'Навигация',
chipsLabel: 'Выбранные элементы',
removeChip: (label) => `Удалить «${label}»`,
pathSeparator: '/',
rootLevel: CASCADER_ROOT_LEVEL_RU,
itemCount: cascaderItemCount,
branchAffordance: 'подменю',
selectedState: 'выбрано',
partiallySelectedState: 'частично выбрано',
columnsLabel: 'Уровни',
actionsLabel: 'Действия',
submenuAffordance: 'открывает меню',
panelLabel: 'Варианты',
keyboardHint: (mode, dir) => {
const open = dir === 'rtl' ? 'влево' : 'вправо'
const back = dir === 'rtl' ? 'вправо' : 'влево'
if (mode === 'tree') {
return `Стрелка ${open} — раскрыть уровень, стрелка ${back} — свернуть.`
}
if (mode === 'columns') {
return `Стрелка ${open} — открыть следующий столбец, стрелка ${back} — вернуться.`
}
return `Стрелка ${open} — открыть ветку, стрелка ${back} — вернуться.`
},
rootAnnouncement: (count) => `${CASCADER_ROOT_LEVEL_RU}, ${cascaderItemCount(count)}`,
expandedAnnouncement: (label, count) => `${label} раскрыт, ${cascaderItemCount(count)}`,
collapsedAnnouncement: (label) => `${label} свёрнут`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, уровень ${depth}, ${cascaderItemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? '1 результат' : `Результатов: ${count}`,
maxReachedAnnouncement: (max) => `Достигнут предел выбора: ${max}`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? 'выбрано' : 'снято'}, вложенных затронуто: ${count}`,
searchingAnnouncement: 'Поиск…',
}
+4 -4
View File
@@ -16,7 +16,7 @@ export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefine
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
return 'AS (номера)'
return 'AS-префиксы'
case 'CDN_CIDRS':
return 'CDN CIDR'
case 'DOMAINS':
@@ -30,7 +30,7 @@ export function moduleTypeRu(type: string): string {
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
tenant_refresh: 'Обновление тенанта',
tenant_refresh: 'Обновление арендатора',
peer_reconcile: 'Согласование пиров',
deploy_apply: 'Применение на спикеры',
apply: 'Применение конфигурации',
@@ -60,7 +60,7 @@ export function jobKindRu(kind: string): string {
const JOB_TRIGGER_RU: Record<string, string> = {
scheduler: 'по расписанию',
api: 'вручную',
api: 'через API',
peer_patch: 'правка пиров',
}
@@ -129,7 +129,7 @@ const JOB_STATUS_RU: Record<string, string> = {
disabled: 'Выключен',
archived: 'В архиве',
block: 'Блокировать',
accept: 'Принимать',
accept: 'Принять',
}
export function jobStatusRu(status: string): string {
-10
View File
@@ -19,16 +19,6 @@ export const SKIP_TO_CONTENT_CLASS =
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
export const kpiStatGridClassName = '@container w-full'
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
export const dashboardKpiGridClassName = kpiStatGridClassName
/** @deprecated Sparkline KPI row replaced by stats-12 grid. */
export const kpiGridClassName =
'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4'
/** @deprecated */
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Two-column chart panel row (monitoring / network overview). */
export const chartPanelGridClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
+2 -2
View File
@@ -55,7 +55,7 @@ function DirectoriesComponent() {
),
},
{
label: 'DoH профили',
label: 'DoH-профили',
value: dohProfiles.length,
icon: <Globe className="size-4" />,
footer: (
@@ -137,7 +137,7 @@ function DirectoriesComponent() {
defaultValue="communities"
items={[
{ value: 'communities', label: 'BGP community', count: communities.length },
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
{ value: 'doh', label: 'DoH-профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="communities" className="mt-0">
+12 -8
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { Bird, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Progress } from '@evobgp/ui/components/progress'
import { MonitoringHealthCard } from '@/components/analytics'
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
import { PageHeader } from '@/components/page-header'
@@ -164,14 +165,17 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
</span>
</div>
{ratio !== null ? (
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
}`}
style={{ width: `${ratio}%` }}
/>
</div>
<Progress
value={ratio}
aria-label="Установлено BGP-сессий"
className={
ratio >= 100
? '[&_[data-slot=progress-indicator]]:bg-success'
: ratio >= 50
? '[&_[data-slot=progress-indicator]]:bg-warning'
: '[&_[data-slot=progress-indicator]]:bg-destructive'
}
/>
) : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
+58 -24
View File
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
import { useMemo, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
import {
FrameSection,
@@ -14,6 +15,14 @@ import {
type QuickActionItem,
} from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
CodeBlock,
CodeBlockCopyButton,
CodeBlockExpandButton,
CodeBlockHeader,
CodeBlockTitle,
} from '@/components/reui/code-block/code-block'
import type { CodeBlockDiffSpec } from '@/components/reui/code-block/code-block-highlight'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
@@ -268,35 +277,31 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}…`,
})),
() => revisions.map((r) => ({ value: r.id, label: r.id })),
[revisions],
)
return (
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Field className="w-full max-w-xs">
<FieldLabel>Ревизия A</FieldLabel>
<SelectMenu
items={revisionItems}
value={a}
placeholder="Выберите"
onValueChange={(v) => v && setA(v)}
/>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
</Field>
<Field className="w-full max-w-xs">
<FieldLabel>Ревизия B</FieldLabel>
<SelectMenu
items={revisionItems}
value={b}
placeholder="Выберите"
onValueChange={(v) => v && setB(v)}
/>
</div>
</Field>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
</Button>
@@ -320,20 +325,49 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
if (added.length === 0 && removed.length === 0) {
return (
<p className="text-muted-foreground text-sm">Различий между ревизиями нет.</p>
)
}
// ReUI CodeBlock diff: добавленные строки идут первыми, удалённые следом,
// диапазоны размечают их (+/− канал и зелёный/красный фон строк).
const code = [...added, ...removed].join('\n')
const diffSpec: CodeBlockDiffSpec = {
...(added.length > 0 ? { added: `1-${added.length}` } : {}),
...(removed.length > 0
? { removed: `${added.length + 1}-${added.length + removed.length}` }
: {}),
}
return (
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{added.join('\n')}
</pre>
</div>
<div>
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{removed.join('\n')}
</pre>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="success-light" size="sm">
Добавлено: {added.length}
</Badge>
<Badge variant="destructive-light" size="sm">
Удалено: {removed.length}
</Badge>
</div>
<CodeBlock
code={code}
language="json"
showLineNumbers
diff={diffSpec}
maxLines={24}
label="Различия ревизий"
>
<CodeBlockHeader>
<CodeBlockTitle className="flex-1">
{`${diff.revision_a ?? '—'} → ${diff.revision_b ?? '—'}`}
</CodeBlockTitle>
<CodeBlockCopyButton />
<CodeBlockExpandButton>Показать всё</CodeBlockExpandButton>
</CodeBlockHeader>
</CodeBlock>
</div>
)
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,8 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { Separator } from "@evobgp/ui/components/separator"
const buttonGroupVariants = cva(
-1
View File
@@ -1,6 +1,5 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
const buttonVariants = cva(
-1
View File
@@ -1,5 +1,4 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@evobgp/ui/lib/utils"
import { CheckIcon } from "lucide-react"
@@ -2,7 +2,6 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@evobgp/ui/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
-1
View File
@@ -1,6 +1,5 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@evobgp/ui/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
-1
View File
@@ -1,6 +1,5 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@evobgp/ui/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
@@ -1,8 +1,5 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@evobgp/ui/lib/utils"
function ScrollArea({
-1
View File
@@ -2,7 +2,6 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@evobgp/ui/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
-1
View File
@@ -1,5 +1,4 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"
function Separator({
-1
View File
@@ -1,7 +1,6 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@evobgp/ui/lib/utils"
function TooltipProvider({
+337
View File
@@ -119,6 +119,9 @@ importers:
shadcn:
specifier: ^4.19.0
version: 4.19.0([email protected])
shiki:
specifier: ^4.4.3
version: 4.4.3
sonner:
specifier: ^1.7.0
version: 1.7.4([email protected]([email protected]))([email protected])
@@ -1414,6 +1417,37 @@ packages:
peerDependencies:
semantic-release: '>=20.1.0'
'@shikijs/[email protected]':
resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==}
engines: {node: '>=20'}
'@shikijs/[email protected]':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@simple-libs/[email protected]':
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
engines: {node: '>=18'}
@@ -1716,12 +1750,18 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/[email protected]':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
'@types/[email protected]':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
'@types/[email protected]':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/[email protected]':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/[email protected]':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -1742,6 +1782,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/[email protected]':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/[email protected]':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -1810,6 +1853,9 @@ packages:
resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/[email protected]':
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
'@vitejs/[email protected]':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2078,6 +2124,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==}
[email protected]:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
[email protected]:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -2101,6 +2150,12 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
[email protected]:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
[email protected]:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -2191,6 +2246,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
[email protected]:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
@@ -2445,6 +2503,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2452,6 +2514,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
[email protected]:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
[email protected]:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -2954,6 +3019,12 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
[email protected]:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
[email protected]:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -2973,6 +3044,9 @@ packages:
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
engines: {node: ^20.17.0 || >=22.9.0}
[email protected]:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
[email protected]:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3545,6 +3619,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
[email protected]:
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
engines: {node: '>= 0.8'}
@@ -3568,6 +3645,21 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
[email protected]:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
[email protected]:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
[email protected]:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
[email protected]:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
[email protected]:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -3860,6 +3952,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
[email protected]:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
[email protected]:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
@@ -4133,6 +4231,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
[email protected]:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -4332,6 +4433,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==}
[email protected]:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
[email protected]:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
[email protected]:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
[email protected]:
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
engines: {node: '>=14'}
@@ -4453,6 +4563,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==}
@@ -4541,6 +4655,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
[email protected]:
resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==}
@@ -4594,6 +4711,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
[email protected]:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
[email protected]:
resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
engines: {node: '>=14.16'}
@@ -4772,6 +4892,9 @@ packages:
resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
[email protected]:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -4870,6 +4993,21 @@ packages:
resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
[email protected]:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
[email protected]:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
[email protected]:
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
[email protected]:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
[email protected]:
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
@@ -4972,6 +5110,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
[email protected]:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -5194,6 +5338,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
[email protected]:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
snapshots:
'@actions/[email protected]':
@@ -6469,6 +6616,46 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@shikijs/[email protected]':
dependencies:
'@shikijs/primitive': 4.4.3
'@shikijs/types': 4.4.3
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/[email protected]':
dependencies:
'@shikijs/types': 4.4.3
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/[email protected]':
dependencies:
'@shikijs/types': 4.4.3
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/[email protected]':
dependencies:
'@shikijs/types': 4.4.3
'@shikijs/[email protected]':
dependencies:
'@shikijs/types': 4.4.3
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/[email protected]':
dependencies:
'@shikijs/types': 4.4.3
'@shikijs/[email protected]':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/[email protected]': {}
'@simple-libs/[email protected]': {}
'@sinclair/[email protected]': {}
@@ -6765,10 +6952,18 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/unist': 3.0.3
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/unist': 3.0.3
'@types/[email protected]':
dependencies:
undici-types: 6.21.0
@@ -6790,6 +6985,8 @@ snapshots:
'@types/[email protected]':
optional: true
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -6887,6 +7084,8 @@ snapshots:
'@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1
'@ungap/[email protected]': {}
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))':
dependencies:
'@babel/core': 7.29.7
@@ -7159,6 +7358,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
assertion-error: 2.0.1
@@ -7184,6 +7385,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -7289,6 +7494,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -7501,10 +7708,16 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
dequal: 2.0.3
[email protected]: {}
[email protected]: {}
@@ -8090,6 +8303,24 @@ snapshots:
dependencies:
function-bind: 1.1.2
[email protected]:
dependencies:
'@types/hast': 3.0.5
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.2.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
[email protected]:
dependencies:
'@types/hast': 3.0.5
[email protected]: {}
[email protected]: {}
@@ -8104,6 +8335,8 @@ snapshots:
dependencies:
lru-cache: 11.5.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -8553,6 +8786,18 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.4.0
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
[email protected]: {}
[email protected]: {}
@@ -8565,6 +8810,23 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
[email protected]: {}
[email protected]:
dependencies:
micromark-util-character: 2.1.1
micromark-util-encode: 2.0.1
micromark-util-symbol: 2.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
braces: 3.0.3
@@ -8762,6 +9024,14 @@ snapshots:
dependencies:
mimic-function: 5.0.1
[email protected]: {}
[email protected]:
dependencies:
oniguruma-parser: 0.12.2
regex: 6.1.0
regex-recursion: 6.0.2
[email protected]:
dependencies:
default-browser: 5.5.1
@@ -9014,6 +9284,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -9256,6 +9528,16 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
regex-utilities: 2.3.0
[email protected]: {}
[email protected]:
dependencies:
regex-utilities: 2.3.0
[email protected]:
dependencies:
'@pnpm/npm-conf': 3.0.3
@@ -9461,6 +9743,17 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@shikijs/core': 4.4.3
'@shikijs/engine-javascript': 4.4.3
'@shikijs/engine-oniguruma': 4.4.3
'@shikijs/langs': 4.4.3
'@shikijs/themes': 4.4.3
'@shikijs/types': 4.4.3
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
[email protected]:
dependencies:
should-type: 1.4.0
@@ -9565,6 +9858,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -9618,6 +9913,11 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
[email protected]:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
[email protected]:
dependencies:
get-own-enumerable-keys: 1.0.0
@@ -9769,6 +10069,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
typescript: 5.9.3
@@ -9846,6 +10148,29 @@ snapshots:
dependencies:
crypto-random-string: 4.0.0
[email protected]:
dependencies:
'@types/unist': 3.0.3
[email protected]:
dependencies:
'@types/unist': 3.0.3
[email protected]:
dependencies:
'@types/unist': 3.0.3
[email protected]:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
[email protected]:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
[email protected]: {}
[email protected]: {}
@@ -9908,6 +10233,16 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
[email protected]:
dependencies:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected]:
dependencies:
'@types/d3-array': 3.2.2
@@ -10131,3 +10466,5 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
+3 -1
View File
@@ -140,7 +140,9 @@ if ([string]::IsNullOrWhiteSpace($stagedRaw)) {
$stagedFiles = @(
$stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
)
$stagedFiles = $stagedFiles | Sort-Object -Unique
$stagedFiles = @(
$stagedFiles | Sort-Object -Unique
)
$scopeBuckets = @{}
foreach ($f in $stagedFiles) {