docs(plan): tag selector search implementation
Six-step inline plan per the approved spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal file
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Tag Selector Search Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a typing-to-filter input above the tag chip list in the filter sidebar of `CatalogSearchOverlay` so users with many tags can narrow the visible list.
|
||||
|
||||
**Architecture:** Single-file UI change. Local `tagSearch` state, computed `filteredTags` derived via case-insensitive substring match, conditionally rendered input (only when `tags.length > 8`), muted "No tags match" hint when the filter empties the list, reset on overlay close. No backend, no new files, no new dependencies.
|
||||
|
||||
**Tech Stack:** React 19 + TanStack Query (via existing `useTags`) + Tailwind CSS v4.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-23-tag-selector-search-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modify only:**
|
||||
- `src/client/components/CatalogSearchOverlay.tsx` — add state, reset hook extension, and JSX changes inside the Tags section of the filter sidebar.
|
||||
|
||||
No new files.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Implement tag search in the filter sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/components/CatalogSearchOverlay.tsx`
|
||||
|
||||
### Step 1: Add `tagSearch` local state
|
||||
|
||||
- [ ] Add the following state declaration immediately after the existing `const [filterOpen, setFilterOpen] = useState(false);` (currently line 25). This keeps filter-panel state grouped together.
|
||||
|
||||
```tsx
|
||||
const [tagSearch, setTagSearch] = useState("");
|
||||
```
|
||||
|
||||
### Step 2: Extend the close-reset effect to reset `tagSearch`
|
||||
|
||||
- [ ] In the existing reset `useEffect` (currently lines 84–98), add `setTagSearch("");` to the list of resets so the filter clears on overlay close/reopen. The updated effect looks like:
|
||||
|
||||
```tsx
|
||||
// Reset state when overlay closes
|
||||
useEffect(() => {
|
||||
if (!catalogSearchOpen) {
|
||||
setSearchInput("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedTags([]);
|
||||
setFilterOpen(false);
|
||||
setTagSearch("");
|
||||
setWeightMin(0);
|
||||
setWeightMax(5000);
|
||||
setPriceMin(0);
|
||||
setPriceMax(100000);
|
||||
setManualEntryMode(false);
|
||||
setSavedItemName(null);
|
||||
setCatalogSubmitted(false);
|
||||
}
|
||||
}, [catalogSearchOpen]);
|
||||
```
|
||||
|
||||
### Step 3: Replace the Tags section JSX with search-input + filtered list + empty hint
|
||||
|
||||
- [ ] In the filter sidebar, the Tags section currently spans lines 333–356. Replace the existing block with the following. This (a) conditionally renders a small filter input only when `tags.length > 8`, (b) computes a case-insensitive substring filter inline, (c) renders either the filtered chip list or a muted "No tags match" hint.
|
||||
|
||||
Current block to replace (exact):
|
||||
|
||||
```tsx
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Tags
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{tags.map((tag) => {
|
||||
const isActive = selectedTags.includes(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replacement block:
|
||||
|
||||
```tsx
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Tags
|
||||
</h3>
|
||||
{tags.length > 8 && (
|
||||
<input
|
||||
type="text"
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
placeholder="Filter tags..."
|
||||
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
const q = tagSearch.trim().toLowerCase();
|
||||
const filteredTags = q
|
||||
? tags.filter((t) => t.name.toLowerCase().includes(q))
|
||||
: tags;
|
||||
if (filteredTags.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 px-2.5 py-1.5">
|
||||
No tags match
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return filteredTags.map((tag) => {
|
||||
const isActive = selectedTags.includes(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The `(() => { ... })()` IIFE keeps the filter logic local to this block without adding another variable at component top-level. It runs on every render, which is fine — cost is O(n) per keystroke and `tags` is small.
|
||||
- `q` is trimmed-and-lowercased once so we don't recompute it per tag.
|
||||
- When `q` is empty, we use `tags` directly to avoid the filter allocation.
|
||||
- The filter input is only rendered when `tags.length > 8`; below that threshold, the old behavior (no input, no filtering) is preserved.
|
||||
|
||||
### Step 4: Run the linter
|
||||
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
```
|
||||
|
||||
Expected: exit 0 with no errors. Biome should be happy (tabs, double quotes, organized imports — existing conventions).
|
||||
|
||||
If there are complaints about the IIFE or `q` variable, resolve them before continuing. If Biome flags the `no-nested-ternary` or complexity rules, hoist the filter logic into a `useMemo` just above the return statement instead — functionally equivalent, syntactically flatter.
|
||||
|
||||
### Step 5: Start the dev server and verify in a browser
|
||||
|
||||
- [ ] Run (in a separate terminal or background):
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
- [ ] Open `http://localhost:5173`, sign in if required, and open the catalog search overlay (FAB or top-nav search). Toggle the Filter icon to open the sidebar.
|
||||
|
||||
- [ ] Walk through acceptance criteria from the spec:
|
||||
|
||||
1. With >8 tags, the "Filter tags..." input appears above the chip list. With ≤8 tags, the input is hidden.
|
||||
2. Typing narrows the list immediately (case-insensitive substring).
|
||||
3. Selecting a tag, then typing a query that excludes it: the chip disappears from the sidebar but the blue pill in the header row remains.
|
||||
4. Typing a query that matches nothing shows "No tags match".
|
||||
5. Closing the overlay (Esc / back arrow / clicking outside header) and reopening: the tag search input is empty.
|
||||
6. Weight/price range filters, active filter pills, and Clear-all still work as before.
|
||||
|
||||
- [ ] If any criterion fails, fix inline and re-verify. Do not commit broken work.
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
- [ ] Stage and commit:
|
||||
|
||||
```bash
|
||||
git add src/client/components/CatalogSearchOverlay.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(catalog): searchable tag filter in global catalog overlay
|
||||
|
||||
Adds a typing-to-filter input above the tag chip list in the filter
|
||||
sidebar, rendered only when there are more than eight tags. Case-
|
||||
insensitive substring match; shows "No tags match" when the query
|
||||
empties the list. Selected tags filtered out of the sidebar remain
|
||||
active as header pills. Resets with the rest of overlay state.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec requirement | Task step |
|
||||
|---|---|
|
||||
| Add `tagSearch` local state | Task 1 Step 1 |
|
||||
| Reset `tagSearch` on overlay close | Task 1 Step 2 |
|
||||
| Render input only when `tags.length > 8` | Task 1 Step 3 (conditional) |
|
||||
| Case-insensitive substring match | Task 1 Step 3 (`q`/`includes`) |
|
||||
| Selected tag filtered out stays active via header pill | Existing behavior preserved (no code removed from header pill row) — verified in Step 5 criterion 3 |
|
||||
| "No tags match" hint on zero results | Task 1 Step 3 (early return) |
|
||||
| Placeholder `Filter tags...` | Task 1 Step 3 |
|
||||
| Style matches minimalist look, narrower for sidebar | Task 1 Step 3 (smaller padding, ring-1, text-xs) |
|
||||
| Closing and reopening shows empty input | Task 1 Step 2 + Step 5 criterion 5 |
|
||||
| No regressions to weight/price/pills | Step 5 criterion 6 |
|
||||
|
||||
All spec requirements mapped.
|
||||
|
||||
**Placeholders:** none. No "TBD" or "handle edge cases" language; all code is shown in full.
|
||||
|
||||
**Type consistency:** `tagSearch` is a `string`, always used as `tagSearch.trim().toLowerCase()`. `filteredTags` is `Tag[]` same shape as `tags`. Tag shape from `useTags` is `{ id: number; name: string }` — both fields used correctly.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Scope is one file + six small steps. Subagent-driven would be over-engineered. Recommended: inline execution with `superpowers:executing-plans`.
|
||||
Reference in New Issue
Block a user