fix(app): make session rename reachable in the mobile sessions dropdown (#2924)
* fix(app): make session rename reachable in the mobile sessions dropdown The per-session actions menu in the mobile tab switcher (a gorhom bottom sheet) opened as an anchored popover, which on native renders a second RN Modal stacked on the sheet — the fragile nested-overlay case docs/floating-panels.md warns about. The actions menu (incl. Rename) never appeared on native, so sessions could not be renamed from the dropdown. Give the menu compactMode="sheet" like every other mobile menu in the file, so it stacks as a gorhom sheet on a gorhom sheet (the supported path). Extract the accessory into workspace-tab-trailing-accessory.tsx and add a reachability test that fails if the sheet presentation regresses. * test(app): cover compact session rename via the tab switcher e2e Adds a Playwright spec that drives the mobile-viewport sessions dropdown -> per-session actions sheet -> Rename, exercising the real gorhom sheet interaction the fix restores (the desktop rename specs only cover the right-click context menu, and the unit test mocks the menu engine). --------- Co-authored-by: Klaude <klaude@Klaudes-MacBook-Pro.local> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Klaude
Mohamed Boudra
parent
1e0ce69c54
commit
49b257c55a
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "sessions dropdown" rename path. The desktop rename specs drive the right-click
|
||||
* `workspace-tab-context-*` menu, which portals into overlay-root and was never broken. This
|
||||
* exercises the tab switcher's per-session actions on a phone-sized viewport — the path that
|
||||
* regressed when the actions menu presented as a popover instead of a bottom sheet.
|
||||
*/
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const initialTitle = `switcher-rename-${randomUUID().slice(0, 8)}`;
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-switcher-rename-",
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Open this session's "…" actions. Before the fix this tried to open a popover-Modal
|
||||
// that never surfaced over the sheet on native; it now opens as a stacked sheet.
|
||||
const menuBase = `workspace-tab-menu-agent_${session.agentId}`;
|
||||
const actionsTrigger = page.getByTestId(`${menuBase}-trigger`);
|
||||
await expect(actionsTrigger).toBeVisible({ timeout: 15_000 });
|
||||
await actionsTrigger.click();
|
||||
|
||||
const renameItem = page.getByTestId(`${menuBase}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${session.agentId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "Renamed from the dropdown";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,21 +20,15 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Settings,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -128,13 +122,13 @@ import {
|
||||
useWorkspaceTabRename,
|
||||
WorkspaceTabRenameModal,
|
||||
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
buildWorkspaceTabMenuEntries,
|
||||
type WorkspaceTabMenuEntry,
|
||||
type WorkspaceTabMenuLabels,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests";
|
||||
@@ -249,12 +243,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
@@ -516,95 +504,10 @@ function WorkspaceDocumentTitleEffect({
|
||||
|
||||
function noop() {}
|
||||
|
||||
function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) {
|
||||
return [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive,
|
||||
];
|
||||
}
|
||||
|
||||
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
|
||||
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
|
||||
}
|
||||
|
||||
function MobileTabTrailingAccessory({
|
||||
menuTestIDBase,
|
||||
presentationLabel,
|
||||
menuEntries,
|
||||
}: {
|
||||
menuTestIDBase: string;
|
||||
presentationLabel: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
|
||||
hitSlop={8}
|
||||
style={mobileTabMenuTriggerStyle}
|
||||
>
|
||||
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTabDropdownMenuItem({
|
||||
entry,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
case "rotate-cw":
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
tooltip={entry.tooltip}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileWorkspaceTabOption({
|
||||
tab,
|
||||
tabIndex,
|
||||
@@ -4170,20 +4073,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
mobileTabMenuTrigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
headerMenuProfileIconWrapper: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import React, { type ReactElement } from "react";
|
||||
import { act, fireEvent } from "@testing-library/react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
||||
|
||||
void testI18n;
|
||||
|
||||
/**
|
||||
* Records the props the accessory hands to the menu engine, so the test can assert the
|
||||
* compact presentation without rendering the real gorhom sheet in jsdom.
|
||||
*/
|
||||
const capturedRootProps: { compactMode?: string }[] = [];
|
||||
const capturedContentProps: { sheetTitle?: string; testID?: string }[] = [];
|
||||
|
||||
const { mockTheme } = vi.hoisted(() => ({
|
||||
mockTheme: {
|
||||
colors: { foregroundMuted: "#aaa", surface2: "#222" },
|
||||
borderRadius: { md: 6 },
|
||||
fontSize: { xs: 11 },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const StubIcon = () => null;
|
||||
return {
|
||||
ArrowLeftToLine: StubIcon,
|
||||
ArrowRightToLine: StubIcon,
|
||||
Copy: StubIcon,
|
||||
CopyX: StubIcon,
|
||||
Ellipsis: StubIcon,
|
||||
Pencil: StubIcon,
|
||||
RotateCw: StubIcon,
|
||||
X: StubIcon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (theme: unknown) => unknown)(mockTheme) : factory,
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: { uniProps?: (theme: unknown) => Record<string, unknown> } & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(mockTheme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
compactMode,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
compactMode?: string;
|
||||
}) => {
|
||||
capturedRootProps.push({ compactMode });
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" data-testid={testID}>
|
||||
{typeof children === "function"
|
||||
? children({ hovered: false, pressed: false, open: false })
|
||||
: children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
sheetTitle,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
sheetTitle?: string;
|
||||
testID?: string;
|
||||
}) => {
|
||||
capturedContentProps.push({ sheetTitle, testID });
|
||||
return <div data-testid={testID}>{children}</div>;
|
||||
},
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" data-testid={testID} onClick={onSelect}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
function agentTab(): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
target: { kind: "agent", agentId: "agent-123" },
|
||||
};
|
||||
}
|
||||
|
||||
function terminalTab(): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: "terminal_abc",
|
||||
tabId: "terminal_abc",
|
||||
kind: "terminal",
|
||||
target: { kind: "terminal", terminalId: "terminal-abc" },
|
||||
};
|
||||
}
|
||||
|
||||
function renderAccessory(
|
||||
tab: WorkspaceTabDescriptor,
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void,
|
||||
): { base: string; unmount: () => void } {
|
||||
const base = `workspace-tab-menu-${tab.tabId}`;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab,
|
||||
index: 0,
|
||||
tabCount: 2,
|
||||
menuTestIDBase: base,
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCopyTerminalId: vi.fn(),
|
||||
onCopyFilePath: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab,
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const element: ReactElement = (
|
||||
<MobileTabTrailingAccessory
|
||||
menuTestIDBase={base}
|
||||
presentationLabel="My session"
|
||||
menuEntries={menuEntries}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
root.render(element);
|
||||
});
|
||||
return {
|
||||
base,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MobileTabTrailingAccessory", () => {
|
||||
let current: { unmount: () => void } | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
document.body.innerHTML = "";
|
||||
capturedRootProps.length = 0;
|
||||
capturedContentProps.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
current?.unmount();
|
||||
current = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the per-session actions as a bottom sheet on compact (so it is reachable over the sessions sheet)", () => {
|
||||
const onRenameTab = vi.fn();
|
||||
current = renderAccessory(agentTab(), onRenameTab);
|
||||
|
||||
// The fix: the menu must open as a sheet, not the default popover that stacks a
|
||||
// fragile second native Modal over the sessions bottom sheet.
|
||||
expect(capturedRootProps).toContainEqual({ compactMode: "sheet" });
|
||||
expect(capturedContentProps[0]?.sheetTitle).toBe("My session");
|
||||
expect(capturedContentProps[0]?.testID).toBe("workspace-tab-menu-agent_123");
|
||||
});
|
||||
|
||||
it("reaches Rename for an agent session", () => {
|
||||
const onRenameTab = vi.fn();
|
||||
const rendered = renderAccessory(agentTab(), onRenameTab);
|
||||
current = rendered;
|
||||
|
||||
expect(document.querySelector(`[data-testid="${rendered.base}-trigger"]`)).not.toBeNull();
|
||||
const renameButton = document.querySelector(`[data-testid="${rendered.base}-rename"]`);
|
||||
expect(renameButton).not.toBeNull();
|
||||
fireEvent.click(renameButton as HTMLElement);
|
||||
|
||||
expect(onRenameTab).toHaveBeenCalledTimes(1);
|
||||
expect(onRenameTab).toHaveBeenCalledWith(agentTab());
|
||||
});
|
||||
|
||||
it("reaches Rename for a terminal session", () => {
|
||||
const onRenameTab = vi.fn();
|
||||
const rendered = renderAccessory(terminalTab(), onRenameTab);
|
||||
current = rendered;
|
||||
|
||||
const renameButton = document.querySelector(`[data-testid="${rendered.base}-rename"]`);
|
||||
expect(renameButton).not.toBeNull();
|
||||
fireEvent.click(renameButton as HTMLElement);
|
||||
|
||||
expect(onRenameTab).toHaveBeenCalledWith(terminalTab());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Copy,
|
||||
CopyX,
|
||||
Ellipsis,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) {
|
||||
return [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive,
|
||||
];
|
||||
}
|
||||
|
||||
function MobileTabDropdownMenuItem({
|
||||
entry,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
case "rotate-cw":
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
tooltip={entry.tooltip}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileTabTrailingAccessory({
|
||||
menuTestIDBase,
|
||||
presentationLabel,
|
||||
menuEntries,
|
||||
}: {
|
||||
menuTestIDBase: string;
|
||||
presentationLabel: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
|
||||
hitSlop={8}
|
||||
style={mobileTabMenuTriggerStyle}
|
||||
>
|
||||
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
sheetTitle={presentationLabel}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
mobileTabMenuTrigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user