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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
|
|
|||
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
|
||||
|
||||
**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.
|
||||
|
||||
## 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";
|
||||
|
||||
const DESKTOP_MQ = "(min-width: 769px)";
|
||||
const LEAVE_DELAY_MS = 150;
|
||||
|
||||
export default function AdminDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -31,7 +70,12 @@ export default function AdminDropdown() {
|
|||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="admin-dropdown" ref={dropdownRef}>
|
||||
<div
|
||||
className="admin-dropdown"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className="admin-dropdown__trigger"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue