| name | list-search-box |
| description | Add a search box to an existing React list page — client-side filter on the visible page (no server roundtrip). Covers the two-layer empty state (raw empty vs filter empty), header flex layout (button + search input), `useMemo` over `[data, query]`, and the 3-step "remove standalone page" checklist when a search box replaces a dedicated list page. Trigger when user says "加 search box" / "搜尋" / "filter list" / "拎走 menu + 加 search" / "list 加 search" / "list page 加 search" on a React/TypeScript frontend, or when a user says "menu 不用有" implying a standalone page should be retired in favor of sub-page search. |
List Search Box (React)
The "係咪 list 有就加 search" pattern — 任何 React list page 加 search box 嘅
標準做法。Client-side filter 起步, server-side 升級 path 留定。
觸發時機
- 用戶講「list 加 search box」「搜尋 list」「呢個 page 加個 search」
- 用戶拎走 standalone list page 但保留 sub-page list → 後者要加 search
- 任何 React list page (
map() 渲染 row) 缺 search affordance
- 用戶講「我搵唔到 XXX」但 list 已經有對應 row → 加 search
- 唔適合:server-side filter 已經有 (例如 dropdown filter), 加 search 變 redundant
7 個 class-level steps
Step 1: 確認 scope — client-side 定 server-side
Default: client-side filter(用戶冇 specify 就 client-side)。Reason:
- List 一頁 10-50 row, 唔需要 server roundtrip
- Type 即時 filter UX 較好(無 debounce 都需要)
- Backend 唔使改, 風險低
Server-side 嘅 case(先 client-side, 之後再升):
- List > 100 row 同 user 想搜「過去一年嘅」
- Search 要 match description / assignee name(超過 title field)
- Multi-field search (title + tags + description)
推 David 揀 client-side 嘅話術:「而家 list 細,client-side filter 即時 type 即時 filter,唔撞 backend,最簡單。將來 list 大咗我哋改 server-side,加 Prisma contains 同 index,大概 2 個鐘 work。」
Step 2: 揀 field — 只 match title, 唔 match description
const filteredX = useMemo(() => {
const q = searchX.trim().toLowerCase()
if (!q) return x
return x.filter(item => item.title.toLowerCase().includes(q))
}, [x, searchX])
Why title only:Search result 同 list row display 一致(用戶睇 row 嘅 title,搜尋就係嗰個字)。
Why not description:Description 係 long text,「張三」可能 hit 中 description 入面一個字,出嚟嘅結果冇 context 對應。
Why not assignee / tags:User 預期「我打咗 keyword 應該見到 title 嗰 row」, 唔 expect 跨 field 搜尋。
後續升級:User 投訴「搜『張三』要搵到佢 assignee 嘅 task」, 加 || item.assignee?.name.toLowerCase().includes(q)。Hold 住唔 over-engineer。
Step 3: useMemo over [data, query], 唔好 inline filter
{x.filter(item => item.title.toLowerCase().includes(searchX)).map(...)}
const filteredX = useMemo(() => {
const q = searchX.trim().toLowerCase()
if (!q) return x
return x.filter(item => item.title.toLowerCase().includes(q))
}, [x, searchX])
{filteredX.map(...)}
Why useMemo:Even 細 list 都有 best practice 嘅 value — toggle modal / hover 都 trigger re-render, inline filter 跑多次浪費。useMemo 確保只 source 改先 re-compute。
Step 4: Header flex layout — button 同 search input 並排
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
{hasAnyPermission(user, ['x.create']) && (
<button onClick={openCreate} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
<Plus size={20} /><span>新建 X</span>
</button>
)}
<div className="relative w-full sm:w-72">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
type="text"
value={searchX}
onChange={(e) => setSearchX(e.target.value)}
placeholder="搜尋 X..."
aria-label="搜尋 X"
className="w-full pl-9 pr-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
規則:
flex-col sm:flex-row: Mobile 直排(button 上, search 下), desktop 並排(button 左, search 右)
sm:w-72 (18rem ≈ 288px) 喺 desktop 唔好 full-width, 留返位置比將來加其他 filter
Search icon 絕對定位喺 input 左邊,pointer-events-none 避免 click 穿過
aria-label="搜尋 X": 唔靠 placeholder 嘅唯一 a11y 信號(placeholder 唔係 accessible name)
- Permission-gated button: 用
hasAnyPermission(user, ['x.create']) 包住,user 冇 create 權限就 button 唔 render,search input 移去右邊
- RWD verify: 開 Chrome DevTools mobile view 睇, search input 唔可以同 button 碰撞
Step 4b: 兩個 component variant (PM-System 2026-06-09 撞過)
唔係所有 list page 嘅 header 都係 single row。兩個常見變體:
Variant A: 兩欄 layout — search 喺 list sidebar header (WikiTab pattern)
WikiTab 嘅 layout 係 flex gap-6(左 list sidebar + 右 content pane)。Search 應該喺 list header 下面, 唔喺 right content header — 因為搜尋結果影響 list 唔影響 content,layout 直覺:
{}
<div className="w-72 flex flex-col">
{}
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">頁面列表</h3>
<button onClick={openCreate} className="p-1.5 text-primary-600 hover:bg-primary-50 rounded-lg">
<Plus size={18} />
</button>
</div>
{}
<div className="relative mb-3">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
type="text"
value={searchX}
onChange={(e) => setSearchX(e.target.value)}
placeholder="搜尋頁面..."
aria-label="搜尋 Wiki 頁面"
className="w-full pl-8 pr-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
{}
</div>
Why 唔同行 justify-between: list header 嘅 title + create button 已經 justify-between, 加 search 入去會變 3 個 item,squeeze 唔落。Search 自己一行, 視覺 hierarchy 清楚。
Variant B: Upload bar 旁 — search 喺右側, RWD 兩行 (AttachmentsTab pattern)
AttachmentsTab 嘅 top section 係 upload button + helper text。Search 應該喺 upload bar 旁邊, desktop 並排, mobile 兩行:
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
{canUpload && (
<div className="flex items-center gap-4">
<label htmlFor="upload" className="btn-primary flex items-center gap-2 cursor-pointer">
<Upload size={18} />{uploading ? '上傳中...' : '上傳附件'}
</label>
<span className="text-sm text-gray-500">支援圖片、文檔、壓縮包等格式</span>
</div>
)}
<div className="relative w-full sm:w-72 sm:ml-auto">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
type="text"
value={searchX}
onChange={(e) => setSearchX(e.target.value)}
placeholder="搜尋附件..."
aria-label="搜尋附件"
className="w-full pl-8 pr-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
Key differences from default (single column) page:
sm:ml-auto 推 search 喺 desktop 右邊, 即使 canUpload 係 false 都 keep right-aligned
py-1.5 唔係 py-2: 附件 list 高度密啲, search 縮細一格 match
Search size={14} 唔係 {16}: 同 attachment card 嘅 icon size (16) 對齊, visual harmony
搜尋 filename 唔係 mimeType: 用戶揾 file 通常記得「嗰份 spec PDF」(filename), mimeType 過濾無意思(e.g.「揀 image」係 filter button 唔係 search)。
Step 5: 2 層 empty state — raw empty vs filter empty
死都唔可以合埋做一個 empty state。理由:
- 「真係冇 data」vs「搜尋無結果」係兩個完全唔同嘅 UX
- 用戶打錯 keyword 同 個 list 真係空, 提示應該分開
{x.length === 0 ? (
<div className="card p-12 text-center">
<FileText size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">暫無 X</h3>
<p className="text-gray-500">為 XX 添加第一個 X</p>
</div>
) : filteredX.length === 0 ? (
<div className="card p-12 text-center">
<Search size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">無符合「{searchX}」嘅 X</h3>
<p className="text-gray-500">試下其他關鍵字,或清空搜尋框</p>
</div>
) : (
<div className="space-y-3">
{filteredX.map((item) => (...))}
</div>
)}
規則:
- 順序重要:
x.length === 0 先 check(原始空),然後 filteredX.length === 0(filter 後空)
- 唔好用同一個 empty state 包兩個 case,UX 訊息會誤導
searchX echo 入 title: 用戶見到自己打咗咩 keyword, debug 容易
Step 6: 拎走 standalone page 嘅 3-step checklist (配套 push back 用)
情境:用戶講「menu 不用有」,但 sub-page 仲要 list X。3 步走:
Layout.tsx 拎走 nav item (1 line delete)
App.tsx 拎走 route (<Route path="x" element={<XPage/>}> + import XPage from './pages/XPage')
- 保留 detail page 嘅 route (
<Route path="x/:id" element={<XDetailPage/>}>) — 從 sub-list / 我的 X 跳過嚟仲要 work
BugsPage.tsx 整個 file delete (死 code 唔留)
跟住嘅 cross-reference audit(容易 miss):
- Back-link grep:
grep -n "/x\b" pages/XDetailPage.tsx 拎出所有 back-link, 改去合理 destination
- Default: 改去「我的 X」(
/my-x) — 從「我的」click row 入 detail, back 返「我的」最自然
- User-contextual: 用
location.state.from 或者 query string ?from=project 攞 entry path, render 動態 back link
- E2E test grep:
grep -n "/x\b\|XPage" e2e/tests/*.spec.ts 拎出 reference
- 拎走 route 會 break test, 用
test.skip + deprecation comment 標住
- 唔好默默 delete test — 留低審計 trail, 下次 sprint 補 QA-TRACKER
DEPRECATED 標記
- TypeScript import cleanup:
import XPage from './pages/XPage' 拎走
我喺呢個 session 嘅 2 輪 push back (2026-06-09 pm-system):
- 輪 1: David 揀 (1) 軟刪除 + (2) search box + (3) 先做 delete+list search。我 push back 問「(1) bugs delete 影響 4 個 frontend entry + 5 個 backend endpoint, 範圍好大, 只改 BugsPage?」+「(2) 搜尋係 client-side 定 server-side?」
- 輪 2: David 答「(1)A + (2)C 加埋 project 內 sub-list」, 但跟住修訂(獨立 cue):「menu 不用有」 → 拎走成個 standalone page
Lesson:
- David 嘅 wording 要 parse 多次:「menu 不用有」聽落簡單, 但其實 scope 包括 standalone page delete + back-link update
- 每收到一條 feedback 即 push back 一次, 唔好悶頭做
- 每個 push back 帶 2-3 個 options + 自己推薦, David 答得快(0-1 個 turn)
- 3 個 step 嘅 scope checklist 用 5-15 分鐘 grep + audit 確認, 比悶頭做大幅省時間
Step 7: Server-side 升級 path(預備, 唔做)
如果將來 list > 100 row 用戶投訴 lag, 改用 server-side:
const bugs = await prisma.bug.findMany({
where: {
title: { contains: query, mode: 'insensitive' },
}
})
const [searchBug, setSearchBug] = useState('')
useEffect(() => {
const timer = setTimeout(() => loadBugs({ search: searchBug }), 300)
return () => clearTimeout(timer)
}, [searchBug])
PG vs SQLite 嘅差異:
mode: 'insensitive' 只 PG 支援
- SQLite 用
LIKE '%query%' (case-insensitive by default for ASCII)
- 詳細 pitfall 見
debugging/prisma-sqlite-bun-setup
Hold 住唔做嘅 trigger:
- 用戶投訴「搜尋好慢」/「結果唔啱」/「想搜 description」
- List > 200 row 同 user 想搜「過去 30 日嘅」
- Multi-field search 成為常見 action
Pitfalls (David 撞過, 後人必讀)
🚨 1. Inline filter 唔 useMemo(performance 唔必要咁差)
{x.filter(item => item.title.toLowerCase().includes(searchX)).map(...)}
const filteredX = useMemo(() => {...}, [x, searchX])
{filteredX.map(...)}
Symptom: 細 list (10 row) 唔覺眼, 大 list (50+ row) 用戶撳 modal 之後有 micro-stutter。
Fix: 一律 useMemo, 一致 pattern, 唔好按 list size 揀。
🚨 2. Empty state 唔分兩層(UX 致命)
{filteredX.length === 0 ? (
<div>暫無 X</div>
) : ...}
{x.length === 0 ? (
<div>暫無 X</div>
) : filteredX.length === 0 ? (
<div>無符合「{searchX}」嘅 X</div>
) : ...}
Symptom: 用戶打錯 keyword 之後, 個 empty state 寫「暫無 X」, 用戶誤以為個 list 真係空, 唔係自己打錯字。
Fix: 一律 2 層, 一致 pattern, 唔好省 empty state。
🚨 3. 拎走 standalone page 漏 back-link(BugDetailPage back-link 仲指 /bugs)
<Link to="/bugs">返回缺陷列表</Link>
<Link to="/my-bugs">返回缺陷列表</Link>
Symptom: 用戶喺「我的缺陷」click row 入 detail, 「返回」掣 click 落到 blank dashboard (因為 /bugs 唔再存在, react-router fallback 返 /)。
Fix: 拎走 route 之後 5 分鐘 grep audit:
grep -rn "/x\b" frontend/src/pages/XDetailPage.tsx
🚨 4. E2E test 拎走 feature 之後唔 skip(red line 12 違規)
await page.goto(`${FRONTEND}/bugs`)
Symptom: CI run 之後 test fail, 但冇人理因為 feature 已 retire。
Fix:
- 用
test.skip + deprecation comment 標住
- 同時更新
docs/QA-TRACKER.md(紅線 11) + docs/PRD.md 標 US DEPRECATED
- 唔好默默 delete test — 留 audit trail, 等 David 確認 feature 真係 retire
🚨 5. Search box 同 create button 撞 layout (RWD mobile)
<div className="flex items-center justify-between gap-3">
<button>新建 X</button>
<input className="w-72" />
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<button>新建 X</button>
<div className="relative w-full sm:w-72">
<input className="w-full ..." />
</div>
</div>
Symptom: Mobile 開個 page, search input 同「新建 X」掣 side-by-side 撞, 兩個都睇唔晒。
Fix: 一律 flex-col sm:flex-row, 開 frontend/rwd-mobile-audit skill verify。
🚨 6. Search 範圍只 title, 用戶投訴要 match 跨 field
情境: 用戶打「張三」想搵 assignee.name = "張三" 嘅 task, 但 search 只 match title, 結果空白。
Root cause: 過早定 scope「只 title」(Step 2)。
Fix:
- 預期用戶會投訴, 唔係 bug, 係 scope 決定
- 投訴出現時, 加
|| item.assignee?.name.toLowerCase().includes(q) 一行 code
- 唔好 over-engineer 開頭做 multi-field search, hold 住 user feedback
🚨 7. aria-label 唔靠 placeholder
<input placeholder="搜尋 X..." />
<input placeholder="搜尋 X..." aria-label="搜尋 X" />
Symptom: Screen reader 讀唔到「搜尋 X」, 因為 placeholder 唔係 ARIA accessible name 嘅標準來源。
Fix: 一律 aria-label="搜尋 X", 唔好慪。
Code Templates
完整 pattern code 喺:
templates/list-search-box-with-empty-states.tsx — search box + 2 層 empty state + useMemo filter 嘅 full code
templates/remove-standalone-page-checklist.md — 拎走 standalone page 嘅 3-step + cross-reference audit checklist
配套 references
references/pm-system-2026-06-09-bugs-menu-remove-and-search-add.md — 真實 session 入面點解 David 拎走「全部缺陷」+ 點加 search box 喺 5 個 list
references/pm-system-2026-06-09-sprint-11-wiki-attachments-and-deprecate.md — Sprint 11 跟進 (commit 8c99f32):Wiki/Attachments 兩個 component variant + describe.skip 6 個 E2E deprecation pattern + QA-TRACKER/PRD DEPRECATED row format
references/pm-system-2026-06-10-sprint-14-projects-search-rwd-autocomplete-dashboard.md — Sprint 14 4 個 David UX feedback closure: /projects search box + RWD header + WorkLogs/Reports project dropdown 改 <ProjectAutocomplete> reusable + Dashboard 重新設計 (Activity Feed + Recent Projects Quick Switch)
references/pm-system-2026-06-10-sprint-14-sprint-15-projects-search-and-dashboard-scope.md — Sprint 14+15 增量: 4 個新 pitfall (ProjectAutocomplete reusable spec / limit: -1 dropdown-only use / getByText strict mode / backend query param 同步 frontend api.ts) + Sprint 15 ?scope=my 通用 pattern (嚴格只 filter 自己 member, 包括 admin)
Audit Checklist (做完必跑)
| # | 檢查項 | 點 verify |
|---|
| 1 | useMemo over [data, query], 唔 inline filter | grep "useMemo" page.tsx |
| 2 | Search box 同 create button 用 flex-col sm:flex-row | Visual inspect + RWD mobile |
| 3 | 2 層 empty state 分開 (raw empty vs filter empty) | grep "x.length === 0" page.tsx 應該有兩個 branch |
| 4 | Search input 有 aria-label | grep "aria-label" page.tsx |
| 5 | Search input 有 Search icon 喺左 | Visual inspect |
| 6 | Filter 只 match title field | grep "item.title.toLowerCase" page.tsx |
| 7 | 拎走 standalone page 之後 grep back-link 全部 updated | grep "/x\b" pages/XDetailPage.tsx |
| 8 | 拎走 standalone page 之後 E2E test test.skip + deprecation comment | grep "test.skip|deprecated" e2e/tests/*.spec.ts |
| 9 | TypeScript 0 error | bunx tsc --noEmit |
| 10 | RWD mobile audit 過 | frontend/rwd-mobile-audit skill verify |
| 11 | Component variant: 兩欄 layout (list sidebar + content) search 喺 sidebar header 下面 own row, 唔同行 justify-between (variant A) | Visual inspect |
| 12 | Component variant: Upload/action bar 旁 search 用 sm:ml-auto keep right-aligned 即使 canUpload 係 false (variant B) | Visual inspect |
配套 skills
frontend/related-entity-entry-points — 任何 sub-list 嘅 parent-child UX
frontend/rwd-mobile-audit — search box 嘅 mobile layout verify
frontend/react-router-v7-params-debug — 拎走 route 嘅 back-link handling
frontend/react-router-sibling-route-conflict — 拎走 route 嘅 sibling route collision check
debugging/prisma-sqlite-bun-setup — server-side search upgrade 嘅 Prisma contains 寫法
software-development/qa-tracker-us-closure — 拎走 standalone page 嘅 US DEPRECATION 同步(US row → ❌(DEPRECATED) ⚫ + test.skip/describe.skip + PRD strikethrough)
過去 session 教訓
- 2026-06-09 pm-system: David feedback「(1) 拎走 '全部缺陷' menu (2) 項目內頁需求/任務/缺陷 + 需求內頁任務/缺陷 加 search box」。Frontend-only, 6 files 改, 271 lines 拎走, 173 加返。Commit
048650e。Retro doc docs/retros/2026-06-09-remove-bugs-menu-add-list-search.md。Lesson:David 嘅 wording 要 parse 多次 — 拎走 menu 唔淨止拎走 nav item, scope 包括 standalone page delete + back-link update + E2E test 跟進。
- 2026-06-09 pm-system (同 session push back): 我做 2 輪 push back, 每次帶 2-3 個 options + 自己推薦。David 0-1 個 turn 答。Lesson: 每收到一條 feedback 即 push back 一次 + 帶選項, David 答得快, 悶頭做嘅後果係做錯 scope 後再改。
- 2026-06-09 pm-system (C 延伸 — Wiki + Attachments 變體): David follow-up feedback 「(1) skip 失效 E2E test (2) 加埋 Wiki/Attachments 嘅 search」。Frontend-only, 6 files 改 (+200 / −38)。Commit
8c99f32。Retro doc docs/retros/2026-06-09-sprint-11-wiki-attachments-search-deprecate-bugs-page.md。Lesson:
- 唔係所有 list 都係 single column。
WikiTab (兩欄 layout: left list sidebar + right content pane) 同 AttachmentsTab (upload bar + grid) 嘅 search box layout 同 default 唔同 — 詳見 Step 4b Variant A / B
- 過咗 default pattern, audit checklist 要 extend (item #11, #12 加咗)
describe.skip 整個 describe 跳過 > 逐個 test.skip 6 個: 6 個 test skip 唔應該逐個 mark, group 一個 describe skip, Playwright 報告清楚 + audit trail 完整
- File header changelog 比 inline comment 更顯眼: 將「2026-06-09 變更」放 file 頂部嘅 block comment, reader 一開 file 就知歷史 context
- E2E test entry 已廢 entry grep: 拎走 route/feature 一定要
grep -n 拎出所有 reference 然後逐個 fix, 唔好假設「其他 file 冇 reference」