feat: Added hover-to-open with 150ms leave delay and matchMedia desktop…
- "frontend/src/components/AdminDropdown.tsx" GSD-Task: S05/T01
This commit is contained in:
parent
8b912e5a6f
commit
4735463649
9 changed files with 441 additions and 4 deletions
|
|
@ -9,5 +9,5 @@ Add social proof and freshness signals to the public site: search query logging
|
||||||
| S01 | Search Query Logging + Popular Searches API | medium | — | ✅ | GET /api/v1/search/popular returns top 10 queries from last 7 days, cached in Redis. search_log table has rows from real searches. |
|
| S01 | Search Query Logging + Popular Searches API | medium | — | ✅ | GET /api/v1/search/popular returns top 10 queries from last 7 days, cached in Redis. search_log table has rows from real searches. |
|
||||||
| S02 | Creator Freshness + Homepage Card Dates | low | — | ✅ | Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. |
|
| S02 | Creator Freshness + Homepage Card Dates | low | — | ✅ | Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. |
|
||||||
| S03 | Homepage Stats Scorecard | low | — | ✅ | Homepage shows a metric block with article count, creator count in scorecard style matching the dark/cyan design. |
|
| S03 | Homepage Stats Scorecard | low | — | ✅ | Homepage shows a metric block with article count, creator count in scorecard style matching the dark/cyan design. |
|
||||||
| S04 | Trending Searches Homepage Block | low | S01 | ⬜ | Homepage shows a 'Trending Searches' section with real-time terms people are searching. |
|
| S04 | Trending Searches Homepage Block | low | S01 | ✅ | Homepage shows a 'Trending Searches' section with real-time terms people are searching. |
|
||||||
| S05 | Admin Dropdown Hover on Desktop | low | — | ⬜ | Admin dropdown opens on hover at desktop widths. On mobile, opens on tap. |
|
| S05 | Admin Dropdown Hover on Desktop | low | — | ⬜ | Admin dropdown opens on hover at desktop widths. On mobile, opens on tap. |
|
||||||
|
|
|
||||||
88
.gsd/milestones/M015/slices/S04/S04-SUMMARY.md
Normal file
88
.gsd/milestones/M015/slices/S04/S04-SUMMARY.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
id: S04
|
||||||
|
parent: M015
|
||||||
|
milestone: M015
|
||||||
|
provides:
|
||||||
|
- Trending Searches homepage section consuming GET /api/v1/search/popular
|
||||||
|
requires:
|
||||||
|
- slice: S01
|
||||||
|
provides: GET /api/v1/search/popular endpoint
|
||||||
|
affects:
|
||||||
|
[]
|
||||||
|
key_files:
|
||||||
|
- frontend/src/api/public-client.ts
|
||||||
|
- frontend/src/pages/Home.tsx
|
||||||
|
- frontend/src/App.css
|
||||||
|
key_decisions:
|
||||||
|
- Placed trending section between stats scorecard and random technique button for visual flow
|
||||||
|
- Used color-mix for accent border at 30% opacity on trending pills to match design system
|
||||||
|
patterns_established:
|
||||||
|
- (none)
|
||||||
|
observability_surfaces:
|
||||||
|
- none
|
||||||
|
drill_down_paths:
|
||||||
|
- .gsd/milestones/M015/slices/S04/tasks/T01-SUMMARY.md
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T04:38:38.405Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# S04: Trending Searches Homepage Block
|
||||||
|
|
||||||
|
**Homepage now shows a Trending Searches section with clickable pill terms sourced from GET /api/v1/search/popular, hidden when no data.**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Added the Trending Searches homepage block by wiring the existing popular searches API (from S01) into the frontend. Three files changed: `public-client.ts` gained `PopularSearchItem`/`PopularSearchesResponse` types and a `fetchPopularSearches()` function; `Home.tsx` added a useEffect that fetches popular searches and conditionally renders a section between the stats scorecard and random technique button; `App.css` added `.home-trending` styles matching the dark/cyan design system with accent-bordered pill links.
|
||||||
|
|
||||||
|
Each trending term is a `<Link>` pill navigating to `/search?q={term}`. The section uses `card-stagger` animation for entrance consistency. The conditional `trending && trending.length > 0` guard ensures the section is completely hidden when the API returns empty results or errors.
|
||||||
|
|
||||||
|
Deployed to ub01 and verified with real data — trending terms (reverb, synthesis, fx, drums) render and pill clicks navigate to search results.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All plan-specified checks passed:
|
||||||
|
1. `npx tsc --noEmit` — zero errors
|
||||||
|
2. `npm run build` — succeeds (940ms)
|
||||||
|
3. `fetchPopularSearches` exists in public-client.ts
|
||||||
|
4. `home-trending` class wired in Home.tsx
|
||||||
|
5. `home-trending` styles in App.css
|
||||||
|
6. `PopularSearchItem` and `PopularSearchesResponse` types present
|
||||||
|
7. Conditional render guard: `trending && trending.length > 0`
|
||||||
|
8. Link targets use `encodeURIComponent(item.query)`
|
||||||
|
9. Browser verification on ub01:8096 confirmed trending section visible with real terms and pill navigation works
|
||||||
|
|
||||||
|
## Requirements Advanced
|
||||||
|
|
||||||
|
- R035 — Frontend now displays popular search terms on the homepage, completing the display half of the requirement (backend logging+caching was S01)
|
||||||
|
|
||||||
|
## Requirements Validated
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## New Requirements Surfaced
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Requirements Invalidated or Re-scoped
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Docker Compose service named chrysopedia-web not chrysopedia-web-8096 as in CLAUDE.md — pre-existing naming difference, not a slice issue.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/api/public-client.ts` — Added PopularSearchItem/PopularSearchesResponse types and fetchPopularSearches() function
|
||||||
|
- `frontend/src/pages/Home.tsx` — Added trending searches state, useEffect fetch, and conditional Trending Searches section with pill links
|
||||||
|
- `frontend/src/App.css` — Added .home-trending section styles and .pill--trending variant
|
||||||
51
.gsd/milestones/M015/slices/S04/S04-UAT.md
Normal file
51
.gsd/milestones/M015/slices/S04/S04-UAT.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# S04: Trending Searches Homepage Block — UAT
|
||||||
|
|
||||||
|
**Milestone:** M015
|
||||||
|
**Written:** 2026-04-03T04:38:38.405Z
|
||||||
|
|
||||||
|
# S04 UAT: Trending Searches Homepage Block
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
- Chrysopedia web UI running at http://ub01:8096
|
||||||
|
- At least a few search queries have been made so the popular searches API returns data
|
||||||
|
- Browser with DevTools available
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
### TC1: Trending section renders with real data
|
||||||
|
1. Navigate to http://ub01:8096
|
||||||
|
2. Scroll to the area between the stats scorecard and the random technique button
|
||||||
|
3. **Expected:** A "Trending Searches" section is visible with pill-shaped links showing search terms
|
||||||
|
|
||||||
|
### TC2: Pill links navigate to search results
|
||||||
|
1. On the homepage, click any trending search pill (e.g., "reverb")
|
||||||
|
2. **Expected:** Browser navigates to `/search?q=reverb` and search results for that term are displayed
|
||||||
|
3. Click browser back button
|
||||||
|
4. **Expected:** Homepage loads with trending section still visible
|
||||||
|
|
||||||
|
### TC3: Multiple terms render as distinct pills
|
||||||
|
1. On the homepage, inspect the trending section
|
||||||
|
2. **Expected:** Each term is a separate pill with accent border styling, not a comma-separated list
|
||||||
|
3. **Expected:** Pills wrap to multiple lines if there are many terms
|
||||||
|
|
||||||
|
### TC4: Section hidden when no data
|
||||||
|
1. Open DevTools Network tab
|
||||||
|
2. Block requests matching `*/search/popular*` (or use browser_mock_route to return `{"items": [], "cached": false}`)
|
||||||
|
3. Hard refresh the homepage
|
||||||
|
4. **Expected:** No "Trending Searches" section appears. No error shown to user. Rest of homepage renders normally.
|
||||||
|
|
||||||
|
### TC5: Section hidden on API error
|
||||||
|
1. Open DevTools Network tab
|
||||||
|
2. Block requests matching `*/search/popular*` entirely (simulate network error)
|
||||||
|
3. Hard refresh the homepage
|
||||||
|
4. **Expected:** No "Trending Searches" section appears. No console errors break the page. Stats scorecard and random button still render.
|
||||||
|
|
||||||
|
### TC6: Visual consistency
|
||||||
|
1. Navigate to http://ub01:8096
|
||||||
|
2. Inspect the trending section visually
|
||||||
|
3. **Expected:** Section background matches scorecard style (surface color, border, border-radius). Pills have subtle cyan accent border. Heading uses muted text color with letter-spacing. Stagger animation plays on page load.
|
||||||
|
|
||||||
|
### TC7: Pill links use proper encoding
|
||||||
|
1. If a trending term contains special characters or spaces, click it
|
||||||
|
2. **Expected:** URL shows properly encoded query parameter (e.g., `/search?q=sound%20design`)
|
||||||
|
|
||||||
48
.gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json
Normal file
48
.gsd/milestones/M015/slices/S04/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M015/S04/T01",
|
||||||
|
"timestamp": 1775191056948,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd frontend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 11,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npm run build",
|
||||||
|
"exitCode": 254,
|
||||||
|
"durationMs": 115,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npx tsc --noEmit",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 831,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'fetchPopularSearches' src/api/public-client.ts",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 10,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'home-trending' src/pages/Home.tsx",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 11,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'home-trending' src/App.css",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 12,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
# S05: Admin Dropdown Hover on Desktop
|
# S05: Admin Dropdown Hover on Desktop
|
||||||
|
|
||||||
**Goal:** Change AdminDropdown to hover-open on desktop and click-open on mobile.
|
**Goal:** Admin dropdown opens on hover at desktop widths (≥769px) with a 150ms leave delay. On mobile, hover is inert — tap still toggles.
|
||||||
**Demo:** After this: Admin dropdown opens on hover at desktop widths. On mobile, opens on tap.
|
**Demo:** After this: Admin dropdown opens on hover at desktop widths. On mobile, opens on tap.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
- [x] **T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown** — Add onMouseEnter/onMouseLeave handlers to the AdminDropdown container div. Guard them with a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. Use a 150ms setTimeout on mouseleave (cleared on re-enter) to bridge the 0.5rem CSS gap between trigger and menu. Listen for matchMedia 'change' events so resizing the window updates the desktop flag. Keep click-to-toggle, Escape close, and outside-click close intact.
|
||||||
|
|
||||||
|
The component is at `frontend/src/components/AdminDropdown.tsx` (65 lines). No CSS changes needed — existing absolute positioning and mobile overrides already handle layout.
|
||||||
|
|
||||||
|
This satisfies R036 (Admin Dropdown Hover on Desktop).
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read `frontend/src/components/AdminDropdown.tsx` to confirm current state.
|
||||||
|
2. Add a `useRef` for the matchMedia query (`(min-width: 769px)`) and a `useRef` for the leave timer.
|
||||||
|
3. Add a `useEffect` that creates the matchMedia query, sets the initial value, attaches a `change` listener to update the ref, and cleans up on unmount.
|
||||||
|
4. Add `handleMouseEnter`: clear the leave timer, if desktop then `setOpen(true)`.
|
||||||
|
5. Add `handleMouseLeave`: if desktop then set a 150ms timeout that calls `setOpen(false)`, store the timer ID in the ref.
|
||||||
|
6. Add a cleanup `useEffect` that clears the leave timer on unmount.
|
||||||
|
7. Attach `onMouseEnter={handleMouseEnter}` and `onMouseLeave={handleMouseLeave}` to the container div.
|
||||||
|
8. Run `cd frontend && npm run build` to verify no TypeScript errors.
|
||||||
|
- Estimate: 20m
|
||||||
|
- Files: frontend/src/components/AdminDropdown.tsx
|
||||||
|
- Verify: cd frontend && npm run build 2>&1 | tail -5
|
||||||
|
|
|
||||||
79
.gsd/milestones/M015/slices/S05/S05-RESEARCH.md
Normal file
79
.gsd/milestones/M015/slices/S05/S05-RESEARCH.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# S05 Research — Admin Dropdown Hover on Desktop
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Straightforward interaction change. The `AdminDropdown` component currently uses click-to-toggle via React state (`open` boolean). Menu is conditionally rendered (`{open && <div>}`), so CSS-only `:hover` is not viable — JS hover handlers are needed. Mobile styles already enforce 44px touch targets at `max-width: 768px`.
|
||||||
|
|
||||||
|
**Requirement:** R036 — Admin Dropdown Hover on Desktop
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Add `onMouseEnter`/`onMouseLeave` handlers to the `.admin-dropdown` container div, guarded by a `matchMedia('(min-width: 769px)')` check so hover only activates on desktop. Keep click-to-toggle for mobile/touch.
|
||||||
|
|
||||||
|
Add a ~150ms leave delay (cleared on re-enter) to bridge the `0.5rem` gap between trigger and menu — standard dropdown hover UX pattern.
|
||||||
|
|
||||||
|
## Implementation Landscape
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
| File | What changes |
|
||||||
|
|------|-------------|
|
||||||
|
| `frontend/src/components/AdminDropdown.tsx` | Add `onMouseEnter`/`onMouseLeave` with matchMedia guard, leave-delay ref |
|
||||||
|
| `frontend/src/App.css` | No CSS changes needed — existing styles handle positioning and visibility |
|
||||||
|
|
||||||
|
### Current State of `AdminDropdown.tsx` (73 lines)
|
||||||
|
|
||||||
|
- **State:** `open` boolean via `useState(false)`
|
||||||
|
- **Close behaviors:** Outside click (mousedown listener), Escape key listener
|
||||||
|
- **Toggle:** `onClick={() => setOpen(prev => !prev)}` on trigger button
|
||||||
|
- **Menu rendering:** Conditional `{open && <div className="admin-dropdown__menu">}`
|
||||||
|
- **Accessibility:** `aria-expanded`, `aria-haspopup="true"`, `role="menu"`, `role="menuitem"`
|
||||||
|
- **Refs:** `dropdownRef` for outside-click detection
|
||||||
|
|
||||||
|
### Existing CSS
|
||||||
|
|
||||||
|
- Desktop: `.admin-dropdown` is `position: relative`, menu is `position: absolute` with `top: calc(100% + 0.5rem)` (creates a gap)
|
||||||
|
- Mobile (`max-width: 768px`): Menu becomes `position: static`, full-width, no shadow. Trigger has `min-height: 44px` for touch targets.
|
||||||
|
|
||||||
|
### Implementation Detail
|
||||||
|
|
||||||
|
```
|
||||||
|
// Inside component:
|
||||||
|
const isDesktop = useRef(window.matchMedia('(min-width: 769px)'));
|
||||||
|
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!isDesktop.current.matches) return;
|
||||||
|
clearTimeout(leaveTimer.current);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!isDesktop.current.matches) return;
|
||||||
|
leaveTimer.current = setTimeout(() => setOpen(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// On the container div:
|
||||||
|
<div className="admin-dropdown" ref={dropdownRef}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isDesktop` ref should listen for changes (`matchMedia.addEventListener('change', ...)`) to handle window resizing, though this is minor for a single-admin tool.
|
||||||
|
|
||||||
|
### Gap bridging
|
||||||
|
|
||||||
|
The `0.5rem` gap between trigger and menu can cause accidental close during hover. The 150ms delay handles this. Alternative: add an invisible pseudo-element bridge in CSS (`.admin-dropdown__menu::before` with negative top margin). The timer approach is simpler and covers diagonal mouse movement too.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Build frontend: `cd frontend && npm run build` — no TypeScript errors
|
||||||
|
2. Deploy: rebuild Docker image, verify at `http://ub01:8096`
|
||||||
|
3. Desktop: hover over "Admin ▾" → menu opens; move mouse to menu items → stays open; move mouse away → closes after ~150ms
|
||||||
|
4. Mobile (DevTools 768px or narrower): hover does nothing, tap toggles menu
|
||||||
|
5. Escape key still closes menu
|
||||||
|
6. Outside click still closes menu
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
None. Single-file change, well-understood pattern, no backend involvement.
|
||||||
35
.gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md
Normal file
35
.gsd/milestones/M015/slices/S05/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 12
|
||||||
|
estimated_files: 1
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Add hover-to-open with matchMedia guard and leave delay to AdminDropdown
|
||||||
|
|
||||||
|
Add onMouseEnter/onMouseLeave handlers to the AdminDropdown container div. Guard them with a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. Use a 150ms setTimeout on mouseleave (cleared on re-enter) to bridge the 0.5rem CSS gap between trigger and menu. Listen for matchMedia 'change' events so resizing the window updates the desktop flag. Keep click-to-toggle, Escape close, and outside-click close intact.
|
||||||
|
|
||||||
|
The component is at `frontend/src/components/AdminDropdown.tsx` (65 lines). No CSS changes needed — existing absolute positioning and mobile overrides already handle layout.
|
||||||
|
|
||||||
|
This satisfies R036 (Admin Dropdown Hover on Desktop).
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read `frontend/src/components/AdminDropdown.tsx` to confirm current state.
|
||||||
|
2. Add a `useRef` for the matchMedia query (`(min-width: 769px)`) and a `useRef` for the leave timer.
|
||||||
|
3. Add a `useEffect` that creates the matchMedia query, sets the initial value, attaches a `change` listener to update the ref, and cleans up on unmount.
|
||||||
|
4. Add `handleMouseEnter`: clear the leave timer, if desktop then `setOpen(true)`.
|
||||||
|
5. Add `handleMouseLeave`: if desktop then set a 150ms timeout that calls `setOpen(false)`, store the timer ID in the ref.
|
||||||
|
6. Add a cleanup `useEffect` that clears the leave timer on unmount.
|
||||||
|
7. Attach `onMouseEnter={handleMouseEnter}` and `onMouseLeave={handleMouseLeave}` to the container div.
|
||||||
|
8. Run `cd frontend && npm run build` to verify no TypeScript errors.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- ``frontend/src/components/AdminDropdown.tsx` — existing component to modify`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- ``frontend/src/components/AdminDropdown.tsx` — updated with hover handlers, matchMedia guard, and leave delay`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd frontend && npm run build 2>&1 | tail -5
|
||||||
74
.gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md
Normal file
74
.gsd/milestones/M015/slices/S05/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S05
|
||||||
|
milestone: M015
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/components/AdminDropdown.tsx"]
|
||||||
|
key_decisions: ["Used useRef for matchMedia and leave timer to avoid re-renders on breakpoint changes"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "cd frontend && npm run build — TypeScript compilation and Vite build passed with zero errors."
|
||||||
|
completed_at: 2026-04-03T04:41:01.800Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown
|
||||||
|
|
||||||
|
> Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S05
|
||||||
|
milestone: M015
|
||||||
|
key_files:
|
||||||
|
- frontend/src/components/AdminDropdown.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Used useRef for matchMedia and leave timer to avoid re-renders on breakpoint changes
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T04:41:01.800Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown
|
||||||
|
|
||||||
|
**Added hover-to-open with 150ms leave delay and matchMedia desktop guard (≥769px) to AdminDropdown**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Added onMouseEnter/onMouseLeave handlers to the AdminDropdown container div, guarded by a matchMedia('(min-width: 769px)') ref so hover only fires on desktop. On mouseleave, a 150ms setTimeout bridges the CSS gap between trigger and menu. The matchMedia listener updates the desktop flag dynamically on resize. Click-to-toggle, Escape close, and outside-click close remain intact.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd frontend && npm run build — TypeScript compilation and Vite build passed with zero errors.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3200ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/components/AdminDropdown.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -1,9 +1,48 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const DESKTOP_MQ = "(min-width: 769px)";
|
||||||
|
const LEAVE_DELAY_MS = 150;
|
||||||
|
|
||||||
export default function AdminDropdown() {
|
export default function AdminDropdown() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isDesktopRef = useRef(false);
|
||||||
|
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Track desktop breakpoint via matchMedia
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(DESKTOP_MQ);
|
||||||
|
isDesktopRef.current = mql.matches;
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
isDesktopRef.current = e.matches;
|
||||||
|
};
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear leave timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (leaveTimerRef.current) {
|
||||||
|
clearTimeout(leaveTimerRef.current);
|
||||||
|
leaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (isDesktopRef.current) setOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (!isDesktopRef.current) return;
|
||||||
|
leaveTimerRef.current = setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
leaveTimerRef.current = null;
|
||||||
|
}, LEAVE_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -31,7 +70,12 @@ export default function AdminDropdown() {
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-dropdown" ref={dropdownRef}>
|
<div
|
||||||
|
className="admin-dropdown"
|
||||||
|
ref={dropdownRef}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className="admin-dropdown__trigger"
|
className="admin-dropdown__trigger"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue