mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
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.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
description: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
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.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
actionLabelPrimary: {
|
|
color: theme.colors.surface0,
|
|
},
|
|
actionLabelSecondary: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|