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 { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' 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 { PlusIcon } from '@phosphor-icons/react'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries' import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
@@ -167,11 +167,6 @@ function DictionariesPage() {
return out return out
}, [data]) }, [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 filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
const resetFilters = () => navigate({ search: {} }) const resetFilters = () => navigate({ search: {} })
@@ -223,40 +218,27 @@ function DictionariesPage() {
return ( return (
<section <section
aria-label={t('dict.list.heading')} aria-label={t('dict.list.heading')}
className="space-y-6" className="space-y-4"
> >
<PageHeader {/* PageHeader per redesign: title + inline "N шт." count, без description.
title={t('nav.dictionaries')} Actions удалены — + Создать переехала в toolbar справа от Граф ⇄. */}
description={t('dict.list.subtitle')} <div className="flex items-baseline gap-3">
actions={createButton} <h1 className="text-title-lg text-ink">{t('nav.dictionaries')}</h1>
/> <span className="text-cap text-mute">
{data.length} {t('dict.list.countSuffix', { defaultValue: 'шт.' })}
{/* Compact toolbar: всё в одной строке (wraps на narrow viewport). </span>
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> </div>
<span className="text-mono text-mute whitespace-nowrap tabular-nums"> {/* Toolbar per redesign prototype: [scope pills] | [bundle pills] [...] N/M [Граф ⇄] [+ Создать] */}
{filtered.length}/{data.length} <div className="flex flex-wrap items-center gap-3">
</span> {/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
{/* Scope chips: компактные, dot + count, full label по hover'у через title */}
<div <div
role="group" role="group"
aria-label={t('dict.list.filter.scope')} aria-label={t('dict.list.filter.scope')}
className="flex items-center gap-1" className="flex items-center gap-1.5"
> >
{SCOPE_ORDER.map((scope) => { {SCOPE_ORDER.map((scope) => {
const selected = scopeFilter.has(scope) const selected = scopeFilter.has(scope)
const count = scopeCounts[scope]
return ( return (
<button <button
key={scope} key={scope}
@@ -264,27 +246,22 @@ function DictionariesPage() {
onClick={() => toggleScope(scope)} onClick={() => toggleScope(scope)}
aria-pressed={selected} aria-pressed={selected}
title={t(`dict.list.section.${scope}`)} 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 selected
? 'border-accent bg-accent-bg text-accent' ? 'border-ink bg-surface text-ink ring-1 ring-ink'
: 'border-line hover:border-accent/60 text-ink' : 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`} }`}
> >
<span {scope}
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>
</button> </button>
) )
})} })}
</div> </div>
{/* Vertical divider */} {/* Vertical divider между scope и bundle */}
<span className="h-5 w-px bg-line" aria-hidden="true" /> <span className="h-6 w-px bg-line shrink-0" aria-hidden="true" />
{/* Bundle filter — chips inline */} {/* Bundle filter — все / cuod / geo / i18n / core */}
<div <div
role="group" role="group"
aria-label="Bundle" aria-label="Bundle"
@@ -294,17 +271,17 @@ function DictionariesPage() {
type="button" type="button"
onClick={() => setBundle(undefined)} onClick={() => setBundle(undefined)}
aria-pressed={!bundleFilter} 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 !bundleFilter
? 'border-accent bg-accent-bg text-accent' ? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
: 'border-line hover:border-accent/60 text-ink' : '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> </button>
{Array.from(bundleCounts.entries()) {Array.from(bundleCounts.entries())
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([bundle, count]) => { .map(([bundle]) => {
const selected = bundleFilter === bundle const selected = bundleFilter === bundle
return ( return (
<button <button
@@ -312,35 +289,39 @@ function DictionariesPage() {
type="button" type="button"
onClick={() => setBundle(selected ? undefined : bundle)} onClick={() => setBundle(selected ? undefined : bundle)}
aria-pressed={selected} 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 selected
? 'border-accent bg-accent-bg text-accent' ? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
: 'border-line hover:border-accent/60 text-ink' : 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`} }`}
> >
{bundle} {bundle}
<span className="text-mute">{count}</span>
</button> </button>
) )
})} })}
</div> </div>
{/* Vertical divider + deps toggle */} {/* Spacer */}
<span className="h-5 w-px bg-line" aria-hidden="true" /> <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>
{/* Граф ⇄ — link to relations graph view */}
<button <button
type="button" type="button"
onClick={() => setSearch({ deps: withDepsOnly ? undefined : '1' })} onClick={() => navigate({ to: '/graph' })}
aria-pressed={withDepsOnly} 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.deps.only')} title={t('dict.list.graph', { defaultValue: 'Граф связей' })}
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')} {t('dict.list.graph', { defaultValue: 'Граф' })}
<span aria-hidden className="text-mute"></span>
</button> </button>
{/* + Создать (moved here from PageHeader actions per redesign) */}
{createButton}
</div>
</div> </div>
{/* Empty state */} {/* Empty state */}