Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/coreui-react/src/components/popover/CPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,26 @@ export const CPopover = forwardRef<HTMLDivElement, CPopoverProps>(
}
}, [_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<HTMLElement> & { ref?: React.Ref<HTMLElement> }
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CPopover trigger="hover" content="content">
<CButton color="primary">Test</CButton>
</CPopover>
)

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(
<CPopover trigger="hover" content="content">
<CButton color="primary">Test</CButton>
</CPopover>
)

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()
})
20 changes: 20 additions & 0 deletions packages/coreui-react/src/components/tooltip/CTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ export const CTooltip = forwardRef<HTMLDivElement, CTooltipProps>(
}
}, [_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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CTooltip trigger="hover" content="content">
<CLink>Test</CLink>
</CTooltip>
)

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(
<CTooltip trigger="hover" content="content">
<CLink>Test</CLink>
</CTooltip>
)

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()
})
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/components/popover/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Escape</kbd> key. As with dropdown menus, a popover shown inside a dialog is dismissed on its own: the first <kbd>Escape</kbd> closes the popover and a subsequent one closes the dialog.

<Example code={PopoverDismissExampleRaw} name="PopoverDismissExample" componentName="React Popover">
<PopoverDismissExample client:only="react" />
</Example>
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/components/tooltip/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Escape</kbd> 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 `<div>` or `<span>`, ideally made keyboard-focusable using `tabindex="0"`.
Expand Down
Loading