Add sidebar callout surface

This commit is contained in:
Mohamed Boudra
2026-04-23 15:19:40 +07:00
parent e21ef2fc9b
commit 576cc0b668
15 changed files with 1812 additions and 316 deletions
@@ -0,0 +1,171 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, xl: 12, "2xl": 16, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
opacity: { 50: 0.5 },
shadow: { md: {} },
colors: {
surfaceSidebar: "#111",
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#666",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff4444",
primary: "#0a84ff",
palette: { white: "#fff" },
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
X: createIcon("X"),
CheckCircle2: createIcon("CheckCircle2"),
AlertTriangle: createIcon("AlertTriangle"),
};
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { CalloutCard } from "./callout-card";
describe("CalloutCard", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("renders title and description", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
);
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).toContain("v1.2.3 is ready to install.");
});
it("renders an icon next to the title", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" icon={<span data-testid="callout-title-icon" />} />,
);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
});
it("renders one action when one is provided", () => {
const onPress = vi.fn();
act(() => {
root?.render(
<CalloutCard
description="Saved."
actions={[{ label: "Undo", onPress }]}
testID="callout"
/>,
);
});
const button = container?.querySelector(
'[data-testid="callout-action-0"]',
) as HTMLElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("Undo");
});
it("renders up to two actions", () => {
act(() => {
root?.render(
<CalloutCard
title="Update available"
description="v1 ready."
actions={[
{ label: "What's new", onPress: vi.fn() },
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
]}
testID="callout"
/>,
);
});
expect(container?.querySelector('[data-testid="callout-action-0"]')?.textContent).toContain(
"What's new",
);
expect(container?.querySelector('[data-testid="callout-action-1"]')?.textContent).toContain(
"Install & restart",
);
});
it("renders no action row when no actions are provided", () => {
act(() => {
root?.render(<CalloutCard description="Copied" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
});
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
const onDismiss = vi.fn();
act(() => {
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
});
const dismissButton = container?.querySelector(
'[data-testid="callout-dismiss"]',
) as HTMLElement | null;
expect(dismissButton).not.toBeNull();
});
it("omits the dismiss button when onDismiss is not provided", () => {
act(() => {
root?.render(<CalloutCard description="Saved" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
});
});
@@ -0,0 +1,224 @@
import { X } from "lucide-react-native";
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type CalloutActionVariant = "primary" | "secondary";
export interface CalloutAction {
label: string;
onPress: () => void;
variant?: CalloutActionVariant;
disabled?: boolean;
testID?: string;
}
export type CalloutVariant = "default" | "success" | "error";
export interface CalloutCardProps {
title?: string;
description?: ReactNode;
icon?: ReactNode;
variant?: CalloutVariant;
actions?: readonly CalloutAction[];
onDismiss?: () => void;
testID?: string;
}
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
return <Text style={styles.description}>{children}</Text>;
}
export function CalloutCard({
title,
description,
icon,
variant = "default",
actions,
onDismiss,
testID,
}: CalloutCardProps) {
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
const hasDescription = description != null && description !== "";
const containerStyle = [styles.container, variant === "error" ? styles.containerError : null];
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
<View style={styles.body}>
{hasHeader || onDismiss ? (
<View style={styles.topRow}>
{hasHeader ? (
<View style={styles.header}>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{title ? (
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
) : null}
</View>
) : null}
{onDismiss ? (
<Pressable
onPress={onDismiss}
hitSlop={8}
style={styles.dismissButton}
testID={testID ? `${testID}-dismiss` : undefined}
accessibilityLabel="Dismiss"
accessibilityRole="button"
>
{({ hovered }) => (
<X
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
</View>
) : null}
{hasDescription ? (
typeof description === "string" ? (
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : (
<View style={styles.descriptionSlot}>{description}</View>
)
) : null}
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<CalloutActionButton
key={`${action.label}-${index}`}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
/>
))}
</View>
) : null}
</View>
</View>
);
}
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
return (
<Pressable
onPress={action.onPress}
disabled={action.disabled}
testID={testID}
accessibilityRole="button"
style={({ pressed }) => [
styles.actionButton,
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
pressed ? styles.actionButtonPressed : null,
action.disabled ? styles.actionButtonDisabled : null,
]}
>
<Text
style={[
styles.actionLabel,
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
]}
numberOfLines={1}
>
{action.label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: "100%",
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
gap: theme.spacing[2],
},
containerError: {
borderTopColor: theme.colors.destructive,
},
dismissButton: {
width: 14,
height: 14,
alignItems: "center",
justifyContent: "center",
},
body: {
gap: theme.spacing[2],
},
topRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
header: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
iconSlot: {
alignItems: "center",
justifyContent: "center",
},
title: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
alignItems: "center",
justifyContent: "center",
},
actionButtonPrimary: {
backgroundColor: theme.colors.foreground,
borderColor: theme.colors.foreground,
},
actionButtonSecondary: {
backgroundColor: "transparent",
borderColor: theme.colors.border,
},
actionButtonPressed: {
opacity: 0.8,
},
actionButtonDisabled: {
opacity: 0.5,
},
actionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
actionLabelPrimary: {
color: theme.colors.surface0,
},
actionLabelSecondary: {
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,209 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const hostsState = vi.hoisted(() => ({
value: [{ serverId: "server-1", label: "Local daemon" }],
}));
const sessionState = vi.hoisted(() => ({
value: {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
},
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => hostsState.value,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
selector(sessionState.value),
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: () => "1.2.3",
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
function Harness() {
return (
<SidebarCalloutProvider>
<DaemonVersionMismatchCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root): Promise<void> {
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("DaemonVersionMismatchCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
sessionState.value = {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
};
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers a daemon mismatch sidebar callout", async () => {
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).not.toBeNull();
expect(container?.textContent).toContain("Daemon version mismatch");
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
expect(container?.textContent).toContain("This app is v1.2.3");
expect(container?.textContent).toContain(
"For the best experience, keep the daemon and client on the same version.",
);
});
it("does not register a callout when versions match", async () => {
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
it("opens host settings from the callout action", async () => {
await renderHarness(root!);
const action = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
) as HTMLElement | null;
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
});
it("persists dismissal for the current app-daemon version pair", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
});
@@ -0,0 +1,79 @@
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
type DaemonVersionMismatch = {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
};
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();
const sessions = useSessionStore((state) => state.sessions);
const appVersion = resolveAppVersion();
return useMemo(() => {
if (!appVersion) {
return [];
}
return hosts
.map((entry) => ({
serverId: entry.serverId,
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
appVersion,
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
}))
.filter((entry): entry is DaemonVersionMismatch =>
isVersionMismatch(entry.appVersion, entry.daemonVersion),
);
}, [appVersion, hosts, sessions]);
}
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
const callouts = useSidebarCallouts();
const router = useRouter();
const { appVersion, daemonVersion, label, serverId } = mismatch;
const openSettings = useStableEvent(() => {
router.navigate(buildSettingsHostRoute(serverId));
});
useEffect(() => {
return callouts.show({
id: `daemon-version-mismatch:${serverId}`,
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
priority: 300,
title: "Daemon version mismatch",
description: `${label} is running ${formatVersionWithPrefix(
daemonVersion,
)}. This app is ${formatVersionWithPrefix(
appVersion,
)}. For the best experience, keep the daemon and client on the same version.`,
variant: "error",
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
testID: `daemon-version-mismatch-callout-${serverId}`,
});
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
return null;
}
export function DaemonVersionMismatchCalloutSource() {
const mismatches = useDaemonVersionMismatches();
return (
<>
{mismatches.map((mismatch) => (
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
))}
</>
);
}
+37 -34
View File
@@ -1,66 +1,67 @@
import { router, usePathname } from "expo-router";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import {
memo,
useCallback,
useMemo,
useState,
useEffect,
useRef,
type Dispatch,
memo,
type ReactElement,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
View,
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
StyleSheet as RNStyleSheet,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import {
usePanelStore,
selectIsAgentListOpen,
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
useSidebarWorkspacesList,
type SidebarProjectEntry,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const MIN_CHAT_WIDTH = 400;
@@ -714,6 +715,8 @@ function DesktopSidebar({
/>
)}
<SidebarCalloutSlot />
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
+1
View File
@@ -0,0 +1 @@
export * from "./sidebar-callout-slot.native";
@@ -0,0 +1,3 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -0,0 +1,116 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => {}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { SidebarCalloutProvider, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
function RegisterCallout() {
const callouts = useSidebarCallouts();
useEffect(
() =>
callouts.show({
id: "slot-test",
title: "Update available",
description: "v1 is ready.",
testID: "slot-test-callout",
}),
[callouts],
);
return null;
}
describe("SidebarCalloutSlot", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("renders the active sidebar callout", async () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<RegisterCallout />
<SidebarCalloutSlot />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).toContain("v1 is ready.");
});
it("renders nothing when there is no active callout", async () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<SidebarCalloutSlot />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
expect(container?.querySelector('[data-testid="slot-test-callout"]')).toBeNull();
});
});
@@ -0,0 +1,17 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
export function SidebarCalloutSlot() {
return (
<View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport />
</View>
);
}
const styles = StyleSheet.create(() => ({
slot: {
width: "100%",
},
}));