fix(app): support keyboard tooltip controls

This commit is contained in:
Michael Wu
2026-10-04 05:45:13 +09:00
parent 71faa28997
commit 8ee1f640d3
2 changed files with 59 additions and 7 deletions
@@ -47,6 +47,8 @@ interface TooltipContextValue {
cancelClose: () => void;
scheduleClose: () => void;
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
focusContent: () => boolean;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
@@ -101,6 +103,17 @@ function composeEventHandlers(
};
}
function focusFirstContentControl(content: View | null): boolean {
if (!isWeb || typeof document === "undefined" || !content) return false;
const root = content as unknown as HTMLElement;
const control = root.querySelector<HTMLElement>(
'[role="button"][tabindex]:not([aria-disabled="true"]), [role="checkbox"][tabindex]:not([aria-disabled="true"]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])',
);
if (!control) return false;
control.focus();
return document.activeElement === control;
}
function useControllableOpenState({
open,
defaultOpen,
@@ -247,6 +260,7 @@ export function Tooltip({
interactive?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
@@ -274,6 +288,7 @@ export function Tooltip({
closeTimerRef.current = null;
}, 100);
}, [cancelClose, interactive, setIsOpen]);
const focusContent = useCallback(() => focusFirstContentControl(contentRef.current), []);
useEffect(() => cancelClose, [cancelClose]);
@@ -284,6 +299,8 @@ export function Tooltip({
cancelClose,
scheduleClose,
triggerRef,
contentRef,
focusContent,
enabled,
openOnPress: opensOnPress,
delayDuration,
@@ -294,6 +311,7 @@ export function Tooltip({
setIsOpen,
cancelClose,
scheduleClose,
focusContent,
enabled,
opensOnPress,
delayDuration,
@@ -311,6 +329,7 @@ export function TooltipTrigger({
onHoverOut,
onFocus,
onBlur,
onKeyDown,
onPress,
asChild = false,
triggerRefProp = "ref",
@@ -318,6 +337,7 @@ export function TooltipTrigger({
}: PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
onKeyDown?: (event: unknown) => void;
}): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -388,6 +408,21 @@ export function TooltipTrigger({
[close, onBlur],
);
const handleKeyDown = useCallback(
(event: unknown) => {
if (isCallable(onKeyDown)) onKeyDown(event);
if (!ctx.interactive || !ctx.open) return;
const keyboardEvent = event as {
key?: unknown;
shiftKey?: unknown;
preventDefault?: () => void;
};
if (keyboardEvent.key !== "Tab" || keyboardEvent.shiftKey) return;
if (ctx.focusContent()) keyboardEvent.preventDefault?.();
},
[ctx, onKeyDown],
);
const handlePress = useCallback(
(e: unknown) => {
if (isCallable(onPress)) onPress(e);
@@ -412,6 +447,7 @@ export function TooltipTrigger({
onHoverOut: handleHoverOut,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
onPress: handlePress,
...(isWeb
? ({
@@ -442,6 +478,7 @@ export function TooltipTrigger({
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
onKeyDown: composeEventHandlers(Reflect.get(rawProps, "onKeyDown"), handleKeyDown),
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
@@ -561,6 +598,7 @@ export function TooltipContent({
<View pointerEvents={ctx.interactive ? "box-none" : "none"} style={styles.portalOverlay}>
<FloatingSurface
pointerEvents={ctx.interactive ? "auto" : "none"}
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
@@ -597,6 +635,7 @@ export function TooltipContent({
/>
<FloatingSurface
pointerEvents="auto"
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
@@ -614,6 +653,7 @@ export function TooltipContent({
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}