Files
dt-design-system/packages/react-native/src/components/DTQuantityStepper.tsx
2026-03-03 17:59:56 -08:00

235 lines
5.3 KiB
TypeScript

/**
* DT QuantityStepper Component
*
* Custom-built quantity increment/decrement control following the
* Dangerous Things design language. No RNP equivalent exists.
*
* Web CSS reference (AddOnOptionItem quantity controls):
* - Plus/minus buttons with beveled corners
* - Quantity display between buttons
*/
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath} from '../utils/bevelPaths';
type DTStepperSize = 'small' | 'medium' | 'large';
interface SizeConfig {
buttonSize: number;
fontSize: number;
bevelSize: number;
iconStrokeWidth: number;
minWidth: number;
}
const sizeConfigs: Record<DTStepperSize, SizeConfig> = {
small: {
buttonSize: 28,
fontSize: 12,
bevelSize: 4,
iconStrokeWidth: 1.5,
minWidth: 28,
},
medium: {
buttonSize: 36,
fontSize: 16,
bevelSize: 6,
iconStrokeWidth: 2,
minWidth: 36,
},
large: {
buttonSize: 44,
fontSize: 20,
bevelSize: 8,
iconStrokeWidth: 2.5,
minWidth: 44,
},
};
interface DTQuantityStepperProps {
/**
* Current quantity value
*/
value: number;
/**
* Called when value changes
*/
onValueChange: (value: number) => void;
/**
* Minimum value
* @default 0
*/
min?: number;
/**
* Maximum value
* @default 99
*/
max?: number;
/**
* Step increment
* @default 1
*/
step?: number;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Whether the stepper is disabled
*/
disabled?: boolean;
/**
* Label text displayed above the stepper
*/
label?: string;
/**
* Size preset
* @default 'medium'
*/
size?: DTStepperSize;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled QuantityStepper with beveled +/- buttons
*
* @example
* <DTQuantityStepper value={qty} onValueChange={setQty} label="Quantity" />
*
* @example
* <DTQuantityStepper value={qty} onValueChange={setQty} min={1} max={10} variant="emphasis" />
*/
export function DTQuantityStepper({
value,
onValueChange,
min = 0,
max = 99,
step = 1,
variant = 'normal',
disabled = false,
label,
size = 'medium',
color,
style,
}: DTQuantityStepperProps) {
const accentColor = getVariantColor(variant, color);
const config = sizeConfigs[size];
const atMin = value <= min;
const atMax = value >= max;
const borderWidth = 2;
const handleDecrement = () => {
const next = Math.max(min, value - step);
onValueChange(next);
};
const handleIncrement = () => {
const next = Math.min(max, value + step);
onValueChange(next);
};
const renderStepButton = (
type: 'minus' | 'plus',
onPress: () => void,
isDisabled: boolean,
) => {
const s = config.buttonSize;
const center = s / 2;
const iconSize = s * 0.35;
const sw = config.iconStrokeWidth;
// Minus: horizontal line; Plus: cross
const iconPath =
type === 'minus'
? `M ${center - iconSize} ${center} L ${center + iconSize} ${center}`
: `M ${center - iconSize} ${center} L ${center + iconSize} ${center} M ${center} ${center - iconSize} L ${center} ${center + iconSize}`;
// Expand touch target to at least 44px
const hitSlopSize = Math.max(0, (44 - s) / 2);
const hitSlop = hitSlopSize > 0 ? hitSlopSize : undefined;
return (
<Pressable
onPress={onPress}
disabled={disabled || isDisabled}
hitSlop={hitSlop}
style={({pressed}) => ({
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
})}>
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<Path
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
fill="transparent"
stroke={accentColor}
strokeWidth={borderWidth}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={sw}
strokeLinecap="round"
/>
</Svg>
</Pressable>
);
};
return (
<View style={[styles.wrapper, style]}>
{label && (
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
)}
<View style={styles.container}>
{renderStepButton('minus', handleDecrement, atMin)}
<View style={[styles.valueContainer, {minWidth: config.minWidth}]}>
<Text
style={[
styles.valueText,
{fontSize: config.fontSize, color: accentColor},
]}>
{value}
</Text>
</View>
{renderStepButton('plus', handleIncrement, atMax)}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: 8,
},
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
label: {
fontSize: 14,
letterSpacing: 0.3,
},
valueContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
},
valueText: {
fontWeight: '700',
letterSpacing: 0.5,
},
});