From 9aaa08e46c66e6de724a1c4519cbf862b51e9f06 Mon Sep 17 00:00:00 2001 From: mrholek Date: Fri, 17 Jul 2026 20:34:29 +0200 Subject: [PATCH] fix(Tooltip): dismiss tooltip and popover on Escape key (WCAG 1.4.13) --- .../src/components/popover/CPopover.tsx | 20 ++++++ .../popover/__tests__/CPopover.spec.tsx | 64 +++++++++++++++++++ .../src/components/tooltip/CTooltip.tsx | 20 ++++++ .../tooltip/__tests__/CTooltip.spec.tsx | 64 +++++++++++++++++++ .../content/docs/components/popover/index.mdx | 2 + .../content/docs/components/tooltip/index.mdx | 2 + 6 files changed, 172 insertions(+) diff --git a/packages/coreui-react/src/components/popover/CPopover.tsx b/packages/coreui-react/src/components/popover/CPopover.tsx index 64899b14..79636449 100644 --- a/packages/coreui-react/src/components/popover/CPopover.tsx +++ b/packages/coreui-react/src/components/popover/CPopover.tsx @@ -266,6 +266,26 @@ export const CPopover = forwardRef( } }, [_visible]) + useEffect(() => { + if (!_visible) { + return + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + handleHide() + } + } + + document.addEventListener('keydown', handleKeyDown, true) + + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [_visible]) + const child = children as React.ReactElement< React.HTMLAttributes & { ref?: React.Ref } > diff --git a/packages/coreui-react/src/components/popover/__tests__/CPopover.spec.tsx b/packages/coreui-react/src/components/popover/__tests__/CPopover.spec.tsx index 387a1b49..ff9317cf 100644 --- a/packages/coreui-react/src/components/popover/__tests__/CPopover.spec.tsx +++ b/packages/coreui-react/src/components/popover/__tests__/CPopover.spec.tsx @@ -171,3 +171,67 @@ test('CPopover onShow and onHide', async () => { vi.useRealTimers() }) + +test('CPopover is dismissed when the Escape key is pressed', async () => { + vi.useFakeTimers() + + render( + + Test + + ) + + const btn = screen.getByRole('button', { name: /test/i }) + + act(() => { + fireEvent.mouseOver(btn) + }) + + act(() => { + vi.runAllTimers() + }) + + expect(document.querySelector('.popover')).toHaveClass('show') + expect(btn).toHaveAttribute('aria-describedby') + + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }) + vi.runAllTimers() + }) + + expect(document.querySelector('.popover.show')).toBeNull() + expect(btn).not.toHaveAttribute('aria-describedby') + + vi.useRealTimers() +}) + +test('CPopover is not dismissed when a non-Escape key is pressed', async () => { + vi.useFakeTimers() + + render( + + Test + + ) + + const btn = screen.getByRole('button', { name: /test/i }) + + act(() => { + fireEvent.mouseOver(btn) + }) + + act(() => { + vi.runAllTimers() + }) + + expect(document.querySelector('.popover')).toHaveClass('show') + + act(() => { + fireEvent.keyDown(document, { key: 'Enter' }) + vi.runAllTimers() + }) + + expect(document.querySelector('.popover')).toHaveClass('show') + + vi.useRealTimers() +}) diff --git a/packages/coreui-react/src/components/tooltip/CTooltip.tsx b/packages/coreui-react/src/components/tooltip/CTooltip.tsx index 1ca1ce3e..aa587df7 100644 --- a/packages/coreui-react/src/components/tooltip/CTooltip.tsx +++ b/packages/coreui-react/src/components/tooltip/CTooltip.tsx @@ -257,6 +257,26 @@ export const CTooltip = forwardRef( } }, [_visible]) + useEffect(() => { + if (!_visible) { + return + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + handleHide() + } + } + + document.addEventListener('keydown', handleKeyDown, true) + + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [_visible]) + useEffect(() => { updatePopper() }, [content]) diff --git a/packages/coreui-react/src/components/tooltip/__tests__/CTooltip.spec.tsx b/packages/coreui-react/src/components/tooltip/__tests__/CTooltip.spec.tsx index 953fac7f..f0884c49 100644 --- a/packages/coreui-react/src/components/tooltip/__tests__/CTooltip.spec.tsx +++ b/packages/coreui-react/src/components/tooltip/__tests__/CTooltip.spec.tsx @@ -183,3 +183,67 @@ test('CTooltip onShow and onHide', async () => { vi.useRealTimers() }) + +test('CTooltip is dismissed when the Escape key is pressed', async () => { + vi.useFakeTimers() + + render( + + Test + + ) + + const link = screen.getByText('Test') + + act(() => { + fireEvent.mouseOver(link) + }) + + act(() => { + vi.runAllTimers() + }) + + expect(document.querySelector('.tooltip')).toHaveClass('show') + expect(link).toHaveAttribute('aria-describedby') + + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }) + vi.runAllTimers() + }) + + expect(document.querySelector('.tooltip.show')).toBeNull() + expect(link).not.toHaveAttribute('aria-describedby') + + vi.useRealTimers() +}) + +test('CTooltip is not dismissed when a non-Escape key is pressed', async () => { + vi.useFakeTimers() + + render( + + Test + + ) + + const link = screen.getByText('Test') + + act(() => { + fireEvent.mouseOver(link) + }) + + act(() => { + vi.runAllTimers() + }) + + expect(document.querySelector('.tooltip')).toHaveClass('show') + + act(() => { + fireEvent.keyDown(document, { key: 'Enter' }) + vi.runAllTimers() + }) + + expect(document.querySelector('.tooltip')).toHaveClass('show') + + vi.useRealTimers() +}) diff --git a/packages/docs/src/content/docs/components/popover/index.mdx b/packages/docs/src/content/docs/components/popover/index.mdx index c7284f76..f87b7e3f 100644 --- a/packages/docs/src/content/docs/components/popover/index.mdx +++ b/packages/docs/src/content/docs/components/popover/index.mdx @@ -46,6 +46,8 @@ You can customize the appearance of popovers using [CSS variables](./styling/#cs Use the `focus` trigger to dismiss popovers on the user's next click of a different element than the toggle element. +A shown popover can also be dismissed by pressing the Escape key. As with dropdown menus, a popover shown inside a dialog is dismissed on its own: the first Escape closes the popover and a subsequent one closes the dialog. + diff --git a/packages/docs/src/content/docs/components/tooltip/index.mdx b/packages/docs/src/content/docs/components/tooltip/index.mdx index 95ea8e70..05470879 100644 --- a/packages/docs/src/content/docs/components/tooltip/index.mdx +++ b/packages/docs/src/content/docs/components/tooltip/index.mdx @@ -44,6 +44,8 @@ Hover over the buttons below to see the four tooltips directions: top, right, bo ## Usage +A shown tooltip can be dismissed by pressing the Escape key, helping satisfy the [WCAG 1.4.13 "Content on Hover or Focus"](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html) success criterion. + ### Disabled elements Elements with the disabled attribute aren’t interactive, meaning users cannot focus, hover, or click them to trigger a tooltip (or popover). As a workaround, you’ll want to trigger the tooltip from a wrapper `
` or ``, ideally made keyboard-focusable using `tabindex="0"`.