Skip to content

Commit 6fc36c4

Browse files
msoginclaude
andauthored
fix(projects): anchor status dropdown to pill (GLOOK-1) (#39)
* fix(projects): anchor status dropdown to pill with fixed positioning (GLOOK-1) When a project's summary panel was expanded, the "Change project status" dropdown appeared below the expanded summary instead of below the status pill. The dropdown used `position: absolute; top: 100%` relative to the `<td>`, which grows in height when the row's adjacent cell expands the summary. Switch to `position: fixed` with coordinates captured from the trigger's `getBoundingClientRect()` at click time, so the dropdown is always anchored to the pill regardless of table row height. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(projects): address PR #39 review — viewport coords, scroll/resize reposition Review feedback: - Critical: getBoundingClientRect() already returns viewport-relative coords and position: fixed is viewport-anchored, so adding window.scrollY/scrollX double-counted the scroll offset and reintroduced the original detach on any scrolled page. - Warning: dropdown was not repositioned/closed on scroll or resize while open, so scrolling detached it from the pill. - Warning: Escape handler did not clear statusDropdownPos, leaving stale position state. Changes: - Drop window.scrollY/scrollX from initial position math. - Keep a ref to the trigger element while the dropdown is open and recompute position on scroll (capture phase) + resize so the dropdown follows the pill rather than detaching. - Close paths (Escape, overlay click, same-status click, transition completion) now also clear the trigger ref. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: msogin <msogin@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d9fb23a commit 6fc36c4

1 file changed

Lines changed: 83 additions & 57 deletions

File tree

src/app/projects/projects-content.tsx

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useEffect, useMemo } from 'react';
3+
import { useState, useEffect, useMemo, useRef } from 'react';
44
import useSWR, { preload } from 'swr';
55
import { useAuth } from '../auth-context';
66
import { findFirstJiraKey } from '@/lib/jira-key-utils';
@@ -120,12 +120,19 @@ export default function ProjectsContent() {
120120

121121
// Status editing
122122
const [editingStatus, setEditingStatus] = useState<string | null>(null);
123+
const [statusDropdownPos, setStatusDropdownPos] = useState<{ top: number; left: number } | null>(null);
124+
const statusTriggerRef = useRef<HTMLElement | null>(null);
123125
const [transitionsCache, setTransitionsCache] = useState<Record<string, Array<{ id: string; name: string; to: { name: string } }>>>({});
124126
const [transitionsLoading, setTransitionsLoading] = useState(false);
125127
const [savingStatus, setSavingStatus] = useState<string | null>(null);
126128

127-
const openStatusEditor = async (epicKey: string) => {
128-
if (editingStatus === epicKey) { setEditingStatus(null); return; }
129+
const openStatusEditor = async (epicKey: string, triggerEl?: HTMLElement) => {
130+
if (editingStatus === epicKey) { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; return; }
131+
if (triggerEl) {
132+
statusTriggerRef.current = triggerEl;
133+
const rect = triggerEl.getBoundingClientRect();
134+
setStatusDropdownPos({ top: rect.bottom + 2, left: rect.left });
135+
}
129136
setEditingStatus(epicKey);
130137
if (transitionsCache[epicKey]) return; // already cached
131138
setTransitionsLoading(true);
@@ -168,16 +175,30 @@ export default function ProjectsContent() {
168175
} finally {
169176
setSavingStatus(null);
170177
setEditingStatus(null);
178+
setStatusDropdownPos(null);
179+
statusTriggerRef.current = null;
171180
// Invalidate transitions cache for this epic (status changed, transitions differ)
172181
setTransitionsCache(prev => { const n = { ...prev }; delete n[epicKey]; return n; });
173182
}
174183
};
175184

176185
useEffect(() => {
177186
if (!editingStatus) return;
178-
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setEditingStatus(null); };
179-
window.addEventListener('keydown', handler);
180-
return () => window.removeEventListener('keydown', handler);
187+
const close = () => { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; };
188+
const reposition = () => {
189+
if (!statusTriggerRef.current) return;
190+
const rect = statusTriggerRef.current.getBoundingClientRect();
191+
setStatusDropdownPos({ top: rect.bottom + 2, left: rect.left });
192+
};
193+
const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
194+
window.addEventListener('keydown', keyHandler);
195+
window.addEventListener('scroll', reposition, { capture: true, passive: true });
196+
window.addEventListener('resize', reposition);
197+
return () => {
198+
window.removeEventListener('keydown', keyHandler);
199+
window.removeEventListener('scroll', reposition, { capture: true });
200+
window.removeEventListener('resize', reposition);
201+
};
181202
}, [editingStatus]);
182203

183204
useEffect(() => {
@@ -911,59 +932,64 @@ export default function ProjectsContent() {
911932
</>
912933
)}
913934
</td>
914-
<td className="px-4 py-3 text-gray-300 relative">
935+
<td className="px-4 py-3 text-gray-300">
915936
<div>{epic.assignee || '—'}</div>
916-
{canAct ? (
917-
<div
918-
onClick={(e) => { e.stopPropagation(); openStatusEditor(epic.key); }}
919-
className={`flex items-center gap-1 mt-0.5 cursor-pointer text-[10px] transition-colors ${
920-
editingStatus === epic.key ? 'text-accent-lighter' : 'text-gray-500 hover:text-gray-300'
921-
}`}
922-
>
923-
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
924-
background: savingStatus === epic.key ? '#6B7280' :
925-
epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' :
926-
epic.status === 'In Progress' ? '#D97706' : '#6B7280'
927-
}} />
928-
{savingStatus === epic.key ? 'Saving...' : epic.status}
929-
<svg className="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /></svg>
930-
</div>
931-
) : (
932-
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-gray-600">
933-
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
934-
background: epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' :
935-
epic.status === 'In Progress' ? '#D97706' : '#6B7280'
936-
}} />
937-
{epic.status}
938-
</div>
939-
)}
940-
{editingStatus === epic.key && (
941-
<>
942-
<div className="fixed inset-0 z-20" onClick={() => setEditingStatus(null)} />
943-
<div className="absolute top-full left-2 mt-0 z-30 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[140px]">
944-
{transitionsLoading && !transitionsCache[epic.key] ? (
945-
<div className="px-3 py-2 text-xs text-gray-500 animate-pulse">Loading...</div>
946-
) : (transitionsCache[epic.key] || []).length === 0 ? (
947-
<div className="px-3 py-2 text-xs text-gray-600">No transitions</div>
948-
) : (
949-
(transitionsCache[epic.key] || []).map(t => (
950-
<button
951-
key={t.id}
952-
onClick={(e) => { e.stopPropagation(); if (t.to.name === epic.status) { setEditingStatus(null); } else { executeTransition(epic.key, t.id, t.to.name); } }}
953-
className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${
954-
t.to.name === epic.status ? 'text-accent-lighter font-medium' : 'text-gray-300 hover:bg-gray-700'
955-
}`}
956-
>
957-
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
958-
background: t.to.name === 'Done' ? '#10B981' : t.to.name === 'Rollout' ? '#3B82F6' : t.to.name === 'In Progress' ? '#D97706' : '#6B7280'
959-
}} />
960-
{t.to.name}
961-
</button>
962-
))
963-
)}
937+
<div className="mt-0.5">
938+
{canAct ? (
939+
<div
940+
onClick={(e) => { e.stopPropagation(); openStatusEditor(epic.key, e.currentTarget as HTMLElement); }}
941+
className={`flex items-center gap-1 cursor-pointer text-[10px] transition-colors ${
942+
editingStatus === epic.key ? 'text-accent-lighter' : 'text-gray-500 hover:text-gray-300'
943+
}`}
944+
>
945+
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
946+
background: savingStatus === epic.key ? '#6B7280' :
947+
epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' :
948+
epic.status === 'In Progress' ? '#D97706' : '#6B7280'
949+
}} />
950+
{savingStatus === epic.key ? 'Saving...' : epic.status}
951+
<svg className="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /></svg>
964952
</div>
965-
</>
966-
)}
953+
) : (
954+
<div className="flex items-center gap-1 text-[10px] text-gray-600">
955+
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
956+
background: epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' :
957+
epic.status === 'In Progress' ? '#D97706' : '#6B7280'
958+
}} />
959+
{epic.status}
960+
</div>
961+
)}
962+
{editingStatus === epic.key && statusDropdownPos && (
963+
<>
964+
<div className="fixed inset-0 z-20" onClick={() => { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; }} />
965+
<div
966+
className="fixed z-30 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[140px]"
967+
style={{ top: statusDropdownPos.top, left: statusDropdownPos.left }}
968+
>
969+
{transitionsLoading && !transitionsCache[epic.key] ? (
970+
<div className="px-3 py-2 text-xs text-gray-500 animate-pulse">Loading...</div>
971+
) : (transitionsCache[epic.key] || []).length === 0 ? (
972+
<div className="px-3 py-2 text-xs text-gray-600">No transitions</div>
973+
) : (
974+
(transitionsCache[epic.key] || []).map(t => (
975+
<button
976+
key={t.id}
977+
onClick={(e) => { e.stopPropagation(); if (t.to.name === epic.status) { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; } else { executeTransition(epic.key, t.id, t.to.name); } }}
978+
className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${
979+
t.to.name === epic.status ? 'text-accent-lighter font-medium' : 'text-gray-300 hover:bg-gray-700'
980+
}`}
981+
>
982+
<span className="w-[6px] h-[6px] rounded-full shrink-0" style={{
983+
background: t.to.name === 'Done' ? '#10B981' : t.to.name === 'Rollout' ? '#3B82F6' : t.to.name === 'In Progress' ? '#D97706' : '#6B7280'
984+
}} />
985+
{t.to.name}
986+
</button>
987+
))
988+
)}
989+
</div>
990+
</>
991+
)}
992+
</div>
967993
</td>
968994
<td className="px-4 py-3">
969995
{epic.team ? (

0 commit comments

Comments
 (0)