feat: Extracted inline typeahead from Home.tsx into shared SearchAutoco…

- "frontend/src/components/SearchAutocomplete.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/Home.tsx"
- "frontend/src/pages/SearchResults.tsx"
- "frontend/src/App.css"

GSD-Task: S04/T02
This commit is contained in:
jlightner 2026-03-31 06:39:01 +00:00
parent 1254e173d4
commit 2a8b0b3a84
9 changed files with 415 additions and 165 deletions

View file

@ -14,7 +14,7 @@ Steps:
- Estimate: 30m
- Files: backend/schemas.py, backend/routers/search.py, backend/tests/test_search.py
- Verify: cd backend && python -m pytest tests/test_search.py -v -k suggestion
- [ ] **T02: Extract SearchAutocomplete component with popular suggestions on focus** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
- [x] **T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
Steps:
1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`.

View file

@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M010/S04/T01",
"timestamp": 1774938937324,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "python -m pytest tests/test_search.py -v -k suggestion",
"exitCode": 4,
"durationMs": 216,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,83 @@
---
id: T02
parent: S04
milestone: M010
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/SearchAutocomplete.tsx", "frontend/src/api/public-client.ts", "frontend/src/pages/Home.tsx", "frontend/src/pages/SearchResults.tsx", "frontend/src/App.css"]
key_decisions: ["Popular suggestions fetched once on mount via ref guard, not re-fetched on every focus", "Suggestion items use button elements since they trigger onSearch callback, not direct navigation"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend build (tsc + vite) passes with zero errors. Backend suggestion tests at backend/tests/test_search.py exist but require live PostgreSQL on ub01."
completed_at: 2026-03-31T06:38:55.912Z
blocker_discovered: false
---
# T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
> Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
## What Happened
---
id: T02
parent: S04
milestone: M010
key_files:
- frontend/src/components/SearchAutocomplete.tsx
- frontend/src/api/public-client.ts
- frontend/src/pages/Home.tsx
- frontend/src/pages/SearchResults.tsx
- frontend/src/App.css
key_decisions:
- Popular suggestions fetched once on mount via ref guard, not re-fetched on every focus
- Suggestion items use button elements since they trigger onSearch callback, not direct navigation
duration: ""
verification_result: passed
completed_at: 2026-03-31T06:38:55.913Z
blocker_discovered: false
---
# T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
**Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages**
## What Happened
Created SearchAutocomplete.tsx encapsulating debounced typeahead search (2+ chars), popular suggestions on empty focus (fetched once on mount from /api/v1/search/suggestions), outside-click/Escape dismissal, and Enter submission via onSearch callback. Added fetchSuggestions() and types to the API client. Refactored Home.tsx to replace ~80 lines of inline typeahead with a single component. Refactored SearchResults.tsx similarly. Added CSS for popular suggestions header, buttons, and type badge color variants.
## Verification
Frontend build (tsc + vite) passes with zero errors. Backend suggestion tests at backend/tests/test_search.py exist but require live PostgreSQL on ub01.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2800ms |
## Deviations
Original verification failure used wrong test path (tests/test_search.py vs backend/tests/test_search.py). Backend tests require PostgreSQL on ub01, unavailable locally.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/SearchAutocomplete.tsx`
- `frontend/src/api/public-client.ts`
- `frontend/src/pages/Home.tsx`
- `frontend/src/pages/SearchResults.tsx`
- `frontend/src/App.css`
## Deviations
Original verification failure used wrong test path (tests/test_search.py vs backend/tests/test_search.py). Backend tests require PostgreSQL on ub01, unavailable locally.
## Known Issues
None.

View file

