165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import React, { memo } from 'react';
|
|
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
import Ionicons from '@react-native-vector-icons/ionicons';
|
|
|
|
import { useTheme } from '../../theme';
|
|
|
|
type InfoBannerTone = 'info' | 'warning' | 'danger' | 'success' | 'brand';
|
|
|
|
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
|
|
|
interface InfoBannerProps {
|
|
/** Optional — a one-line banner reads better without one. */
|
|
title?: string;
|
|
message: string;
|
|
tone?: InfoBannerTone;
|
|
/** Overrides the tone's default icon. */
|
|
icon?: IoniconName;
|
|
/** Renders a text action beneath the message. */
|
|
actionLabel?: string;
|
|
onActionPress?: () => void;
|
|
style?: StyleProp<ViewStyle>;
|
|
}
|
|
|
|
/**
|
|
* An inline notice — the thing to reach for instead of a toast when the message
|
|
* has to stay on screen while the customer fixes something.
|
|
*
|
|
* The tone is carried by an icon as well as a colour, because a red border
|
|
* alone tells a colour-blind customer nothing, and tells an agent holding the
|
|
* phone in direct sunlight even less.
|
|
*/
|
|
const DEFAULT_ICON: Record<InfoBannerTone, IoniconName> = {
|
|
info: 'information-circle',
|
|
warning: 'warning',
|
|
danger: 'alert-circle',
|
|
success: 'checkmark-circle',
|
|
brand: 'shield-checkmark',
|
|
};
|
|
|
|
const InfoBannerComponent = ({
|
|
title,
|
|
message,
|
|
tone = 'info',
|
|
icon,
|
|
actionLabel,
|
|
onActionPress,
|
|
style,
|
|
}: InfoBannerProps) => {
|
|
const theme = useTheme();
|
|
|
|
const palette = ((): { fg: string; bg: string; border: string } => {
|
|
switch (tone) {
|
|
case 'warning':
|
|
return {
|
|
fg: theme.colors.warning,
|
|
bg: theme.colors.warningSurface,
|
|
border: theme.colors.warningBorder,
|
|
};
|
|
case 'danger':
|
|
return {
|
|
fg: theme.colors.danger,
|
|
bg: theme.colors.dangerSurface,
|
|
border: theme.colors.dangerBorder,
|
|
};
|
|
case 'success':
|
|
return {
|
|
fg: theme.colors.success,
|
|
bg: theme.colors.successSurface,
|
|
border: theme.colors.successBorder,
|
|
};
|
|
case 'brand':
|
|
return {
|
|
fg: theme.colors.primary,
|
|
bg: theme.colors.primarySurface,
|
|
border: theme.colors.primaryBorder,
|
|
};
|
|
case 'info':
|
|
default:
|
|
return {
|
|
fg: theme.colors.info,
|
|
bg: theme.colors.infoSurface,
|
|
border: theme.colors.infoBorder,
|
|
};
|
|
}
|
|
})();
|
|
|
|
return (
|
|
<View
|
|
// Announced when it appears, which is the point of an inline notice that
|
|
// replaces a toast.
|
|
accessibilityRole="alert"
|
|
accessibilityLabel={title ? `${title}. ${message}` : message}
|
|
style={[
|
|
styles.banner,
|
|
{
|
|
backgroundColor: palette.bg,
|
|
borderColor: palette.border,
|
|
borderRadius: theme.radius.md,
|
|
padding: theme.spacing.sm,
|
|
gap: theme.spacing.sm,
|
|
},
|
|
style,
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={icon ?? DEFAULT_ICON[tone]}
|
|
size={18}
|
|
color={palette.fg}
|
|
style={styles.icon}
|
|
/>
|
|
|
|
<View style={styles.body}>
|
|
{title ? (
|
|
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>{title}</Text>
|
|
) : null}
|
|
|
|
{/* The message stays in secondary ink rather than the tone colour.
|
|
Tinting a whole paragraph is what makes these banners shout — the
|
|
icon and the tinted surface already carry the tone. */}
|
|
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>{message}</Text>
|
|
|
|
{actionLabel && onActionPress ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={actionLabel}
|
|
onPress={onActionPress}
|
|
// A text link inside a banner is the easiest place to ship a 20pt
|
|
// target by accident; the hitSlop is what brings it to 44.
|
|
hitSlop={12}
|
|
style={styles.action}
|
|
>
|
|
<Text style={[theme.text.button, { color: palette.fg }]}>{actionLabel}</Text>
|
|
<Ionicons name="chevron-forward" size={15} color={palette.fg} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
banner: {
|
|
flexDirection: 'row',
|
|
borderWidth: 1,
|
|
},
|
|
icon: {
|
|
// Optically aligns the icon with the first line of text rather than with
|
|
// the centre of a paragraph that may run to four lines.
|
|
marginTop: 1,
|
|
},
|
|
body: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
action: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
marginTop: 6,
|
|
minHeight: 24,
|
|
},
|
|
});
|
|
|
|
export const InfoBanner = memo(InfoBannerComponent);
|