forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useRef} from 'react'
2import {type ScrollView, StyleSheet, View} from 'react-native'
3
4import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
5import {Text} from '#/components/Typography'
6import {PressableWithHover} from '../util/PressableWithHover'
7import {DraggableScrollView} from './DraggableScrollView'
8
9export interface TabBarProps {
10 testID?: string
11 selectedPage: number
12 items: string[]
13 indicatorColor?: string
14 backgroundColor?: string
15
16 onSelect?: (index: number) => void
17 onPressSelected?: (index: number) => void
18}
19
20// How much of the previous/next item we're showing
21// to give the user a hint there's more to scroll.
22const OFFSCREEN_ITEM_WIDTH = 20
23
24export function TabBar({
25 testID,
26 selectedPage,
27 items,
28 onSelect,
29 onPressSelected,
30}: TabBarProps) {
31 const t = useTheme()
32 const scrollElRef = useRef<ScrollView>(null)
33 const itemRefs = useRef<Array<Element>>([])
34 const {gtMobile} = useBreakpoints()
35 const styles = gtMobile ? desktopStyles : mobileStyles
36
37 useEffect(() => {
38 // On the web, the primary interaction is tapping.
39 // Scrolling under tap feels disorienting so only adjust the scroll offset
40 // when tapping on an item out of view--and we adjust by almost an entire page.
41 const parent = scrollElRef?.current?.getScrollableNode?.()
42 if (!parent) {
43 return
44 }
45 const parentRect = parent.getBoundingClientRect()
46 if (!parentRect) {
47 return
48 }
49 const {
50 left: parentLeft,
51 right: parentRight,
52 width: parentWidth,
53 } = parentRect
54 const child = itemRefs.current[selectedPage]
55 if (!child) {
56 return
57 }
58 const childRect = child.getBoundingClientRect?.()
59 if (!childRect) {
60 return
61 }
62 const {left: childLeft, right: childRight, width: childWidth} = childRect
63 let dx = 0
64 if (childRight >= parentRight) {
65 dx += childRight - parentRight
66 dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
67 } else if (childLeft <= parentLeft) {
68 dx -= parentLeft - childLeft
69 dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
70 }
71 let x = parent.scrollLeft + dx
72 x = Math.max(0, x)
73 x = Math.min(x, parent.scrollWidth - parentWidth)
74 if (dx !== 0) {
75 parent.scroll({
76 left: x,
77 behavior: 'smooth',
78 })
79 }
80 }, [scrollElRef, selectedPage, styles])
81
82 const onPressItem = useCallback(
83 (index: number) => {
84 onSelect?.(index)
85 if (index === selectedPage) {
86 onPressSelected?.(index)
87 }
88 },
89 [onSelect, selectedPage, onPressSelected],
90 )
91
92 return (
93 <View
94 testID={testID}
95 style={[t.atoms.bg, styles.outer]}
96 accessibilityRole="tablist">
97 <DraggableScrollView
98 testID={`${testID}-selector`}
99 horizontal={true}
100 showsHorizontalScrollIndicator={false}
101 ref={scrollElRef}
102 contentContainerStyle={styles.contentContainer}>
103 {items.map((item, i) => {
104 const selected = i === selectedPage
105 return (
106 <PressableWithHover
107 testID={`${testID}-selector-${i}`}
108 key={`${item}-${i}`}
109 ref={node => {
110 itemRefs.current[i] = node as any
111 }}
112 style={styles.item}
113 hoverStyle={t.atoms.bg_contrast_25}
114 onPress={() => onPressItem(i)}
115 accessibilityRole="tab">
116 <View style={styles.itemInner}>
117 <Text
118 emoji
119 testID={testID ? `${testID}-${item}` : undefined}
120 style={[
121 styles.itemText,
122 selected ? t.atoms.text : t.atoms.text_contrast_medium,
123 a.text_md,
124 a.font_semi_bold,
125 {lineHeight: 20},
126 ]}>
127 {item}
128 <View
129 style={[
130 styles.itemIndicator,
131 selected && {
132 backgroundColor: t.palette.primary_500,
133 },
134 ]}
135 />
136 </Text>
137 </View>
138 </PressableWithHover>
139 )
140 })}
141 </DraggableScrollView>
142 <View style={[t.atoms.border_contrast_low, styles.outerBottomBorder]} />
143 </View>
144 )
145}
146
147const desktopStyles = StyleSheet.create({
148 outer: {
149 flexDirection: 'row',
150 width: 600,
151 },
152 contentContainer: {
153 flexGrow: 1,
154 paddingHorizontal: 0,
155 backgroundColor: 'transparent',
156 },
157 item: {
158 flexGrow: 1,
159 alignItems: 'stretch',
160 paddingTop: 14,
161 paddingHorizontal: 14,
162 justifyContent: 'center',
163 },
164 itemInner: {
165 alignItems: 'center',
166 ...web({overflowX: 'hidden'}),
167 },
168 itemText: {
169 textAlign: 'center',
170 paddingBottom: 10 + 3,
171 },
172 itemIndicator: {
173 position: 'absolute',
174 bottom: 0,
175 height: 3,
176 left: '50%',
177 transform: 'translateX(-50%)',
178 minWidth: 45,
179 width: '100%',
180 },
181 outerBottomBorder: {
182 position: 'absolute',
183 left: 0,
184 right: 0,
185 top: '100%',
186 borderBottomWidth: StyleSheet.hairlineWidth,
187 },
188})
189
190const mobileStyles = StyleSheet.create({
191 outer: {
192 flexDirection: 'row',
193 },
194 contentContainer: {
195 flexGrow: 1,
196 backgroundColor: 'transparent',
197 paddingHorizontal: 6,
198 },
199 item: {
200 flexGrow: 1,
201 alignItems: 'stretch',
202 paddingTop: 10,
203 paddingHorizontal: 10,
204 justifyContent: 'center',
205 },
206 itemInner: {
207 flexGrow: 1,
208 alignItems: 'center',
209 ...web({overflowX: 'hidden'}),
210 },
211 itemText: {
212 textAlign: 'center',
213 paddingBottom: 10 + 3,
214 },
215 itemIndicator: {
216 position: 'absolute',
217 bottom: 0,
218 height: 3,
219 left: '50%',
220 transform: 'translateX(-50%)',
221 minWidth: 45,
222 width: '100%',
223 },
224 outerBottomBorder: {
225 position: 'absolute',
226 left: 0,
227 right: 0,
228 top: '100%',
229 borderBottomWidth: StyleSheet.hairlineWidth,
230 },
231})