@ -1131,6 +1131,59 @@ a.app-footer__repo:hover {
background: var(--color-bg-surface-hover);
}
/* Popular suggestions (shown on empty focus) */
.typeahead-suggestions-header {
padding: 0.5rem 1rem 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.typeahead-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
color: var(--color-text);
transition: background 0.1s;
}
.typeahead-suggestion:hover {
background: var(--color-bg-surface-hover);
}
.typeahead-suggestion__text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeahead-item__type--technique {
background: var(--color-badge-technique-bg, rgba(34, 211, 238, 0.15));
color: var(--color-badge-technique-text, #22d3ee);
}
.typeahead-item__type--topic {
background: var(--color-badge-topic-bg, rgba(168, 85, 247, 0.15));
color: var(--color-badge-topic-text, #a855f7);
}
.typeahead-item__type--creator {
background: var(--color-badge-creator-bg, rgba(251, 146, 60, 0.15));
color: var(--color-badge-creator-text, #fb923c);
}
/* ── Navigation cards ─────────────────────────────────────────────────────── */
.nav-cards {

View file

@ -203,6 +203,19 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
// ── Search ───────────────────────────────────────────────────────────────────
export interface SuggestionItem {
text: string;
type: "topic" | "technique" | "creator";
}
export interface SuggestionsResponse {
suggestions: SuggestionItem[];
}
export async function fetchSuggestions(): Promise<SuggestionsResponse> {
return request<SuggestionsResponse>(`${BASE}/search/suggestions`);
}
export async function searchApi(
q: string,
scope?: string,

View file

@ -0,0 +1,225 @@
/**
* Shared search autocomplete component.
*
* - On focus with empty input: shows popular suggestions from /api/v1/search/suggestions
* - On 2+ chars: shows debounced typeahead search results (top 5)
* - Escape / outside-click closes dropdown
* - Enter submits via onSearch callback
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
searchApi,
fetchSuggestions,
type SearchResultItem,
type SuggestionItem,
} from "../api/public-client";
interface SearchAutocompleteProps {
onSearch: (query: string) => void;
placeholder?: string;
heroSize?: boolean;
initialQuery?: string;
autoFocus?: boolean;
}
export default function SearchAutocomplete({
onSearch,
placeholder = "Search techniques…",
heroSize = false,
initialQuery = "",
autoFocus = false,
}: SearchAutocompleteProps) {
const [query, setQuery] = useState(initialQuery);
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [popularSuggestions, setPopularSuggestions] = useState<SuggestionItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const suggestionsLoadedRef = useRef(false);
// Sync initialQuery when URL changes (e.g. SearchResults page)
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
// Fetch popular suggestions once on mount
useEffect(() => {
if (suggestionsLoadedRef.current) return;
suggestionsLoadedRef.current = true;
void (async () => {
try {
const res = await fetchSuggestions();
setPopularSuggestions(res.suggestions);
} catch {
// Non-critical — autocomplete still works without popular suggestions
}
})();
}, []);
// Auto-focus
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Debounced typeahead search
const handleInputChange = useCallback((value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.length < 2) {
setSearchResults([]);
// Show popular suggestions if input is empty and we have them
if (value.length === 0 && popularSuggestions.length > 0) {
setShowDropdown(true);
} else {
setShowDropdown(false);
}
return;
}
debounceRef.current = setTimeout(() => {
void (async () => {
try {
const res = await searchApi(value, undefined, 5);
setSearchResults(res.items);
setShowDropdown(res.items.length > 0);
} catch {
setSearchResults([]);
setShowDropdown(false);
}
})();
}, 300);
}, [popularSuggestions.length]);
function handleFocus() {
if (query.length === 0 && popularSuggestions.length > 0) {
setShowDropdown(true);
} else if (query.length >= 2 && searchResults.length > 0) {
setShowDropdown(true);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (query.trim()) {
setShowDropdown(false);
onSearch(query.trim());
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
setShowDropdown(false);
}
}
function handleSuggestionClick(text: string) {
setShowDropdown(false);
setQuery(text);
onSearch(text);
}
const showPopular = query.length < 2 && popularSuggestions.length > 0;
const showSearch = query.length >= 2 && searchResults.length > 0;
const typeLabel: Record<string, string> = {
technique: "Technique",
topic: "Topic",
creator: "Creator",
technique_page: "Technique",
key_moment: "Key Moment",
};
return (
<div className="search-container" ref={dropdownRef}>
<form
onSubmit={handleSubmit}
className={`search-form ${heroSize ? "search-form--hero" : "search-form--inline"}`}
>
<input
ref={inputRef}
type="search"
className={`search-input ${heroSize ? "search-input--hero" : "search-input--inline"}`}
placeholder={placeholder}
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
aria-label="Search techniques"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
{showDropdown && (showPopular || showSearch) && (
<div className="typeahead-dropdown">
{showPopular && (
<>
<div className="typeahead-suggestions-header">Popular</div>
{popularSuggestions.map((item) => (
<button
key={`${item.type}-${item.text}`}
className="typeahead-suggestion"
onClick={() => handleSuggestionClick(item.text)}
type="button"
>
<span className="typeahead-suggestion__text">{item.text}</span>
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{typeLabel[item.type] ?? item.type}
</span>
</button>
))}
</>
)}
{showSearch && (
<>
{searchResults.map((item) => (
<Link
key={`${item.type}-${item.slug}`}
to={`/techniques/${item.slug}`}
className="typeahead-item"
onClick={() => setShowDropdown(false)}
>
<span className="typeahead-item__title">{item.title}</span>
<span className="typeahead-item__meta">
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{typeLabel[item.type] ?? item.type}
</span>
{item.creator_name && (
<span className="typeahead-item__creator">
{item.creator_name}
</span>
)}
</span>
</Link>
))}
<Link
to={`/search?q=${encodeURIComponent(query)}`}
className="typeahead-see-all"
onClick={() => setShowDropdown(false)}
>
See all results for &ldquo;{query}&rdquo;
</Link>
</>
)}
</div>
)}
</div>
);
}

View file

@ -6,33 +6,21 @@
*/
import { IconTopics, IconCreators } from "../components/CategoryIcons";
import { useCallback, useEffect, useRef, useState } from "react";
import SearchAutocomplete from "../components/SearchAutocomplete";
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
searchApi,
fetchTechniques,
fetchTopics,
type SearchResultItem,
type TechniqueListItem,
} from "../api/public-client";
export default function Home() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchResultItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [featured, setFeatured] = useState<TechniqueListItem | null>(null);
const [recent, setRecent] = useState<TechniqueListItem[]>([]);
const [recentLoading, setRecentLoading] = useState(true);
const [popularTopics, setPopularTopics] = useState<{name: string; count: number}[]>([]);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Auto-focus search on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Load featured technique (random)
useEffect(() => {
@ -88,63 +76,6 @@ export default function Home() {
};
}, []);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setShowDropdown(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Debounced typeahead
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.length < 2) {
setSuggestions([]);
setShowDropdown(false);
return;
}
debounceRef.current = setTimeout(() => {
void (async () => {
try {
const res = await searchApi(value, undefined, 5);
setSuggestions(res.items);
setShowDropdown(res.items.length > 0);
} catch {
setSuggestions([]);
setShowDropdown(false);
}
})();
}, 300);
},
[],
);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (query.trim()) {
setShowDropdown(false);
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
setShowDropdown(false);
}
}
return (
<div className="home">
{/* Hero search */}
@ -154,58 +85,11 @@ export default function Home() {
Search techniques, key moments, and creators
</p>
<div className="search-container" ref={dropdownRef}>
<form onSubmit={handleSubmit} className="search-form search-form--hero">
<input
ref={inputRef}
type="search"
className="search-input search-input--hero"
placeholder="Search techniques…"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => {
if (suggestions.length > 0) setShowDropdown(true);
}}
onKeyDown={handleKeyDown}
aria-label="Search techniques"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
{showDropdown && suggestions.length > 0 && (
<div className="typeahead-dropdown">
{suggestions.map((item) => (
<Link
key={`${item.type}-${item.slug}`}
to={`/techniques/${item.slug}`}
className="typeahead-item"
onClick={() => setShowDropdown(false)}
>
<span className="typeahead-item__title">{item.title}</span>
<span className="typeahead-item__meta">
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{item.type === "technique_page" ? "Technique" : "Key Moment"}
</span>
{item.creator_name && (
<span className="typeahead-item__creator">
{item.creator_name}
</span>
)}
</span>
</Link>
))}
<Link
to={`/search?q=${encodeURIComponent(query)}`}
className="typeahead-see-all"
onClick={() => setShowDropdown(false)}
>
See all results for "{query}"
</Link>
</div>
)}
</div>
<SearchAutocomplete
heroSize
autoFocus
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>
<p className="home-hero__value-prop">
Real music production techniques extracted from creator tutorials.

View file

@ -6,10 +6,11 @@
* keyword search was used.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Link, useSearchParams, useNavigate } from "react-router-dom";
import { searchApi, type SearchResultItem } from "../api/public-client";
import { catSlug } from "../utils/catSlug";
import SearchAutocomplete from "../components/SearchAutocomplete";
export default function SearchResults() {
const [searchParams] = useSearchParams();
@ -19,8 +20,6 @@ export default function SearchResults() {
const [results, setResults] = useState<SearchResultItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localQuery, setLocalQuery] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const doSearch = useCallback(async (query: string) => {
if (!query.trim()) {
@ -43,33 +42,9 @@ export default function SearchResults() {
// Search when URL param changes
useEffect(() => {
setLocalQuery(q);
if (q) void doSearch(q);
}, [q, doSearch]);
function handleInputChange(value: string) {
setLocalQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (value.trim()) {
navigate(`/search?q=${encodeURIComponent(value.trim())}`, {
replace: true,
});
}
}, 400);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (debounceRef.current) clearTimeout(debounceRef.current);
if (localQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(localQuery.trim())}`, {
replace: true,
});
}
}
// Group results by type
const techniqueResults = results.filter((r) => r.type === "technique_page");
const momentResults = results.filter((r) => r.type === "key_moment");
@ -77,19 +52,12 @@ export default function SearchResults() {
return (
<div className="search-results-page">
{/* Inline search bar */}
<form onSubmit={handleSubmit} className="search-form search-form--inline">
<input
type="search"
className="search-input search-input--inline"
placeholder="Search techniques…"
value={localQuery}
onChange={(e) => handleInputChange(e.target.value)}
aria-label="Refine search"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
<SearchAutocomplete
initialQuery={q}
onSearch={(newQ) =>
navigate(`/search?q=${encodeURIComponent(newQ)}`, { replace: true })
}
/>
{/* Status */}
{loading && <div className="loading">Searching</div>}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}