Files
CBS-Mobile-App/src/components/InfoBanner/index.tsx
T

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);