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.
235 lines
6.3 KiB
TypeScript
235 lines
6.3 KiB
TypeScript
import { X } from "lucide-react-native";
|
|
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 SidebarCalloutActionVariant = "primary" | "secondary";
|
|
|
|
export interface SidebarCalloutAction {
|
|
label: string;
|
|
onPress: () => void;
|
|
variant?: SidebarCalloutActionVariant;
|
|
disabled?: boolean;
|
|
testID?: string;
|
|
}
|
|
|
|
export type SidebarCalloutVariant = "default" | "success" | "error";
|
|
|
|
export interface SidebarCalloutProps {
|
|
title?: string;
|
|
description?: ReactNode;
|
|
icon?: ReactNode;
|
|
variant?: SidebarCalloutVariant;
|
|
actions?: readonly SidebarCalloutAction[];
|
|
onDismiss?: () => void;
|
|
testID?: string;
|
|
}
|
|
|
|
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
|
|
return <Text style={styles.description}>{children}</Text>;
|
|
}
|
|
|
|
export function SidebarCallout({
|
|
title,
|
|
description,
|
|
icon,
|
|
variant = "default",
|
|
actions,
|
|
onDismiss,
|
|
testID,
|
|
}: SidebarCalloutProps) {
|
|
const { theme } = useUnistyles();
|
|
const visibleActions = (actions ?? []).slice(0, 2);
|
|
const hasHeader = title != null || icon != null;
|
|
const hasDescription = description != null && description !== "";
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.container, variant === "error" ? styles.containerError : null],
|
|
[variant],
|
|
);
|
|
|
|
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" ? (
|
|
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
|
|
) : null}
|
|
{hasDescription && typeof description !== "string" ? (
|
|
<View style={styles.descriptionSlot}>{description}</View>
|
|
) : null}
|
|
|
|
{visibleActions.length > 0 ? (
|
|
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
|
{visibleActions.map((action, index) => (
|
|
<SidebarCalloutActionButton
|
|
key={action.label}
|
|
action={action}
|
|
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SidebarCalloutActionButton({
|
|
action,
|
|
testID,
|
|
}: {
|
|
action: SidebarCalloutAction;
|
|
testID?: string;
|
|
}) {
|
|
const isPrimary = action.variant === "primary";
|
|
const labelStyle = useMemo(
|
|
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
|
|
[isPrimary],
|
|
);
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [
|
|
styles.actionButton,
|
|
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
|
|
pressed ? styles.actionButtonPressed : null,
|
|
action.disabled ? styles.actionButtonDisabled : null,
|
|
],
|
|
[action.disabled, isPrimary],
|
|
);
|
|
return (
|
|
<Pressable
|
|
onPress={action.onPress}
|
|
disabled={action.disabled}
|
|
testID={testID}
|
|
accessibilityRole="button"
|
|
style={pressableStyle}
|
|
>
|
|
<Text style={labelStyle} 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,
|
|
},
|
|
}));
|