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

182 lines
5.8 KiB
TypeScript

import React, { memo } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
interface StepperProps {
/** Two to four short labels. Longer than four and they stop fitting a 360pt phone. */
steps: readonly string[];
/** Zero-based. Steps before it are done, steps after it are still to come. */
current: number;
/**
* Set when this sits on the navy brand wash rather than a light surface.
*/
onBrand?: boolean;
style?: StyleProp<ViewStyle>;
}
/**
* Where the customer is in a flow, and what is still ahead of them.
*
* Registration is three screens — choose a SIM, enter the code, set an MPIN —
* and until this existed the first of those gave no hint that two more were
* coming. Someone deciding whether to hand over their number is entitled to
* know how long the process is before they start it.
*
* A completed step is marked with a tick as well as a fill, so progress is not
* carried by colour alone. The whole thing is inert: it reports position, it is
* not a way to navigate, and steps are not tappable — you cannot jump to an OTP
* screen before an OTP has been sent.
*/
const StepperComponent = ({ steps, current, onBrand = false, style }: StepperProps) => {
const theme = useTheme();
/**
* The connector, and the outline of a step not yet reached.
*
* On brand this is a white at 0.55 rather than the shared `onBrandBorder`
* (0.24). A border token is measured for a panel edge, which sits next to a
* fill; a hairline on the open wash has nothing beside it and 0.24 came to
* 1.75:1 — under the 3:1 that non-text contrast asks for. At 0.55 it is
* 3.34:1 against the lightest point of the gradient and, at one point tall,
* still reads as a rule rather than a bar.
*
* On a light surface it stays a border tone. The rail is reinforcement in
* either case: sequence is carried by the numbers, and completion by the fill
* and the tick, so a reader who cannot make out the line loses nothing.
*/
const railColour = onBrand ? 'rgba(255, 255, 255, 0.55)' : theme.colors.cardBorderStrong;
const doneColour = onBrand ? theme.colors.onBrandPrimary : theme.colors.primary;
return (
<View
accessibilityRole="progressbar"
accessibilityLabel={`Step ${current + 1} of ${steps.length}: ${steps[current] ?? ''}`}
style={[styles.container, style]}
>
{steps.map((label, index) => {
const done = index < current;
const active = index === current;
/**
* A done or active marker is a solid fill, so the number and tick on it
* are the solid tone — the translucent on-brand tiers are measured
* against the bare wash and lose their contrast over a fill.
*/
const markerFill = done || active ? doneColour : 'transparent';
const markerBorder = done || active ? doneColour : railColour;
const markerInk = done || active
? onBrand
? theme.colors.primary
: theme.colors.textOnBrand
: onBrand
? theme.colors.onBrandSecondary
: theme.colors.textMuted;
const labelColour = active
? onBrand
? theme.colors.onBrandPrimary
: theme.colors.textPrimary
: onBrand
? theme.colors.onBrandSecondary
: theme.colors.textMuted;
return (
<View key={label} style={styles.step}>
{/* The rails are halves either side of the marker, each flexing to
fill its own gap. Drawing one line behind the whole row instead
would need an absolute overlay measured against the markers,
which drifts as soon as a label wraps. */}
<View style={styles.railRow}>
<View
style={[
styles.rail,
{ backgroundColor: index === 0 ? 'transparent' : railColour },
]}
/>
<View
style={[
styles.marker,
{ backgroundColor: markerFill, borderColor: markerBorder },
]}
>
{done ? (
<Ionicons name="checkmark" size={13} color={markerInk} />
) : (
<Text style={[theme.text.eyebrow, styles.markerText, { color: markerInk }]}>
{index + 1}
</Text>
)}
</View>
<View
style={[
styles.rail,
{
backgroundColor: index === steps.length - 1 ? 'transparent' : railColour,
},
]}
/>
</View>
<Text
numberOfLines={2}
style={[
active ? theme.text.bodyStrong : theme.text.bodySmall,
styles.label,
{ color: labelColour },
]}
>
{label}
</Text>
</View>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
},
step: {
flex: 1,
alignItems: 'center',
gap: 7,
},
railRow: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch',
},
rail: {
flex: 1,
height: 1,
},
marker: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
markerText: {
fontSize: 11,
lineHeight: 14,
letterSpacing: 0,
},
label: {
fontSize: 12,
lineHeight: 16,
textAlign: 'center',
paddingHorizontal: 2,
},
});
export const Stepper = memo(StepperComponent);