Merge branch 'fix/topbar-restore-search-single-x' into 'main'

fix(ui): restore TopBar search + notifications bell + theme to sidebar

See merge request 2-6/2-6-4/terravault/ordinis!91
This commit is contained in:
Александр Зимин
2026-05-11 19:09:15 +00:00
4 changed files with 51 additions and 27 deletions
@@ -16,6 +16,7 @@ import { useAuth } from 'react-oidc-context'
import { useCanMutate } from '@/auth/useCanMutate' import { useCanMutate } from '@/auth/useCanMutate'
import { useDictionaries, useMyDrafts } from '@/api/queries' import { useDictionaries, useMyDrafts } from '@/api/queries'
import { Sheet, SheetContent } from '@/ui' import { Sheet, SheetContent } from '@/ui'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** /**
@@ -238,20 +239,22 @@ function SidebarFooter({
return ( return (
<> <>
{/* Language toggle row (только когда expanded). */} {/* Language toggle + Theme switch row (только когда expanded).
* ThemeSwitch и LanguageSwitch переехали из TopBar per user
* ("свитч темы можно тоже в профиль перенести"). */}
{!collapsed && ( {!collapsed && (
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0"> <div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
<span className="text-cap text-mute">
{t('sidebar.lang', { defaultValue: 'Язык' })}
</span>
<button <button
type="button" type="button"
onClick={() => i18n.changeLanguage(next)} onClick={() => i18n.changeLanguage(next)}
className="ml-auto h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors" className="h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
aria-label={`Language: ${cur.toUpperCase()}, click to switch`} aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
> >
{cur.toUpperCase()} {cur.toUpperCase()}
</button> </button>
<div className="ml-auto">
<ThemeSwitch />
</div>
</div> </div>
)} )}
@@ -1,10 +1,9 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from '@tanstack/react-router' import { Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useDictionaries, useDictionaryDetail } from '@/api/queries' import { useDictionaries, useDictionaryDetail } from '@/api/queries'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { VersionBadge } from '@/components/version/VersionBadge' import { VersionBadge } from '@/components/version/VersionBadge'
import { SearchInput } from '@/ui/components/search-input' import { SearchInput } from '@/ui/components/search-input'
import { Menu } from 'lucide-react' import { Bell, Menu } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
/** /**
@@ -21,28 +20,24 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const isCatalog = location.pathname === '/dictionaries' const isCatalog = location.pathname === '/dictionaries'
// Catalog mode: bind input to URL ?q= (live filter). Else: local state submitting к /search.
const catalogQ = isCatalog const catalogQ = isCatalog
? ((location.search as Record<string, unknown>)?.q as string | undefined) ?? '' ? ((location.search as Record<string, unknown>)?.q as string | undefined) ?? ''
: '' : ''
const [searchValue, setSearchValue] = useState(catalogQ) const [searchValue, setSearchValue] = useState(catalogQ)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const breadcrumb = useBreadcrumb()
// Sync local state с URL когда navigate на /dictionaries (catalog). // Sync local input value c URL `?q=` when navigating between routes.
useEffect(() => { useEffect(() => {
setSearchValue(catalogQ) setSearchValue(catalogQ)
}, [catalogQ]) }, [catalogQ])
const breadcrumb = useBreadcrumb() // ⌘K / Ctrl+K focus shortcut.
// ⌘K / Ctrl+K shortcut — focus search input from anywhere. Skip когда
// активен другой input/textarea (юзер печатает в record edit form).
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
// Уже в input — пусть default browser shortcut работает.
if (target === inputRef.current) return if (target === inputRef.current) return
} }
e.preventDefault() e.preventDefault()
@@ -55,10 +50,8 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
}, []) }, [])
// Context-aware search: // Context-aware search:
// - На /dictionaries (catalog) каждый keystroke обновляет URL `?q=` чтобы // - На /dictionaries — live filter каталога через URL `?q=`
// filter работал live. Submit (Enter) — no-op (фильтр уже applied). // - На остальных routes — local state, submit redirect'ит на /search
// - На остальных routes input — local state, submit redirect'ит на /search
// с records search.
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value const v = e.target.value
setSearchValue(v) setSearchValue(v)
@@ -78,7 +71,7 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
e.preventDefault() e.preventDefault()
const q = searchValue.trim() const q = searchValue.trim()
if (q.length === 0) return if (q.length === 0) return
if (isCatalog) return // На catalog уже live filter, submit не нужен. if (isCatalog) return
void navigate({ to: '/search', search: { q } }) void navigate({ to: '/search', search: { q } })
} }
@@ -151,7 +144,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
})} })}
</nav> </nav>
{/* Search center, max-width per handoff */} {/* Search center — context-aware (catalog mode = live filter, else =
* records search submit). Двойной X fixed в SearchInput component
* (type="text" вместо "search" — убрал native browser X). */}
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]"> <form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
<SearchInput <SearchInput
ref={inputRef} ref={inputRef}
@@ -168,9 +163,10 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
/> />
</form> </form>
{/* Right cluster — Docs + VersionBadge + ThemeSwitch only. {/* Right cluster — broadcast slot per user: "руководство и версию
* AuthBadge + LanguageSwitch переехали в Sidebar footer per user * оставить в топбар чтобы подсвечивать новинки. туда же и сообщения".
* feedback ("вход в сайдбар перенесем", "ru/en в сайдбаре"). */} * Docs + VersionBadge + Notifications. ThemeSwitch / Lang / Auth
* переехали в Sidebar footer. */}
<div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0"> <div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0">
<a <a
href="/docs/" href="/docs/"
@@ -183,12 +179,32 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
<span className="hidden md:inline-flex"> <span className="hidden md:inline-flex">
<VersionBadge /> <VersionBadge />
</span> </span>
<ThemeSwitch /> <NotificationsBell />
</div> </div>
</header> </header>
) )
} }
/**
* Placeholder notifications bell — будет показывать N unread сообщений
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
* Сейчас static bell без counter — wire'ится когда backend notifications
* stream появится.
*/
function NotificationsBell() {
const { t } = useTranslation()
return (
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
</button>
)
}
type Crumb = { type Crumb = {
label: string label: string
to?: string to?: string
@@ -231,10 +231,12 @@ function DictionariesPage() {
{/* Toolbar — top section of the panel, tinted bg + bottom border. {/* Toolbar — top section of the panel, tinted bg + bottom border.
* Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */} * Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
<div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5"> <div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5">
{/* Catalog search — filter dicts by name/displayName/description. */} {/* Catalog search — filter dicts by name/displayName/description.
* type="text" (не "search") чтобы избежать native X — мы рендерим
* свой clear button если нужно. */}
<div className="flex-1 min-w-[200px] max-w-xs"> <div className="flex-1 min-w-[200px] max-w-xs">
<input <input
type="search" type="text"
value={search.q ?? ''} value={search.q ?? ''}
onChange={(e) => onChange={(e) =>
setSearch({ q: e.target.value || undefined }) setSearch({ q: e.target.value || undefined })
@@ -23,7 +23,10 @@ export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
/> />
<Input <Input
ref={ref} ref={ref}
type="search" // type="text" вместо "search" — native browser X (Chrome/Safari)
// дублировал custom onClear X справа ("два крестика"). Custom X
// имеет правильный onClick handler и tracks state, native — нет.
type="text"
value={value} value={value}
className={cn('pl-8', hasValue && onClear && 'pr-8', className)} className={cn('pl-8', hasValue && onClear && 'pr-8', className)}
{...props} {...props}