From 29ae25d7aa44dadc91cfc77cd4c64d475bd18d8c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 21:57:50 +0200 Subject: [PATCH] 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 --- .../src/components/ui/menu/menu-root.test.tsx | 26 ++++++++++++ .../app/src/components/ui/menu/menu-root.tsx | 41 +++++++++++++++---- .../composer/agent-controls/control.test.tsx | 39 ++++++++++++++++++ .../src/composer/agent-controls/control.tsx | 13 +++--- .../composer/agent-controls/mode-control.tsx | 3 +- 5 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/components/ui/menu/menu-root.test.tsx create mode 100644 packages/app/src/composer/agent-controls/control.test.tsx diff --git a/packages/app/src/components/ui/menu/menu-root.test.tsx b/packages/app/src/components/ui/menu/menu-root.test.tsx new file mode 100644 index 000000000..2b24fa122 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-root.test.tsx @@ -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(); + + render( + + + Open + + , + ); + + expect(triggerRef.current).not.toBeNull(); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-root.tsx b/packages/app/src/components/ui/menu/menu-root.tsx index ca801c8ca..08c4b2407 100644 --- a/packages/app/src/components/ui/menu/menu-root.tsx +++ b/packages/app/src/components/ui/menu/menu-root.tsx @@ -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 ReactNode); } -export function MenuTrigger({ - children, - disabled, - style, - ...props -}: MenuTriggerProps): ReactElement { +function assignRef(ref: Ref | undefined, value: T | null): void { + if (!ref) return; + if (typeof ref === "function") { + ref(value); + return; + } + Object.assign(ref, { current: value }); +} + +export const MenuTrigger = forwardRef(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 ( ); -} +}); diff --git a/packages/app/src/composer/agent-controls/control.test.tsx b/packages/app/src/composer/agent-controls/control.test.tsx new file mode 100644 index 000000000..70eb679a2 --- /dev/null +++ b/packages/app/src/composer/agent-controls/control.test.tsx @@ -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( + , + ); + const trigger = view.getByRole("button", { name: "Select mode" }); + + fireEvent.pointerEnter(trigger); + fireEvent.focus(trigger); + + expect(onPointerEnter).toHaveBeenCalledTimes(1); + expect(onFocus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index 2077c86ab..bef4bc891 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -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, + "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( function AgentControlTrigger( @@ -36,6 +37,7 @@ export const AgentControlTrigger = forwardRef( onPress, accessibilityLabel, testID, + ...triggerProps }, ref, ) { @@ -57,6 +59,7 @@ export const AgentControlTrigger = forwardRef( return ( ({ }, tooltipText: { color: theme.colors.foreground, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, }, }));