diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 00000000..89cec960 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2023-10-13 - 세션 타임라인 리본 툴팁 키보드 접근성 개선 +**Learning:** 마우스 호버(`onMouseEnter`, `onMouseMove`)에만 의존하여 툴팁을 띄우는 커스텀 UI 패턴에서는 키보드 사용자(Tab 키 이동)가 정보를 전혀 확인할 수 없는 문제가 발생할 수 있습니다. +**Action:** 인터랙티브 요소(버튼 등)에 호버 액션을 추가할 때는 항상 `onFocus`와 `onBlur`를 병행하여 키보드 포커스 시에도 동일한 컨텍스트(툴팁 등)가 제공되도록 설계해야 합니다. diff --git a/packages/web/src/components/dashboard/session-activity-ribbon.tsx b/packages/web/src/components/dashboard/session-activity-ribbon.tsx index 12228af3..bc2c5b94 100644 --- a/packages/web/src/components/dashboard/session-activity-ribbon.tsx +++ b/packages/web/src/components/dashboard/session-activity-ribbon.tsx @@ -158,13 +158,21 @@ export function SessionActivityRibbon({ const [hover, setHover] = useState(null) const containerRef = useRef(null) - const trackMouse = useCallback((e: React.MouseEvent) => { + const trackMouse = useCallback((e: React.MouseEvent | React.FocusEvent) => { const rect = containerRef.current?.getBoundingClientRect() - if (!rect) return e.clientX - return e.clientX - rect.left + if (!rect) { + if ('clientX' in e) return e.clientX + return 0 + } + if ('clientX' in e) { + return e.clientX - rect.left + } + // For focus events, calculate the center of the focused element relative to the container + const targetRect = (e.target as HTMLElement).getBoundingClientRect() + return targetRect.left - rect.left + targetRect.width / 2 }, []) - const handleEventHover = useCallback((idx: number) => (e: React.MouseEvent) => { + const handleEventHover = useCallback((idx: number) => (e: React.MouseEvent | React.FocusEvent) => { setHover({ kind: 'event', idx, x: trackMouse(e) }) }, [trackMouse]) @@ -173,7 +181,7 @@ export function SessionActivityRibbon({ toolName: string, count: number, firstEvent: TimelineEvent, - ) => (e: React.MouseEvent) => { + ) => (e: React.MouseEvent | React.FocusEvent) => { setHover({ kind: 'merged', firstIdx, @@ -200,6 +208,8 @@ export function SessionActivityRibbon({ onMouseEnter={handleEventHover(idx)} onMouseMove={handleEventHover(idx)} onMouseLeave={() => setHover(null)} + onFocus={handleEventHover(idx)} + onBlur={() => setHover(null)} aria-label={`Event ${idx + 1}`} aria-current={selected ? 'true' : undefined} className={`h-full ${bg} transition-opacity ${ @@ -225,6 +235,8 @@ export function SessionActivityRibbon({ onMouseEnter={handleEventHover(idx)} onMouseMove={handleEventHover(idx)} onMouseLeave={() => setHover(null)} + onFocus={handleEventHover(idx)} + onBlur={() => setHover(null)} aria-label={`Event ${idx + 1}`} aria-current={selected ? 'true' : undefined} className={`h-full ${bg} transition-opacity ${ @@ -288,6 +300,13 @@ export function SessionActivityRibbon({ group.items[0].event, )} onMouseLeave={() => setHover(null)} + onFocus={handleMergedHover( + firstIdx, + group.toolName, + group.items.length, + group.items[0].event, + )} + onBlur={() => setHover(null)} aria-label={`Expand ${group.toolName} group, ${group.items.length} events`} className={`relative h-full ${segmentVisuals(group.items[0].event).bg} transition-opacity hover:opacity-70`} >