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
6 changes: 6 additions & 0 deletions .changeset/tooltip-wcag-1413.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Tooltip: satisfy WCAG 1.4.13 (Content on Hover or Focus). Tooltips can now be dismissed with `Escape` while visible, and stay open when the pointer moves from the trigger onto the tooltip surface (a short hover bridge). `useLayer` context render props gain `onMouseEnter`/`onMouseLeave` on the layer container to support hoverable overlays.
@cixzhang
16 changes: 15 additions & 1 deletion packages/core/src/Layer/useLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export interface ContextRenderProps {
* @default 'div'
*/
as?: 'div' | 'span';
/**
* Pointer-enter handler attached to the popover container itself. Lets a
* consumer keep a hover-driven layer open while the pointer is over the
* surface (e.g. Tooltip/HoverCard "hoverable" behavior — WCAG 1.4.13).
*/
onMouseEnter?: React.MouseEventHandler<HTMLElement>;
/**
* Pointer-leave handler attached to the popover container itself.
*/
onMouseLeave?: React.MouseEventHandler<HTMLElement>;
}

/**
Expand Down Expand Up @@ -389,6 +399,8 @@ export function useLayer(
className: extraClassName,
style: extraStyle,
as: Container = 'div',
onMouseEnter,
onMouseLeave,
} = props || {};

// CSS anchor positioning (dynamic, not in StyleX)
Expand All @@ -413,7 +425,9 @@ export function useLayer(
role={role}
popover={lightDismiss ? 'auto' : 'manual'}
className={combinedClassName}
style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}>
style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{children}
</Container>
);
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,72 @@ describe('Tooltip', () => {
});
});
});

describe('WCAG 1.4.13 — content on hover or focus', () => {
it('dismisses on Escape while visible (dismissible)', async () => {
const onOpenChange = vi.fn();
render(
<Tooltip content="Dismiss me" onOpenChange={onOpenChange} delay={0}>
<button type="button">Trigger</button>
</Tooltip>,
);

const trigger = screen.getByRole('button', {name: 'Trigger'});
fireEvent.mouseEnter(trigger);
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});

fireEvent.keyDown(document, {key: 'Escape'});
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});

it('ignores Escape during IME composition', async () => {
const onOpenChange = vi.fn();
render(
<Tooltip content="Stay" onOpenChange={onOpenChange} delay={0}>
<button type="button">Trigger</button>
</Tooltip>,
);

const trigger = screen.getByRole('button', {name: 'Trigger'});
fireEvent.mouseEnter(trigger);
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
onOpenChange.mockClear();

fireEvent.keyDown(document, {key: 'Escape', isComposing: true});
// Give any (incorrect) async hide a chance to run.
await new Promise(r => setTimeout(r, 20));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});

it('stays open when the pointer moves onto the tooltip surface (hoverable)', async () => {
const onOpenChange = vi.fn();
render(
<Tooltip content="Hover me" onOpenChange={onOpenChange} delay={0}>
<button type="button">Trigger</button>
</Tooltip>,
);

const trigger = screen.getByRole('button', {name: 'Trigger'});
fireEvent.mouseEnter(trigger);
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
onOpenChange.mockClear();

// Pointer leaves the trigger but enters the tooltip surface before the
// hover-bridge grace period elapses — the tooltip must not hide.
fireEvent.mouseLeave(trigger);
const layer = screen.getByRole('tooltip', {hidden: true});
fireEvent.mouseEnter(layer);

await new Promise(r => setTimeout(r, 150));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
});
63 changes: 55 additions & 8 deletions packages/core/src/Tooltip/useTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ import {
typeScaleVars,
} from '../theme/tokens.stylex';

/**
* Grace period (ms) before hiding on pointer-leave when no explicit `hideDelay`
* is set, so the pointer can travel across the small gap from the trigger onto
* the tooltip surface without the tooltip disappearing (WCAG 1.4.13 hoverable).
*/
const HOVER_BRIDGE_DELAY = 100;

const styles = stylex.create({
// Base container styles - inverted colors for high contrast
container: {
Expand Down Expand Up @@ -291,21 +298,29 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
}, delay);
}, [isEnabled, isOpen, clearTimeouts, layer, delay]);

// Schedule hide with delay (suppressed when isOpen is true)
// Schedule hide with delay (suppressed when isOpen is true).
// A small hover bridge (when hideDelay is 0) lets the pointer travel from the
// trigger onto the tooltip surface without the tooltip vanishing — required
// for WCAG 1.4.13 (Content on Hover or Focus: hoverable).
const scheduleHide = useCallback(() => {
if (isOpen === true) {
return;
}
clearTimeouts();
if (hideDelay > 0) {
hideTimeoutRef.current = setTimeout(() => {
layer.hide();
}, hideDelay);
} else {
const effectiveHideDelay = hideDelay > 0 ? hideDelay : HOVER_BRIDGE_DELAY;
hideTimeoutRef.current = setTimeout(() => {
layer.hide();
}
}, effectiveHideDelay);
}, [isOpen, clearTimeouts, layer, hideDelay]);

// Cancel a pending hide (e.g. the pointer entered the tooltip surface).
const cancelHide = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);

// Event handlers
const handleMouseEnter = useCallback(() => {
// Suppress tooltips on touch devices — hover is simulated and eats a tap
Expand Down Expand Up @@ -420,6 +435,32 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
}
}, [isOpen, clearTimeouts, layer]);

// Dismiss on Escape (WCAG 1.4.13 — dismissible). Uncontrolled tooltips only;
// a controlled tooltip's visibility is owned by the consumer. The listener is
// mounted for the lifetime of an uncontrolled tooltip rather than gated on
// `layer.isOpen` (React state, which can lag a frame behind the DOM) —
// `layer.hide()` self-guards and no-ops when the layer is already closed.
// Guarded against IME composition-cancel.
useEffect(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm seeing this in a few different PRs plus we want to ensure that only top layer gets dismissed before cascading to other layers. We should coordinate these in a followup.

if (isOpen !== undefined) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
return;
}
if (e.isComposing || e.keyCode === 229) {
return;
}
clearTimeouts();
layer.hide();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, layer, clearTimeouts]);

// Render function that wraps layer.render with tooltip styling
const renderTooltip = useCallback(
(children: ReactNode, props?: ContextRenderProps): ReactNode => {
Expand All @@ -430,14 +471,20 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
role: 'tooltip',
xstyle: [popoverXstyle, layerAnimations[renderPlacement]],
className: themeProps('tooltip').className,
// Keep the tooltip open while the pointer is over the surface itself
// (WCAG 1.4.13 hoverable). These sit on the layer container — the
// element the user actually hovers — not the inner content div, since
// mouseenter/leave do not bubble.
onMouseEnter: cancelHide,
onMouseLeave: scheduleHide,
};

return layer.render(
<div {...stylex.props(styles.content)}>{children}</div>,
renderProps,
);
},
[layer, placement, alignment, popoverXstyle],
[layer, placement, alignment, popoverXstyle, cancelHide, scheduleHide],
);

return {
Expand Down
Loading