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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/bindx-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"tabbable": "^6.5.0",
"tailwind-merge": "^3.3.1"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-ui/src/datagrid/ui/label-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { uic } from '../../utils/uic.js'

// Focusable so the filter affordance and its tooltip are reachable without a mouse.
export const DataGridTooltipLabel = uic('span', {
defaultProps: { tabIndex: 0 },
defaultProps: { tabIndex: 0, 'aria-keyshortcuts': 'ArrowDown' },
baseClass: 'cursor-pointer underline decoration-dashed decoration-transparent underline-offset-4 transition-colors hover:decoration-gray-400 focus-visible:decoration-gray-800 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring rounded-xs',
})
87 changes: 72 additions & 15 deletions packages/bindx-ui/src/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { tabbable, type FocusableElement } from 'tabbable'
import { cn } from '../utils/cn.js'

export interface TooltipProps {
Expand All @@ -33,9 +34,10 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(({
const [open, setOpen] = useState(false)
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const interaction = useRef({ pointer: false, focus: false })
// Focus moves into the panel only when the keyboard opened it. A hover must
// leave focus wherever the user put it.
const openedByFocus = useRef(false)
const panelRef = useRef<HTMLDivElement>(null)
const focusOrigin = useRef<FocusableElement | null>(null)
const enterOnMount = useRef(false)
const restoringFocus = useRef(false)

const cancelClose = useCallback((): void => {
if (closeTimer.current !== null) {
Expand All @@ -56,46 +58,101 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(({
const openFor = useCallback((source: 'pointer' | 'focus'): void => {
cancelClose()
interaction.current[source] = true
openedByFocus.current = source === 'focus'
setOpen(true)
}, [cancelClose])

const closePanel = (): void => {
cancelClose()
interaction.current = { pointer: false, focus: false }
enterOnMount.current = false
setOpen(false)
}

const returnToTrigger = (): void => {
closePanel()
restoringFocus.current = true
focusOrigin.current?.focus({ preventScroll: true })
restoringFocus.current = false
}

const focusPanel = (): void => {
const panel = panelRef.current
if (!panel) return
const target = tabbable(panel)[0] ?? panel
target.focus({ preventScroll: true })
}

useEffect(() => cancelClose, [cancelClose])

return (
<PopoverPrimitive.Root open={open} onOpenChange={nextOpen => {
if (!nextOpen) {
cancelClose()
interaction.current = { pointer: false, focus: false }
}
setOpen(nextOpen)
if (nextOpen) setOpen(true)
else closePanel()
}}>
{/* Trigger rather than Anchor: Radix excludes the trigger's subtree from
its outside-dismissal, so focusing the label does not close the panel. */}
<PopoverPrimitive.Trigger asChild>
<div
ref={ref}
tabIndex={-1}
data-bindx-tooltip=""
className={cn('inline-block', className)}
onPointerEnter={() => openFor('pointer')}
onPointerEnter={event => {
if (!event.currentTarget.contains(document.activeElement)) {
focusOrigin.current = tabbable(event.currentTarget)[0] ?? event.currentTarget
}
openFor('pointer')
}}
onPointerLeave={() => scheduleClose('pointer')}
onFocus={() => openFor('focus')}
onFocus={event => {
focusOrigin.current = event.target
if (!restoringFocus.current) openFor('focus')
}}
onBlur={() => scheduleClose('focus')}
onKeyDown={event => {
if (event.key !== 'ArrowDown' || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
if (!(event.target instanceof HTMLElement)) return
if (event.target.closest('a,button,input,select,textarea,[contenteditable="true"]')) return
event.preventDefault()
event.stopPropagation()
focusOrigin.current = event.target
if (panelRef.current) focusPanel()
else {
enterOnMount.current = true
openFor('focus')
}
}}
>
{children}
</div>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={panelRef}
data-bindx-tooltip-panel=""
side={side}
sideOffset={6}
onOpenAutoFocus={event => {
if (!openedByFocus.current) event.preventDefault()
event.preventDefault()
if (enterOnMount.current) {
enterOnMount.current = false
focusPanel()
}
}}
onCloseAutoFocus={event => event.preventDefault()}
onEscapeKeyDown={event => {
event.preventDefault()
if (panelRef.current?.contains(document.activeElement)) returnToTrigger()
else closePanel()
}}
onCloseAutoFocus={event => {
// Restoring focus after hover/blur closure would open the panel again.
if (!interaction.current.focus) event.preventDefault()
onKeyDownCapture={event => {
if (event.key !== 'Tab' || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
const stops = tabbable(event.currentTarget)
const edge = event.shiftKey ? stops[0] : stops[stops.length - 1]
if (event.target !== edge && event.target !== event.currentTarget) return
// Bypass Radix's loop; native Tab continues from the original cell.
event.stopPropagation()
returnToTrigger()
}}
onPointerEnter={() => openFor('pointer')}
onPointerLeave={() => scheduleClose('pointer')}
Expand Down
2 changes: 1 addition & 1 deletion packages/example/pages/datagrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function DataGridPage(): ReactElement {
<DataGridTextColumn field={it.content} />
<DataGridDateColumn field={it.publishedAt} sortable filter />
<DataGridHasOneColumn field={it.author}>
{author => author.name.value ?? '\u2014'}
{author => <a href="#entity-lists" data-testid="datagrid-author-link">{author.name.value ?? '\u2014'}</a>}
</DataGridHasOneColumn>
<DataGridHasManyColumn field={it.tags}>
{tag => tag.name.value ?? ''}
Expand Down
8 changes: 8 additions & 0 deletions tests/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface ElementHandle {
attr(name: string): string
count(): number
click(): void
hover(): void
fill(value: string): void
select(optionText: string): void
}
Expand Down Expand Up @@ -113,6 +114,9 @@ export function el(selector: string): ElementHandle {
exec(`agent-browser click ${quoted}`)
Bun.sleepSync(500)
},
hover(): void {
exec(`agent-browser hover ${quoted}`)
},
fill(value: string): void {
exec(`agent-browser scrollintoview ${quoted}`)
exec(`agent-browser fill ${quoted} ${q(value)}`)
Expand Down Expand Up @@ -199,6 +203,10 @@ export function evalJs(js: string): string {
return exec(`agent-browser eval ${q(js)}`)
}

export function press(key: string): void {
exec(`agent-browser press ${q(key)}`)
}

export function screenshot(path?: string): string {
const target = path ?? `/tmp/browser-test-${Date.now()}.png`
exec(`agent-browser screenshot ${target}`)
Expand Down
82 changes: 82 additions & 0 deletions tests/browser/relationTooltipKeyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect, test } from 'bun:test'
import { browserTest, el, evalJs, press, tid, waitFor } from './browser.js'

const cell = `${tid('datagrid-example')} ${tid('datagrid-row-0')} ${tid('datagrid-cell-author')}`
const label = `${cell} [aria-keyshortcuts="ArrowDown"]`
const panel = '[data-bindx-tooltip-panel]'

function activeMatches(selector: string): boolean {
return evalJs(`document.activeElement?.matches(${JSON.stringify(selector)})`) === 'true'
}

function focusLabel(): void {
evalJs(`document.querySelector(${JSON.stringify(label)}).focus()`)
waitFor(() => el(panel).exists)
expect(activeMatches(label)).toBe(true)
}

browserTest('relation tooltip keyboard navigation', () => {
test('keeps cell links in native tab order and follows them with Enter', () => {
waitFor(() => el(label).exists)
focusLabel()
press('Tab')
expect(activeMatches(`${cell} a`)).toBe(true)
press('Shift+Tab')
expect(activeMatches(label)).toBe(true)
press('Tab')
press('Enter')
waitFor(() => evalJs('location.hash') === '"#entity-lists"')
evalJs('location.hash = "datagrid"')
waitFor(() => el(label).exists)
})

test('ArrowDown enters actions; Escape restores focus without reopening', () => {
focusLabel()
press('ArrowDown')
expect(activeMatches(`${panel} button:first-child`)).toBe(true)
press('Escape')
waitFor(() => !el(panel).exists)
expect(activeMatches(label)).toBe(true)
press('ArrowDown')
waitFor(() => activeMatches(`${panel} button:first-child`))
press('Escape')
waitFor(() => !el(panel).exists)
press('Tab')
expect(activeMatches(`${cell} a`)).toBe(true)
})

test('Tab traverses both actions then continues at the cell link', () => {
focusLabel()
press('ArrowDown')
press('Tab')
expect(activeMatches(`${panel} button:last-child`)).toBe(true)
press('Tab')
expect(activeMatches(`${cell} a`)).toBe(true)
press('Tab')
expect(activeMatches(`${panel} *`)).toBe(false)
expect(activeMatches(`${cell} *`)).toBe(false)
expect(activeMatches('body')).toBe(false)
})

test('Shift+Tab at the first action leaves the panel backwards', () => {
focusLabel()
press('ArrowDown')
press('Shift+Tab')
expect(activeMatches(`${panel} *`)).toBe(false)
expect(activeMatches('body')).toBe(false)
press('Tab')
expect(activeMatches(label)).toBe(true)
})

test('Escape restores the cell after entering a hover-opened panel', () => {
press('Escape')
waitFor(() => !el(panel).exists)
evalJs('document.activeElement.blur()')
el(label).hover()
waitFor(() => el(panel).exists)
evalJs(`document.querySelector('${panel} button').focus()`)
press('Escape')
waitFor(() => !el(panel).exists)
expect(activeMatches(label)).toBe(true)
})
}, 'datagrid')
63 changes: 63 additions & 0 deletions tests/react/dataview/relationTooltipKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import '../../setup'
import { afterEach, expect, test } from 'bun:test'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
import { Tooltip, DataGridTooltipLabel } from '@contember/bindx-ui'

afterEach(async () => {
await act(async () => {
cleanup()
await new Promise(resolve => setTimeout(resolve, 0))
})
})

function renderCell(): ReturnType<typeof render> {
return render(
<Tooltip content={<><button>Filter</button><button>Exclude</button></>}>
<DataGridTooltipLabel data-testid="label"><a href="#author">Author</a></DataGridTooltipLabel>
</Tooltip>,
)
}

test('focus reveals actions without stealing focus from the label or its link', async () => {
const { getByTestId, getByRole } = renderCell()
const label = getByTestId('label')
act(() => label.focus())
await waitFor(() => expect(getByRole('button', { name: 'Filter' })).not.toBeNull())
expect(document.activeElement === label).toBe(true)
const link = getByRole('link')
act(() => link.focus())
expect(document.activeElement === link).toBe(true)
fireEvent.keyDown(link, { key: 'ArrowDown' })
expect(document.activeElement === link).toBe(true)
})

test('Escape returns to the trigger without reopening; ArrowDown can enter again', async () => {
const { getByTestId, getByRole } = renderCell()
const label = getByTestId('label')
act(() => label.focus())
await waitFor(() => expect(getByRole('button', { name: 'Filter' })).not.toBeNull())
act(() => getByRole('button', { name: 'Filter' }).focus())
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')).toBeNull())
expect(document.activeElement === label).toBe(true)
fireEvent.keyDown(label, { key: 'ArrowDown' })
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')?.contains(document.activeElement)).toBe(true))
})

test('Escape on a preview keeps focus on the link and outside dismissal preserves the new focus', async () => {
const { getByRole } = renderCell()
const { getByText } = render(<button>Outside</button>)
const link = getByRole('link')
act(() => link.focus())
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')).not.toBeNull())
fireEvent.keyDown(link, { key: 'Escape' })
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')).toBeNull())
expect(document.activeElement === link).toBe(true)
act(() => getByText('Outside').focus())
act(() => link.focus())
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')).not.toBeNull())
act(() => getByText('Outside').focus())
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel]')).toBeNull())
expect(document.activeElement === getByText('Outside')).toBe(true)
})
4 changes: 4 additions & 0 deletions tests/react/dataview/relationTooltipPortal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ describe('relation column filter affordance', () => {
})
const label = getByTestId(container, 'datagrid-cell-author').querySelector<HTMLElement>('[tabindex="0"]')!
act(() => label.focus())
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel] button')).not.toBeNull())
act(() => document.querySelector<HTMLButtonElement>('[data-bindx-tooltip-panel] button')!.focus())
await waitFor(() => expect(document.activeElement?.textContent).toBe('Filter'))
const panel = document.querySelector('[data-bindx-tooltip-panel]')!

Expand All @@ -105,6 +107,8 @@ describe('relation column filter affordance', () => {
})
const label = getByTestId(container, 'datagrid-cell-author').querySelector<HTMLElement>('[tabindex="0"]')!
act(() => label.focus())
await waitFor(() => expect(document.querySelector('[data-bindx-tooltip-panel] button')).not.toBeNull())
act(() => document.querySelector<HTMLButtonElement>('[data-bindx-tooltip-panel] button')!.focus())
await waitFor(() => expect(document.activeElement?.textContent).toBe('Filter'))
const panel = document.querySelector('[data-bindx-tooltip-panel]')!

Expand Down
Loading