Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
237 lines
6.4 KiB
TypeScript
237 lines
6.4 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";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
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 { t } = useTranslation();
|
|
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={t("sidebarCallout.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,
|
|
},
|
|
}));
|