feat(catalog): toolbar layout per redesign prototype

Match handoff screenshot:
- Title "Справочники" + inline "N шт." count caption (no description text)
- Remove page-level SearchInput (TopBar already has global search ⌘K)
- Reorder toolbar to single row:
  [scope pills PUBLIC|INTERNAL|RESTRICTED]
  | divider |
  [bundle pills все|cuod|geo|i18n|core]
  | spacer |
  N/M counter
  [Граф ⇄] (→ /graph)
  [+ Создать] (moved here from PageHeader actions)
- Scope/bundle pills are bordered chips с ring-1 для active state
- Drop deps-only toggle (not in redesign)
- Drop scopeCounts useMemo (counts moved out of chip labels per design)
This commit is contained in:
Zimin A.N.
2026-05-11 19:09:39 +03:00
parent 5a2220d779
commit ad7c51d93f
@@ -16,7 +16,7 @@
import { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui'
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui'
import { PlusIcon } from '@phosphor-icons/react'
import { useQueries } from '@tanstack/react-query'
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
@@ -167,11 +167,6 @@ function DictionariesPage() {
return out
}, [data])
const scopeCounts = useMemo(() => {
const out: Record<DataScope, number> = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 }
if (data) for (const d of data) out[d.scope]++
return out
}, [data])
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
const resetFilters = () => navigate({ search: {} })
@@ -223,40 +218,27 @@ function DictionariesPage() {
return (
<section
aria-label={t('dict.list.heading')}
className="space-y-6"
className="space-y-4"
>
<PageHeader
title={t('nav.dictionaries')}
description={t('dict.list.subtitle')}
actions={createButton}
/>
{/* Compact toolbar: всё в одной строке (wraps на narrow viewport).
Tradeoff: scope/bundle chips меньше padding'а + краткие labels.
UPPERCASE labels оставлены для consistency с brand voice (Tektur-like). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex-1 min-w-[240px] max-w-sm">
<SearchInput
value={search.q ?? ''}
onChange={(e) => setSearch({ q: e.target.value })}
placeholder={t('dict.list.search.placeholder')}
aria-label={t('dict.list.search.placeholder')}
/>
</div>
<span className="text-mono text-mute whitespace-nowrap tabular-nums">
{filtered.length}/{data.length}
{/* PageHeader per redesign: title + inline "N шт." count, без description.
Actions удалены — + Создать переехала в toolbar справа от Граф ⇄. */}
<div className="flex items-baseline gap-3">
<h1 className="text-title-lg text-ink">{t('nav.dictionaries')}</h1>
<span className="text-cap text-mute">
{data.length} {t('dict.list.countSuffix', { defaultValue: 'шт.' })}
</span>
</div>
{/* Scope chips: компактные, dot + count, full label по hover'у через title */}
{/* Toolbar per redesign prototype: [scope pills] | [bundle pills] [...] N/M [Граф ⇄] [+ Создать] */}
<div className="flex flex-wrap items-center gap-3">
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
<div
role="group"
aria-label={t('dict.list.filter.scope')}
className="flex items-center gap-1"
className="flex items-center gap-1.5"
>
{SCOPE_ORDER.map((scope) => {
const selected = scopeFilter.has(scope)
const count = scopeCounts[scope]
return (
<button
key={scope}
@@ -264,27 +246,22 @@ function DictionariesPage() {
onClick={() => toggleScope(scope)}
aria-pressed={selected}
title={t(`dict.list.section.${scope}`)}
className={`text-cap px-2 py-1 rounded-full flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected
? 'border-accent bg-accent-bg text-accent'
: 'border-line hover:border-accent/60 text-ink'
? 'border-ink bg-surface text-ink ring-1 ring-ink'
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`}
>
<span
className={`inline-block size-1.5 rounded-full ${SCOPE_DOT[scope]}`}
aria-hidden="true"
/>
{t(`dict.list.section.${scope}.short`)}
<span className="text-mute ml-0.5">{count}</span>
{scope}
</button>
)
})}
</div>
{/* Vertical divider */}
<span className="h-5 w-px bg-line" aria-hidden="true" />
{/* Vertical divider между scope и bundle */}
<span className="h-6 w-px bg-line shrink-0" aria-hidden="true" />
{/* Bundle filter — chips inline */}
{/* Bundle filter — все / cuod / geo / i18n / core */}
<div
role="group"
aria-label="Bundle"
@@ -294,17 +271,17 @@ function DictionariesPage() {
type="button"
onClick={() => setBundle(undefined)}
aria-pressed={!bundleFilter}
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
className={`text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
!bundleFilter
? 'border-accent bg-accent-bg text-accent'
: 'border-line hover:border-accent/60 text-ink'
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`}
>
{t('dict.list.bundle.all')}
{t('dict.list.bundle.all', { defaultValue: 'все' })}
</button>
{Array.from(bundleCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([bundle, count]) => {
.map(([bundle]) => {
const selected = bundleFilter === bundle
return (
<button
@@ -312,35 +289,39 @@ function DictionariesPage() {
type="button"
onClick={() => setBundle(selected ? undefined : bundle)}
aria-pressed={selected}
className={`text-cap px-2 py-1 rounded-sm flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
className={`text-mono text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected
? 'border-accent bg-accent-bg text-accent'
: 'border-line hover:border-accent/60 text-ink'
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`}
>
{bundle}
<span className="text-mute">{count}</span>
</button>
)
})}
</div>
{/* Vertical divider + deps toggle */}
<span className="h-5 w-px bg-line" aria-hidden="true" />
{/* Spacer */}
<div className="ml-auto flex items-center gap-2">
{/* N / M counter */}
<span className="text-cell text-mute whitespace-nowrap tabular-nums">
{filtered.length} / {data.length}
</span>
<button
type="button"
onClick={() => setSearch({ deps: withDepsOnly ? undefined : '1' })}
aria-pressed={withDepsOnly}
title={t('dict.list.deps.only')}
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
withDepsOnly
? 'border-accent bg-accent-bg text-accent'
: 'border-line hover:border-accent/60 text-ink'
}`}
>
{t('dict.list.deps.short')}
</button>
{/* Граф ⇄ — link to relations graph view */}
<button
type="button"
onClick={() => navigate({ to: '/graph' })}
className="h-9 px-3 rounded-md border border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2 text-body inline-flex items-center gap-1.5 transition-colors"
title={t('dict.list.graph', { defaultValue: 'Граф связей' })}
>
{t('dict.list.graph', { defaultValue: 'Граф' })}
<span aria-hidden className="text-mute"></span>
</button>
{/* + Создать (moved here from PageHeader actions per redesign) */}
{createButton}
</div>
</div>
{/* Empty state */}