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:
@@ -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 {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
|
type View,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
@@ -49,14 +57,29 @@ export interface MenuTriggerProps extends Omit<PressableProps, "style" | "childr
|
|||||||
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
|
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuTrigger({
|
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||||
children,
|
if (!ref) return;
|
||||||
disabled,
|
if (typeof ref === "function") {
|
||||||
style,
|
ref(value);
|
||||||
...props
|
return;
|
||||||
}: MenuTriggerProps): ReactElement {
|
}
|
||||||
|
Object.assign(ref, { current: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigger(
|
||||||
|
{ children, disabled, style, ...props },
|
||||||
|
forwardedRef,
|
||||||
|
): ReactElement {
|
||||||
const ctx = useMenuContext("MenuTrigger");
|
const ctx = useMenuContext("MenuTrigger");
|
||||||
|
|
||||||
|
const handleTriggerRef = useCallback(
|
||||||
|
(node: View | null) => {
|
||||||
|
assignRef(ctx.triggerRef, node);
|
||||||
|
assignRef(forwardedRef, node);
|
||||||
|
},
|
||||||
|
[ctx.triggerRef, forwardedRef],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
ctx.setOpen(!ctx.open);
|
ctx.setOpen(!ctx.open);
|
||||||
@@ -83,7 +106,7 @@ export function MenuTrigger({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...props}
|
{...props}
|
||||||
ref={ctx.triggerRef}
|
ref={handleTriggerRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
@@ -92,4 +115,4 @@ export function MenuTrigger({
|
|||||||
{renderChildren}
|
{renderChildren}
|
||||||
</Pressable>
|
</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 { Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
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 { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||||
|
|
||||||
interface AgentControlTriggerProps {
|
type AgentControlTriggerProps = Omit<
|
||||||
|
ComponentProps<typeof ComboboxTrigger>,
|
||||||
|
"accessibilityLabel" | "block" | "children" | "chevron" | "onPress" | "style"
|
||||||
|
> & {
|
||||||
icon: AgentControlIcon;
|
icon: AgentControlIcon;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
surface: "toolbar" | "sheet";
|
surface: "toolbar" | "sheet";
|
||||||
@@ -15,11 +18,9 @@ interface AgentControlTriggerProps {
|
|||||||
showToolbarLabel?: boolean;
|
showToolbarLabel?: boolean;
|
||||||
showCaret?: boolean;
|
showCaret?: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
testID?: string;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||||
function AgentControlTrigger(
|
function AgentControlTrigger(
|
||||||
@@ -36,6 +37,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
|||||||
onPress,
|
onPress,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
testID,
|
testID,
|
||||||
|
...triggerProps
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -57,6 +59,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboboxTrigger
|
<ComboboxTrigger
|
||||||
|
{...triggerProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
tooltipText: {
|
tooltipText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user