refactor: split sidebar callout from generic Alert primitive

Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and
add a shadcn-style Alert primitive with info/success/warning/error variants
for inline use in screens. Replace the misused SidebarCallout in project
settings with Alert and surface the underlying RPC error message instead
of generic copy. Tune destructive color per theme to a calm tinted red.
This commit is contained in:
Mohamed Boudra
2026-04-26 13:16:24 +07:00
parent 88ea88162c
commit 291a124663
10 changed files with 296 additions and 170 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -365,7 +365,7 @@ function FooterIconButton({
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof Plus;
icon: typeof FolderPlus;
theme: SidebarTheme;
}) {
return (
@@ -448,7 +448,7 @@ function SidebarFooter({
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
icon={Plus}
icon={FolderPlus}
theme={theme}
/>
</TooltipTrigger>
@@ -55,15 +55,15 @@ vi.mock("lucide-react-native", () => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { CalloutCard } from "./callout-card";
import { SidebarCallout } from "./sidebar-callout";
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
type SidebarCalloutActions = React.ComponentProps<typeof SidebarCallout>["actions"];
function buildSingleAction(onPress: () => void): CalloutCardActions {
function buildSingleAction(onPress: () => void): SidebarCalloutActions {
return [{ label: "Undo", onPress }];
}
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): SidebarCalloutActions {
return [
{ label: "What's new", onPress: onWhatsNew },
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
@@ -72,7 +72,7 @@ function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): Callout
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
describe("CalloutCard", () => {
describe("SidebarCallout", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -96,7 +96,7 @@ describe("CalloutCard", () => {
it("renders title and description", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
<SidebarCallout title="Update available" description="v1.2.3 is ready to install." />,
);
});
@@ -106,7 +106,7 @@ describe("CalloutCard", () => {
it("renders an icon next to the title", () => {
act(() => {
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
root?.render(<SidebarCallout title="Update available" icon={calloutTitleIcon} />);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
@@ -116,7 +116,7 @@ describe("CalloutCard", () => {
const onPress = vi.fn();
const actions = buildSingleAction(onPress);
act(() => {
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
root?.render(<SidebarCallout description="Saved." actions={actions} testID="callout" />);
});
const button = container?.querySelector(
@@ -130,7 +130,7 @@ describe("CalloutCard", () => {
const actions = buildTwoActions(vi.fn(), vi.fn());
act(() => {
root?.render(
<CalloutCard
<SidebarCallout
title="Update available"
description="v1 ready."
actions={actions}
@@ -149,7 +149,7 @@ describe("CalloutCard", () => {
it("renders no action row when no actions are provided", () => {
act(() => {
root?.render(<CalloutCard description="Copied" testID="callout" />);
root?.render(<SidebarCallout description="Copied" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
@@ -158,7 +158,7 @@ describe("CalloutCard", () => {
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" />);
root?.render(<SidebarCallout description="Saved" onDismiss={onDismiss} testID="callout" />);
});
const dismissButton = container?.querySelector(
@@ -169,7 +169,7 @@ describe("CalloutCard", () => {
it("omits the dismiss button when onDismiss is not provided", () => {
act(() => {
root?.render(<CalloutCard description="Saved" testID="callout" />);
root?.render(<SidebarCallout description="Saved" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
@@ -3,33 +3,33 @@ import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type CalloutActionVariant = "primary" | "secondary";
export type SidebarCalloutActionVariant = "primary" | "secondary";
export interface CalloutAction {
export interface SidebarCalloutAction {
label: string;
onPress: () => void;
variant?: CalloutActionVariant;
variant?: SidebarCalloutActionVariant;
disabled?: boolean;
testID?: string;
}
export type CalloutVariant = "default" | "success" | "error";
export type SidebarCalloutVariant = "default" | "success" | "error";
export interface CalloutCardProps {
export interface SidebarCalloutProps {
title?: string;
description?: ReactNode;
icon?: ReactNode;
variant?: CalloutVariant;
actions?: readonly CalloutAction[];
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
onDismiss?: () => void;
testID?: string;
}
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
return <Text style={styles.description}>{children}</Text>;
}
export function CalloutCard({
export function SidebarCallout({
title,
description,
icon,
@@ -37,7 +37,7 @@ export function CalloutCard({
actions,
onDismiss,
testID,
}: CalloutCardProps) {
}: SidebarCalloutProps) {
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
@@ -84,7 +84,7 @@ export function CalloutCard({
) : null}
{hasDescription && typeof description === "string" ? (
<CalloutDescriptionText>{description}</CalloutDescriptionText>
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
@@ -93,7 +93,7 @@ export function CalloutCard({
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<CalloutActionButton
<SidebarCalloutActionButton
key={action.label}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
@@ -106,7 +106,13 @@ export function CalloutCard({
);
}
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
function SidebarCalloutActionButton({
action,
testID,
}: {
action: SidebarCalloutAction;
testID?: string;
}) {
const isPrimary = action.variant === "primary";
const labelStyle = useMemo(
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
+121
View File
@@ -0,0 +1,121 @@
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "lucide-react-native";
import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export interface AlertProps {
title?: string;
description?: ReactNode;
variant?: AlertVariant;
icon?: ReactNode;
children?: ReactNode;
testID?: string;
}
const VARIANT_ICON: Record<Exclude<AlertVariant, "default">, LucideIcon> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
};
export function Alert({
title,
description,
variant = "default",
icon,
children,
testID,
}: AlertProps) {
const { theme } = useUnistyles();
const accentColor = resolveAccentColor(variant, theme);
const containerStyle = useMemo(
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
[accentColor],
);
const titleStyle = useMemo(
() => [styles.title, accentColor ? { color: accentColor } : null],
[accentColor],
);
const resolvedIcon = useMemo(() => {
if (icon !== undefined) return icon;
if (variant === "default") return null;
const Icon = VARIANT_ICON[variant];
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
}, [icon, variant, theme, accentColor]);
const hasDescription = description != null && description !== "";
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
<View style={styles.body}>
{title ? <Text style={titleStyle}>{title}</Text> : null}
{hasDescription && typeof description === "string" ? (
<Text style={styles.description}>{description}</Text>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
) : null}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
</View>
);
}
function resolveAccentColor(
variant: AlertVariant,
theme: ReturnType<typeof useUnistyles>["theme"],
): string | null {
if (variant === "info") return theme.colors.palette.blue[300];
if (variant === "success") return theme.colors.palette.green[400];
if (variant === "warning") return theme.colors.palette.amber[500];
if (variant === "error") return theme.colors.destructive;
return null;
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
borderRadius: theme.borderRadius.xl,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
iconSlot: {
paddingTop: 2,
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
}));