Files
paseo/packages/app/src/components/ui/segmented-control.tsx
Mohamed Boudra 27f1f1d207 fix(app): align schedules screen shell with app conventions
Move the New schedule button out of the screen header (the only screen
using MenuHeader rightContent) into the content filter row, and match the
History screen's full-width layout instead of the 720px centered column.

Tighten the shared sm SegmentedControl and Button vertical padding so the
status switcher and button sit flush with the host-filter pill; this is a
global sm change and also affects settings and the schedule form sheet.
2026-07-02 15:41:08 +02:00

206 lines
5.4 KiB
TypeScript

import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
export interface SegmentedControlOption<T extends string> {
value: T;
label: string;
icon?: SegmentedControlIconRenderer;
disabled?: boolean;
testID?: string;
}
interface SegmentedControlProps<T extends string> {
options: SegmentedControlOption<T>[];
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onValueChange,
size = "md",
hideLabels = false,
style,
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
[containerSizeStyle, style],
);
return (
<View style={containerStyle} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<SegmentItem
key={option.value}
option={option}
isSelected={isSelected}
iconColor={iconColor}
iconSize={iconSize}
hideLabels={hideLabels}
segmentSizeStyle={segmentSizeStyle}
labelSizeStyle={labelSizeStyle}
currentValue={value}
onValueChange={onValueChange}
/>
);
})}
</View>
);
}
function SegmentItem<T extends string>({
option,
isSelected,
iconColor,
iconSize,
hideLabels,
segmentSizeStyle,
labelSizeStyle,
currentValue,
onValueChange,
}: {
option: SegmentedControlOption<T>;
isSelected: boolean;
iconColor: string;
iconSize: number;
hideLabels: boolean;
segmentSizeStyle: StyleProp<ViewStyle>;
labelSizeStyle: StyleProp<TextStyle>;
currentValue: T;
onValueChange: (value: T) => void;
}) {
const labelStyle = useMemo(
() => [styles.label, labelSizeStyle, isSelected && styles.labelSelected],
[labelSizeStyle, isSelected],
);
const handlePress = useCallback(() => {
if (!option.disabled && option.value !== currentValue) {
onValueChange(option.value);
}
}, [option.disabled, option.value, currentValue, onValueChange]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
Boolean(hovered) && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
],
[isSelected, option.disabled, segmentSizeStyle],
);
const accessibilityState = useMemo(
() => ({ selected: isSelected, disabled: option.disabled }),
[isSelected, option.disabled],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={option.disabled}
testID={option.testID}
onPress={handlePress}
style={pressableStyle}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
{hideLabels ? null : (
<Text style={labelStyle} numberOfLines={1}>
{option.label}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.lg,
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
segmentSelected: {
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
},
iconContainer: {
alignItems: "center",
justifyContent: "center",
},
label: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelSm: {
fontSize: theme.fontSize.sm,
},
labelMd: {
fontSize: theme.fontSize.base,
},
labelSelected: {
color: theme.colors.foreground,
},
}));