Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 231 lines 6.0 kB view raw
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})