182 lines
5.8 KiB
TypeScript
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);
|