import { useEffect, useRef, useState } from 'react' import { Pressable, ScrollView, Text, View, type ScrollViewInstance, type StyleProp, type ViewStyle } from 'react-native' import { triggerSelection } from '../platform/haptics/haptics' import { resolveTabStripScrollOffset } from '../session/tabs/tab-strip-scroll' import { colors, concentricRadius, feedback, radii, spacing } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' export type MobileSegment = { value: Value label: string /** A longer spoken name where the short label leans on its surroundings. */ accessibilityLabel?: string } type Props = { segments: readonly MobileSegment[] /** A value outside the segments selects none, e.g. one a newer host sent. */ selected: string | undefined onSelect: (value: Value) => void /** Prefixes each segment's testID with this, then the segment's value. */ testIDPrefix?: string /** Segments share the full width instead of sizing to their labels. */ fill?: boolean /** Like `fill`, but labels too long for an equal share widen their segment and the track scrolls. */ scrollWhenCrowded?: boolean /** Paints the track like a sheet's grouped rows, so it stands out from the sheet. */ onSheet?: boolean /** The track; its default color suits a panel-colored row. */ style?: StyleProp /** Shown but not pickable, e.g. while the value it sets is locked. */ disabled?: boolean /** The segments switch the content below, so screen readers hear tabs instead of radio buttons. */ tabs?: boolean } /** A pick-one pill control; each segment reads as a radio button, or as a tab with `tabs`. */ export function MobileSegmentedControl(props: Props) { useMobileColorScheme() const [trackWidth, setTrackWidth] = useState(0) const scrollRef = useRef(null) const scrollX = useRef(0) const [widths] = useState(() => new Map()) const revealSelected = (): void => { if (!scrollRef.current || trackWidth === 0) { return } const span = segmentSpan(props.segments, widths, props.selected) if (span) { const x = resolveTabStripScrollOffset({ ...span, viewportWidth: trackWidth, currentOffset: scrollX.current, margin: TRACK_INSET }) scrollRef.current.scrollTo({ x, animated: false }) } } const segmentValues = props.segments.map((segment) => segment.value).join('\n') // Not on every render: a re-render must not undo the user's own scroll. useEffect(revealSelected, [props.selected, trackWidth, segmentValues]) // Equal shares that exactly fill the track while every label fits. const equalShare = props.scrollWhenCrowded && trackWidth > 0 && { minWidth: Math.floor((trackWidth - 2 * TRACK_INSET) / props.segments.length) } const accessibilityRole = props.tabs ? 'tablist' : 'radiogroup' const segments = props.segments.map((segment) => { const active = segment.value === props.selected return ( { // Widths, not x: web reports only size changes, so a shifted segment's x goes stale. widths.set(segment.value, event.nativeEvent.layout.width) revealSelected() } : undefined } accessibilityRole={props.tabs ? 'tab' : 'radio'} aria-checked={props.tabs ? undefined : active} accessibilityState={{ disabled: props.disabled === true, ...(props.tabs ? { selected: active } : {}) }} disabled={props.disabled} accessibilityLabel={segment.accessibilityLabel ?? segment.label} testID={props.testIDPrefix ? `${props.testIDPrefix}${segment.value}` : undefined} onPress={() => { if (!active) { triggerSelection() } props.onSelect(segment.value) }} // Stretches the 34 pt segment to a 44 pt touch box. hitSlop={{ top: 5, bottom: 5 }} style={({ pressed }) => [ styles.segment, props.fill && !props.scrollWhenCrowded && styles.segmentFill, props.scrollWhenCrowded && styles.segmentCrowdable, equalShare, active && (props.onSheet ? styles.segmentActiveOnSheet : styles.segmentActive), pressed && !active && styles.segmentPressed ]} > {segment.label} ) }) const sheetStyle = props.onSheet && styles.segmentedOnSheet if (!props.scrollWhenCrowded) { return ( {segments} ) } return ( setTrackWidth(event.nativeEvent.layout.width)} onScroll={(event) => { scrollX.current = event.nativeEvent.contentOffset.x }} scrollEventThrottle={16} > {segments} ) } const TRACK_INSET = 3 /** Where the selected segment sits in the scrolled content, once every segment has a width. */ function segmentSpan( segments: readonly MobileSegment[], widths: ReadonlyMap, selected: string | undefined ): { tabX: number; tabWidth: number; contentWidth: number } | undefined { let left = TRACK_INSET let span: { tabX: number; tabWidth: number } | undefined for (const segment of segments) { const width = widths.get(segment.value) if (width === undefined) { return undefined } if (segment.value === selected) { span = { tabX: left, tabWidth: width } } left += width } return span && { ...span, contentWidth: left + TRACK_INSET } } const styles = themedStyleSheet(() => ({ segmented: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.bgBase, borderRadius: radii.control, padding: TRACK_INSET }, // Room above and below the label, so it doesn't sit against the track's edges. segment: { minHeight: 34, justifyContent: 'center', paddingHorizontal: spacing.md, borderRadius: concentricRadius(radii.control, TRACK_INSET) }, segmentFill: { flex: 1, alignItems: 'center', paddingHorizontal: spacing.xs }, segmentCrowdable: { flexGrow: 1, alignItems: 'center', paddingHorizontal: spacing.xs }, // The inset sits on the content, so segments scroll out under the track's rounded edge. scrollTrack: { flexGrow: 0, backgroundColor: colors.bgBase, borderRadius: radii.control }, scrollContent: { flexGrow: 1, alignItems: 'center', padding: TRACK_INSET }, segmentedOnSheet: { backgroundColor: colors.sheetGroup }, segmentActive: { backgroundColor: colors.bgRaised }, // bgRaised is the sheet group's own color in dark; the switch's grey fill lifts off it in both themes. segmentActiveOnSheet: { backgroundColor: colors.translucentFill }, segmentText: { fontSize: 13, color: colors.textSecondary, fontWeight: '600' }, segmentTextActive: { color: colors.textPrimary }, segmentPressed: { opacity: feedback.pressedOpacity } }))