Skip to content

Commit 9c9bca1

Browse files
committed
fix(access-control): dismiss floating permission help with Escape
1 parent db76a84 commit 9c9bca1

2 files changed

Lines changed: 39 additions & 7 deletions

File tree

‎packages/emcn/src/components/info/info.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ interface InfoProps {
2424
interactive?: boolean
2525
/** Optional class names applied to the badge. */
2626
className?: string
27-
/** Tooltip side. Defaults to `'top'`. */
27+
/** Preferred side for pinned help. Hover help follows the cursor. Defaults to `'top'`. */
2828
side?: InfoSide
29-
/** Tooltip alignment. Defaults to `'start'`. */
29+
/** Alignment for pinned help. Hover help follows the cursor. Defaults to `'start'`. */
3030
align?: InfoAlign
3131
}
3232

@@ -69,7 +69,7 @@ export function Info({
6969
</button>
7070
</Tooltip.Trigger>
7171
<Tooltip.Content side={side} align={align} className='max-w-xs'>
72-
{children}
72+
<p>{children}</p>
7373
</Tooltip.Content>
7474
</Tooltip.Root>
7575
)
@@ -78,12 +78,19 @@ export function Info({
7878
function InteractiveInfo({ children, className, side, align, label }: InfoProps) {
7979
const contentId = useId()
8080
const [open, setOpen] = useState(false)
81-
const { state, handlers } = useFloatingTooltip(() => !open, {
81+
const { state, handlers, dismiss } = useFloatingTooltip(() => !open, {
8282
preferAbove: side === 'top',
8383
revalidateKey: open,
84+
dismissOnEscape: true,
8485
})
8586
return (
86-
<Popover open={open} onOpenChange={setOpen}>
87+
<Popover
88+
open={open}
89+
onOpenChange={(nextOpen) => {
90+
if (!nextOpen) dismiss()
91+
setOpen(nextOpen)
92+
}}
93+
>
8794
<PopoverTrigger asChild>
8895
<button
8996
type='button'

‎packages/emcn/src/components/tooltip/tooltip.tsx‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export interface UseFloatingTooltipOptions {
9898
getFocusTarget?: () => HTMLElement | null
9999
/** Semantic value whose changes should revalidate a visible tooltip. */
100100
revalidateKey?: unknown
101+
/** Dismiss visible help with Escape until the next hover or focus gesture. Defaults to false. */
102+
dismissOnEscape?: boolean
101103
}
102104

103105
export function useFloatingTooltip(
@@ -106,6 +108,8 @@ export function useFloatingTooltip(
106108
): {
107109
state: FloatingTooltipState
108110
handlers: FloatingTooltipHandlers
111+
/** Hides help until a fresh hover or focus gesture, including while the pointer stays on its trigger. */
112+
dismiss: () => void
109113
} {
110114
const canShowRef = React.useRef(canShow)
111115
canShowRef.current = canShow
@@ -115,6 +119,7 @@ export function useFloatingTooltip(
115119
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
116120
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
117121
const triggerRef = React.useRef<HTMLElement | null>(null)
122+
const dismissedRef = React.useRef(false)
118123
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
119124

120125
const reset = React.useCallback(() => {
@@ -129,6 +134,11 @@ export function useFloatingTooltip(
129134
setState((current) => (current.visible ? HIDDEN_STATE : current))
130135
}, [reset])
131136

137+
const dismiss = React.useCallback(() => {
138+
dismissedRef.current = true
139+
hide()
140+
}, [hide])
141+
132142
const apply = React.useCallback((clientX: number, clientY: number, motion: TooltipMotion) => {
133143
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
134144
setState((current) =>
@@ -175,12 +185,13 @@ export function useFloatingTooltip(
175185
return {
176186
onPointerEnter: (event) => {
177187
if (event.pointerType === 'touch') return
188+
dismissedRef.current = false
178189
if (!canShowRef.current(event.currentTarget)) return
179190
triggerRef.current = event.currentTarget
180191
showFromPointer(event.clientX, event.clientY)
181192
},
182193
onPointerMove: (event) => {
183-
if (event.pointerType === 'touch') return
194+
if (event.pointerType === 'touch' || dismissedRef.current) return
184195
if (!canShowRef.current(event.currentTarget)) return
185196
triggerRef.current = event.currentTarget
186197
const now = performance.now()
@@ -210,6 +221,7 @@ export function useFloatingTooltip(
210221
onPointerDown: hide,
211222
onFocus: (event) => {
212223
const target = event.currentTarget
224+
dismissedRef.current = false
213225
if (!canShowRef.current(target)) return
214226
if (!isFocusVisible(target)) return
215227
triggerRef.current = target
@@ -223,6 +235,7 @@ export function useFloatingTooltip(
223235
const target = options.getFocusTarget?.()
224236
if (!target) return undefined
225237
const show = () => {
238+
dismissedRef.current = false
226239
if (!canShowRef.current(target) || !isFocusVisible(target)) return
227240
triggerRef.current = target
228241
showFromElement(target)
@@ -240,6 +253,18 @@ export function useFloatingTooltip(
240253
if (state.visible && (!trigger || !canShowRef.current(trigger))) hide()
241254
}, [hide, options.revalidateKey, state.visible])
242255

256+
React.useEffect(() => {
257+
if (!options.dismissOnEscape || !state.visible) return undefined
258+
const onKeyDown = (event: KeyboardEvent) => {
259+
if (event.key !== 'Escape') return
260+
event.preventDefault()
261+
event.stopPropagation()
262+
dismiss()
263+
}
264+
window.addEventListener('keydown', onKeyDown, true)
265+
return () => window.removeEventListener('keydown', onKeyDown, true)
266+
}, [dismiss, options.dismissOnEscape, state.visible])
267+
243268
/**
244269
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
245270
* browsers don't re-dispatch boundary events until the pointer next moves (e.g. an editor bubble
@@ -255,7 +280,7 @@ export function useFloatingTooltip(
255280
return () => window.clearInterval(intervalId)
256281
}, [state.visible, hide])
257282

258-
return { state, handlers }
283+
return { state, handlers, dismiss }
259284
}
260285

261286
const overflowMeasureByElement = new WeakMap<Element, () => void>()

0 commit comments

Comments
 (0)