Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Web header — restore search icon on non-home pages (2026-05-24)

- **Search icon back in the header** ([`4eb1986`](https://github.com/mrviduus/textstack/commit/4eb1986)) — was removed in [`3e53e3e`](https://github.com/mrviduus/textstack/commit/3e53e3e) on the assumption that the hero search on home was enough. It wasn't: on every other page (library, discover, vocabulary, reader, …) the hero is gone and users had nowhere to launch a search from. Icon now shows on all routes except home (where the hero input still owns the affordance), opens the existing `MobileSearchOverlay`, and ships with 10 new `Header.test.tsx` cases pinning visibility per route + open/close flow.

### Safe refactor — backend partial-class splits + util tests (2026-05-24)

Cosmetic refactor pass: three god-class backend files split across C# partial
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { LocalizedLink } from './LocalizedLink'
import { DiscoverMenu } from './DiscoverMenu'
import { MobileSearchOverlay } from './Search'
import { LoginButton } from './auth/LoginButton'
import { UserMenu } from './auth/UserMenu'
import { useAuth } from '../context/AuthContext'
Expand All @@ -15,12 +17,20 @@ import { emit } from '../lib/telemetry/navTelemetry'

export function Header() {
const [badgePopup, setBadgePopup] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const badgeWrapperRef = useRef<HTMLDivElement>(null)
const { isAuthenticated, isLoading } = useAuth()
const isScrolled = useScrolled(50)
const { isDark, toggleTheme } = useDarkMode()
const { t } = useTranslation()
const quickStats = useQuickStats()
// Suppress the header search icon on the home page — HeroSection already
// renders a prominent search input there, so duplicating it in the chrome
// would be visual noise. On every other route the hero is gone, and users
// have nowhere else to launch a search from (was the regression that
// motivated bringing the icon back — see 3e53e3e for the removal).
const location = useLocation()
const isHomePage = /^\/(en|uk)?\/?$/.test(location.pathname)

return (
<header className={`site-header ${isScrolled ? 'site-header--scrolled' : ''}`}>
Expand Down Expand Up @@ -68,6 +78,19 @@ export function Header() {
</div>
<div className="site-header__right">
<UploadButton />
{!isHomePage && (
<button
className="site-header__icon-btn"
onClick={() => {
emit('header.click', { item: 'search' })
setSearchOpen(true)
}}
aria-label={t('nav.search')}
title={t('nav.search')}
>
<span className="material-icons-outlined">search</span>
</button>
)}
<button
className="site-header__icon-btn"
onClick={toggleTheme}
Expand Down Expand Up @@ -101,6 +124,7 @@ export function Header() {
)}
{!isLoading && (isAuthenticated ? <UserMenu /> : <LoginButton />)}
</div>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
</header>
)
}
77 changes: 74 additions & 3 deletions apps/web/src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Header } from '../Header'

Expand All @@ -25,6 +25,7 @@ vi.mock('../../hooks/useTranslation', () => ({
'nav.library': 'Library',
'nav.discover': 'Discover',
'nav.vocabulary': 'Vocabulary',
'nav.search': 'Search',
'nav.about': 'About',
'nav.aboutTextStack': 'About TextStack',
'nav.brandTitle': 'TextStack',
Expand All @@ -40,10 +41,19 @@ vi.mock('../auth/UserMenu', () => ({ UserMenu: () => <div data-testid="user-menu
vi.mock('../library/UploadButton', () => ({ UploadButton: () => <button>Upload</button> }))
vi.mock('../StreakBadge', () => ({ StreakBadge: () => null }))
vi.mock('../VocabBadgePopup', () => ({ VocabBadgePopup: () => null }))
// MobileSearchOverlay pulls in api hooks + IndexedDB-touching code we don't
// need to exercise here — assert visibility via a marker div.
vi.mock('../Search', () => ({
MobileSearchOverlay: ({ onClose }: { onClose: () => void }) => (
<div data-testid="search-overlay">
<button onClick={onClose}>Close search</button>
</div>
),
}))

function renderHeader() {
function renderHeader(initialPath = '/') {
return render(
<MemoryRouter>
<MemoryRouter initialEntries={[initialPath]}>
<Header />
</MemoryRouter>
)
Expand Down Expand Up @@ -87,4 +97,65 @@ describe('Header', () => {
const brand = screen.getByTitle('TextStack')
expect(brand).toHaveAttribute('href', '/en')
})

// Search icon was removed in 3e53e3e ("clean header") on the assumption that
// the hero search on home was enough. It wasn't — on every other page the
// hero is gone and users had nowhere to launch a search from. These tests
// pin the new behavior: visible on non-home routes, hidden on home + on /uk
// home (and bare home for unauth marketing root).

it('search icon hidden on home (hero already has search)', () => {
renderHeader('/')
expect(screen.queryByLabelText('Search')).not.toBeInTheDocument()
})

it('search icon hidden on /en home', () => {
renderHeader('/en')
expect(screen.queryByLabelText('Search')).not.toBeInTheDocument()
})

it('search icon hidden on /en/ trailing-slash home', () => {
renderHeader('/en/')
expect(screen.queryByLabelText('Search')).not.toBeInTheDocument()
})

it('search icon hidden on /uk home (multilingual root)', () => {
renderHeader('/uk')
expect(screen.queryByLabelText('Search')).not.toBeInTheDocument()
})

it('search icon visible on /en/library', () => {
renderHeader('/en/library')
expect(screen.getByLabelText('Search')).toBeInTheDocument()
})

it('search icon visible on /en/discover', () => {
renderHeader('/en/discover')
expect(screen.getByLabelText('Search')).toBeInTheDocument()
})

it('search icon visible on /en/vocabulary', () => {
renderHeader('/en/vocabulary')
expect(screen.getByLabelText('Search')).toBeInTheDocument()
})

it('search icon visible on a reader route /en/books/foo', () => {
renderHeader('/en/books/foo')
expect(screen.getByLabelText('Search')).toBeInTheDocument()
})

it('clicking search icon opens overlay', () => {
renderHeader('/en/library')
expect(screen.queryByTestId('search-overlay')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Search'))
expect(screen.getByTestId('search-overlay')).toBeInTheDocument()
})

it('overlay close button removes the overlay', () => {
renderHeader('/en/library')
fireEvent.click(screen.getByLabelText('Search'))
expect(screen.getByTestId('search-overlay')).toBeInTheDocument()
fireEvent.click(screen.getByText('Close search'))
expect(screen.queryByTestId('search-overlay')).not.toBeInTheDocument()
})
})
1 change: 1 addition & 0 deletions apps/web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@
"library": "Library",
"discover": "Discover",
"vocabulary": "Vocabulary",
"search": "Search",
"highlights": "Highlights",
"about": "About",
"brandTitle": "TextStack Reader - Learn languages through reading",
Expand Down
Loading