Bluesky app fork with some witchin' additions 馃挮
at main 287 lines 7.1 kB view raw
1import { 2 createContext, 3 useCallback, 4 useContext, 5 useLayoutEffect, 6 useMemo, 7 useState, 8} from 'react' 9import {type StyleProp, View, type ViewStyle} from 'react-native' 10import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 11 12import {useHaptics} from '#/lib/haptics' 13import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 14import {atoms as a, native, platform, useTheme} from '#/alf' 15import { 16 Button, 17 type ButtonProps, 18 ButtonText, 19 type ButtonTextProps, 20} from '../Button' 21 22const InternalContext = createContext<{ 23 type: 'tabs' | 'radio' 24 size: 'small' | 'large' 25 selectedValue: string 26 selectedPosition: {width: number; x: number} | null 27 onSelectValue: ( 28 value: string, 29 position: {width: number; x: number} | null, 30 ) => void 31 updatePosition: (position: {width: number; x: number}) => void 32} | null>(null) 33 34/** 35 * Segmented control component. 36 * 37 * @example 38 * ```tsx 39 * <SegmentedControl.Root value={value} onChange={setValue}> 40 * <SegmentedControl.Item value="one"> 41 * <SegmentedControl.ItemText value="one"> 42 * One 43 * </SegmentedControl.ItemText> 44 * </SegmentedControl.Item> 45 * <SegmentedControl.Item value="two"> 46 * <SegmentedControl.ItemText value="two"> 47 * Two 48 * </SegmentedControl.ItemText> 49 * </SegmentedControl.Item> 50 * </SegmentedControl.Root> 51 * ``` 52 */ 53export function Root<T extends string>({ 54 label, 55 type = 'radio', 56 size = 'large', 57 value, 58 onChange, 59 children, 60 style, 61 accessibilityHint, 62}: { 63 label: string 64 type: 'tabs' | 'radio' 65 size?: 'small' | 'large' 66 value: T 67 onChange: (value: T) => void 68 children: React.ReactNode 69 style?: StyleProp<ViewStyle> 70 accessibilityHint?: string 71}) { 72 const t = useTheme() 73 const [selectedPosition, setSelectedPosition] = useState<{ 74 width: number 75 x: number 76 } | null>(null) 77 78 const contextValue = useMemo(() => { 79 return { 80 type, 81 size, 82 selectedValue: value, 83 selectedPosition, 84 onSelectValue: ( 85 val: string, 86 position: {width: number; x: number} | null, 87 ) => { 88 onChange(val as T) 89 if (position) setSelectedPosition(position) 90 }, 91 updatePosition: (position: {width: number; x: number}) => { 92 setSelectedPosition(currPos => { 93 if ( 94 currPos && 95 currPos.width === position.width && 96 currPos.x === position.x 97 ) { 98 return currPos 99 } 100 return position 101 }) 102 }, 103 } 104 }, [value, selectedPosition, setSelectedPosition, onChange, type, size]) 105 106 return ( 107 <View 108 accessibilityLabel={label} 109 accessibilityHint={accessibilityHint ?? ''} 110 style={[ 111 a.w_full, 112 a.flex_1, 113 a.relative, 114 a.flex_row, 115 t.atoms.bg_contrast_50, 116 {borderRadius: 14}, 117 a.curve_continuous, 118 a.p_xs, 119 style, 120 ]} 121 role={type === 'tabs' ? 'tablist' : 'radiogroup'}> 122 {selectedPosition !== null && ( 123 <Slider x={selectedPosition.x} width={selectedPosition.width} /> 124 )} 125 <InternalContext.Provider value={contextValue}> 126 {children} 127 </InternalContext.Provider> 128 </View> 129 ) 130} 131 132const InternalItemContext = createContext<{ 133 active: boolean 134 pressed: boolean 135 hovered: boolean 136 focused: boolean 137} | null>(null) 138 139export function Item({ 140 value, 141 style, 142 children, 143 onPress: onPressProp, 144 ...props 145}: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) { 146 const playHaptic = useHaptics() 147 const [position, setPosition] = useState<{x: number; width: number} | null>( 148 null, 149 ) 150 151 const ctx = useContext(InternalContext) 152 if (!ctx) 153 throw new Error( 154 'SegmentedControl.Item must be used within a SegmentedControl.Root', 155 ) 156 157 const active = ctx.selectedValue === value 158 159 // update position if change was external, and not due to onPress 160 const needsUpdate = 161 active && 162 position && 163 (ctx.selectedPosition?.x !== position.x || 164 ctx.selectedPosition?.width !== position.width) 165 166 // can't wait for `useEffectEvent` 167 const update = useNonReactiveCallback(() => { 168 if (position) ctx.updatePosition(position) 169 }) 170 171 useLayoutEffect(() => { 172 if (needsUpdate) { 173 update() 174 } 175 }, [needsUpdate, update]) 176 177 const onPress = useCallback( 178 (evt: any) => { 179 playHaptic('Light') 180 ctx.onSelectValue(value, position) 181 onPressProp?.(evt) 182 }, 183 [ctx, value, position, onPressProp, playHaptic], 184 ) 185 186 return ( 187 <View 188 style={[a.flex_1, a.flex_row]} 189 onLayout={evt => { 190 const measuredPosition = { 191 x: evt.nativeEvent.layout.x, 192 width: evt.nativeEvent.layout.width, 193 } 194 if (!ctx.selectedPosition && active) { 195 ctx.onSelectValue(value, measuredPosition) 196 } 197 setPosition(measuredPosition) 198 }}> 199 <Button 200 {...props} 201 onPress={onPress} 202 role={ctx.type === 'tabs' ? 'tab' : 'radio'} 203 accessibilityState={{selected: active}} 204 style={[ 205 a.flex_1, 206 a.bg_transparent, 207 a.px_sm, 208 a.py_xs, 209 {minHeight: ctx.size === 'large' ? 40 : 32}, 210 style, 211 ]}> 212 {({pressed, hovered, focused}) => ( 213 <InternalItemContext.Provider 214 value={{active, pressed, hovered, focused}}> 215 {children} 216 </InternalItemContext.Provider> 217 )} 218 </Button> 219 </View> 220 ) 221} 222 223export function ItemText({style, ...props}: ButtonTextProps) { 224 const t = useTheme() 225 const ctx = useContext(InternalItemContext) 226 if (!ctx) 227 throw new Error( 228 'SegmentedControl.ItemText must be used within a SegmentedControl.Item', 229 ) 230 return ( 231 <ButtonText 232 {...props} 233 style={[ 234 a.text_center, 235 a.text_md, 236 a.font_medium, 237 a.px_xs, 238 ctx.active 239 ? t.atoms.text 240 : ctx.focused || ctx.hovered || ctx.pressed 241 ? t.atoms.text_contrast_medium 242 : t.atoms.text_contrast_low, 243 style, 244 ]} 245 /> 246 ) 247} 248 249function Slider({x, width}: {x: number; width: number}) { 250 const t = useTheme() 251 252 return ( 253 <Animated.View 254 layout={native(LinearTransition.easing(Easing.out(Easing.exp)))} 255 style={[ 256 a.absolute, 257 a.curve_continuous, 258 t.atoms.bg, 259 { 260 top: 4, 261 bottom: 4, 262 left: 0, 263 width, 264 borderRadius: 10, 265 }, 266 // TODO: new arch supports boxShadow on native 267 // in the meantime this is an attempt to get close 268 platform({ 269 web: { 270 boxShadow: '0px 2px 4px 0px #0000000D', 271 }, 272 ios: { 273 shadowColor: '#000', 274 shadowOffset: {width: 0, height: 2}, 275 shadowOpacity: 0x0d / 0xff, 276 shadowRadius: 4, 277 }, 278 android: {elevation: 0.25}, 279 }), 280 platform({ 281 native: [{left: x}], 282 web: [{transform: [{translateX: x}]}, a.transition_transform], 283 }), 284 ]} 285 /> 286 ) 287}