forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}