11'use client'
22
33import {
4+ type KeyboardEvent as ReactKeyboardEvent ,
45 type ReactNode ,
56 type PointerEvent as ReactPointerEvent ,
67 useCallback ,
78 useEffect ,
9+ useId ,
810 useRef ,
911 useState ,
1012} from 'react'
@@ -25,6 +27,8 @@ interface CodeHoverCardProps {
2527/** Interactive, two-axis-scrollable source preview anchored to a canvas code chip. */
2628export function CodeHoverCard ( { preview, className, children } : CodeHoverCardProps ) {
2729 const [ open , setOpen ] = useState ( false )
30+ const contentId = useId ( )
31+ const triggerRef = useRef < HTMLSpanElement > ( null )
2832 const openTimerRef = useRef < number | null > ( null )
2933 const closeTimerRef = useRef < number | null > ( null )
3034
@@ -59,6 +63,13 @@ export function CodeHoverCard({ preview, className, children }: CodeHoverCardPro
5963 } , delay )
6064 }
6165
66+ const openImmediatelyIfClipped = ( trigger : HTMLSpanElement ) => {
67+ if ( ! isTextClipped ( trigger ) ) return
68+ clearOpenTimer ( )
69+ clearCloseTimer ( )
70+ setOpen ( true )
71+ }
72+
6273 const handleOpenChange = useCallback (
6374 ( nextOpen : boolean ) => {
6475 if ( ! nextOpen ) {
@@ -78,19 +89,73 @@ export function CodeHoverCard({ preview, className, children }: CodeHoverCardPro
7889 [ clearCloseTimer , clearOpenTimer ]
7990 )
8091
92+ const handleTriggerPointerDown = ( event : ReactPointerEvent < HTMLSpanElement > ) => {
93+ event . preventDefault ( )
94+ if ( event . pointerType === 'touch' || event . pointerType === 'pen' ) {
95+ event . stopPropagation ( )
96+ if ( open ) {
97+ handleOpenChange ( false )
98+ } else {
99+ openImmediatelyIfClipped ( event . currentTarget )
100+ }
101+ return
102+ }
103+ handleOpenChange ( false )
104+ }
105+
106+ const handleTriggerKeyDown = ( event : ReactKeyboardEvent < HTMLSpanElement > ) => {
107+ if ( event . key === 'Escape' ) {
108+ event . stopPropagation ( )
109+ handleOpenChange ( false )
110+ return
111+ }
112+ if ( event . key !== 'Enter' && event . key !== ' ' ) return
113+
114+ event . preventDefault ( )
115+ event . stopPropagation ( )
116+ if ( open ) {
117+ handleOpenChange ( false )
118+ } else {
119+ openImmediatelyIfClipped ( event . currentTarget )
120+ }
121+ }
122+
123+ const handleContentKeyDown = ( event : ReactKeyboardEvent < HTMLDivElement > ) => {
124+ if ( event . key !== 'Escape' ) return
125+ event . stopPropagation ( )
126+ handleOpenChange ( false )
127+ triggerRef . current ?. focus ( )
128+ }
129+
81130 return (
82131 < Popover open = { open } onOpenChange = { handleOpenChange } >
83132 < PopoverAnchor asChild >
84133 < span
134+ ref = { triggerRef }
135+ role = 'button'
136+ tabIndex = { 0 }
137+ aria-haspopup = 'dialog'
138+ aria-expanded = { open }
139+ aria-controls = { open ? contentId : undefined }
85140 className = { className }
86141 onPointerEnter = { handleTriggerPointerEnter }
87- onPointerLeave = { ( ) => scheduleClose ( TRIGGER_EXIT_GRACE_MS ) }
88- onPointerDown = { ( ) => handleOpenChange ( false ) }
142+ onPointerLeave = { ( ) => {
143+ if ( document . activeElement !== triggerRef . current ) {
144+ scheduleClose ( TRIGGER_EXIT_GRACE_MS )
145+ }
146+ } }
147+ onPointerDown = { handleTriggerPointerDown }
148+ onFocus = { ( event ) => openImmediatelyIfClipped ( event . currentTarget ) }
149+ onBlur = { ( ) => scheduleClose ( TRIGGER_EXIT_GRACE_MS ) }
150+ onKeyDown = { handleTriggerKeyDown }
89151 >
90152 { children }
91153 </ span >
92154 </ PopoverAnchor >
93155 < PopoverContent
156+ id = { contentId }
157+ role = 'dialog'
158+ tabIndex = { 0 }
94159 aria-label = 'Code preview'
95160 data-code-hover-card = ''
96161 align = 'start'
@@ -101,6 +166,9 @@ export function CodeHoverCard({ preview, className, children }: CodeHoverCardPro
101166 maxHeight = { CODE_TOOLTIP_MAX_HEIGHT_PX }
102167 onPointerEnter = { clearCloseTimer }
103168 onPointerLeave = { ( ) => scheduleClose ( CONTENT_EXIT_GRACE_MS ) }
169+ onFocus = { clearCloseTimer }
170+ onBlur = { ( ) => scheduleClose ( CONTENT_EXIT_GRACE_MS ) }
171+ onKeyDown = { handleContentKeyDown }
104172 className = 'nodrag nowheel overflow-hidden overscroll-contain p-0'
105173 >
106174 < Code . Viewer
0 commit comments