Show tooltips for all composer controls (#3173)

* fix(app): restore composer control tooltips

Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries.

* fix(app): align mode tooltip typography
This commit is contained in:
Mohamed Boudra
2026-08-10 21:57:50 +02:00
committed by GitHub
parent df9e8e8f06
commit 29ae25d7aa
5 changed files with 107 additions and 15 deletions
@@ -0,0 +1,26 @@
/**
* @vitest-environment jsdom
*/
import React, { createRef } from "react";
import { render } from "@testing-library/react";
import { Text, type View } from "react-native";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MenuRoot, MenuTrigger } from "./menu-root";
beforeEach(() => vi.stubGlobal("React", React));
describe("MenuTrigger", () => {
it("forwards its rendered trigger to callers", () => {
const triggerRef = createRef<View>();
render(
<MenuRoot>
<MenuTrigger ref={triggerRef} accessibilityLabel="Open menu">
<Text>Open</Text>
</MenuTrigger>
</MenuRoot>,
);
expect(triggerRef.current).not.toBeNull();
});
});
@@ -1,6 +1,14 @@
import { useCallback, type PropsWithChildren, type ReactElement, type ReactNode } from "react";
import {
forwardRef,
useCallback,
type PropsWithChildren,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
import {
Pressable,
type View,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
@@ -49,14 +57,29 @@ export interface MenuTriggerProps extends Omit<PressableProps, "style" | "childr
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
}
export function MenuTrigger({
children,
disabled,
style,
...props
}: MenuTriggerProps): ReactElement {
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
Object.assign(ref, { current: value });
}
export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigger(
{ children, disabled, style, ...props },
forwardedRef,
): ReactElement {
const ctx = useMenuContext("MenuTrigger");
const handleTriggerRef = useCallback(
(node: View | null) => {
assignRef(ctx.triggerRef, node);
assignRef(forwardedRef, node);
},
[ctx.triggerRef, forwardedRef],
);
const handlePress = useCallback(() => {
if (disabled) return;
ctx.setOpen(!ctx.open);
@@ -83,7 +106,7 @@ export function MenuTrigger({
return (
<Pressable
{...props}
ref={ctx.triggerRef}
ref={handleTriggerRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
@@ -92,4 +115,4 @@ export function MenuTrigger({
{renderChildren}
</Pressable>
);
}
});
@@ -0,0 +1,39 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentControlTrigger } from "./control";
beforeEach(() => vi.stubGlobal("React", React));
function TestIcon() {
return null;
}
describe("AgentControlTrigger", () => {
it("forwards interaction handlers to the rendered trigger", () => {
const onPointerEnter = vi.fn();
const onFocus = vi.fn();
const onPress = vi.fn();
const view = render(
<AgentControlTrigger
icon={TestIcon}
surface="toolbar"
label="Mode"
onPress={onPress}
onPointerEnter={onPointerEnter}
onFocus={onFocus}
accessibilityLabel="Select mode"
/>,
);
const trigger = view.getByRole("button", { name: "Select mode" });
fireEvent.pointerEnter(trigger);
fireEvent.focus(trigger);
expect(onPointerEnter).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { forwardRef, useCallback } from "react";
import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -6,7 +6,10 @@ import { useComposerControlLayout } from "@/composer/agent-controls/layout-conte
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
interface AgentControlTriggerProps {
type AgentControlTriggerProps = Omit<
ComponentProps<typeof ComboboxTrigger>,
"accessibilityLabel" | "block" | "children" | "chevron" | "onPress" | "style"
> & {
icon: AgentControlIcon;
iconColor?: string;
surface: "toolbar" | "sheet";
@@ -15,11 +18,9 @@ interface AgentControlTriggerProps {
showToolbarLabel?: boolean;
showCaret?: boolean;
open?: boolean;
disabled?: boolean;
onPress: () => void;
accessibilityLabel: string;
testID?: string;
}
};
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
function AgentControlTrigger(
@@ -36,6 +37,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
onPress,
accessibilityLabel,
testID,
...triggerProps
},
ref,
) {
@@ -57,6 +59,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
return (
<ComboboxTrigger
{...triggerProps}
ref={ref}
collapsable={false}
disabled={disabled}
@@ -317,6 +317,7 @@ const styles = StyleSheet.create((theme) => ({
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
}));