Bluesky app fork with some witchin' additions 💫

Tooltip (#8555)

* Working overlay, WIP

* Ok working with no overlay and global gesture handler

* Ok pretty good on native

* Cleanup

* Cleanup

* add animation

* add transform origin to animation

* Some a11y

* Improve colors

* Explicitly wrap gesture handler

* Add easier abstraction

* Web

* Fix animation

* Cleanup and remove provider

* Include demo for now

* Ok diff interface to avoid collapsed views

* Use dimensions hook

* Adjust overlap, clarify intent of consts

* Revert testing edits

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
4c151516 cd820709

+693 -5
+8 -5
src/App.native.tsx
··· 33 33 ensureGeolocationResolved, 34 34 Provider as GeolocationProvider, 35 35 } from '#/state/geolocation' 36 + import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 36 37 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 37 38 import {Provider as InvitesStateProvider} from '#/state/invites' 38 39 import {Provider as LightboxStateProvider} from '#/state/lightbox' ··· 154 155 <HideBottomBarBorderProvider> 155 156 <GestureHandlerRootView 156 157 style={s.h100pct}> 157 - <IntentDialogProvider> 158 - <TestCtrls /> 159 - <Shell /> 160 - <NuxDialogs /> 161 - </IntentDialogProvider> 158 + <GlobalGestureEventsProvider> 159 + <IntentDialogProvider> 160 + <TestCtrls /> 161 + <Shell /> 162 + <NuxDialogs /> 163 + </IntentDialogProvider> 164 + </GlobalGestureEventsProvider> 162 165 </GestureHandlerRootView> 163 166 </HideBottomBarBorderProvider> 164 167 </ServiceAccountManager>
+4
src/alf/atoms.ts
··· 983 983 transition_none: web({ 984 984 transitionProperty: 'none', 985 985 }), 986 + transition_timing_default: web({ 987 + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', 988 + transitionDuration: '100ms', 989 + }), 986 990 transition_all: web({ 987 991 transitionProperty: 'all', 988 992 transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+6
src/components/Tooltip/const.ts
··· 1 + import {atoms as a} from '#/alf' 2 + 3 + export const BUBBLE_MAX_WIDTH = 240 4 + export const ARROW_SIZE = 12 5 + export const ARROW_HALF_SIZE = ARROW_SIZE / 2 6 + export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
+411
src/components/Tooltip/index.tsx
··· 1 + import { 2 + Children, 3 + createContext, 4 + useCallback, 5 + useContext, 6 + useMemo, 7 + useRef, 8 + useState, 9 + } from 'react' 10 + import {useWindowDimensions, View} from 'react-native' 11 + import Animated, {Easing, ZoomIn} from 'react-native-reanimated' 12 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 13 + 14 + import {atoms as a, select, useTheme} from '#/alf' 15 + import {useOnGesture} from '#/components/hooks/useOnGesture' 16 + import {Portal} from '#/components/Portal' 17 + import { 18 + ARROW_HALF_SIZE, 19 + ARROW_SIZE, 20 + BUBBLE_MAX_WIDTH, 21 + MIN_EDGE_SPACE, 22 + } from '#/components/Tooltip/const' 23 + import {Text} from '#/components/Typography' 24 + 25 + /** 26 + * These are native specific values, not shared with web 27 + */ 28 + const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target 29 + const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip 30 + 31 + type TooltipContextType = { 32 + position: 'top' | 'bottom' 33 + ready: boolean 34 + onVisibleChange: (visible: boolean) => void 35 + } 36 + 37 + type TargetContextType = { 38 + targetMeasurements: 39 + | { 40 + x: number 41 + y: number 42 + width: number 43 + height: number 44 + } 45 + | undefined 46 + targetRef: React.RefObject<View> 47 + } 48 + 49 + const TooltipContext = createContext<TooltipContextType>({ 50 + position: 'bottom', 51 + ready: false, 52 + onVisibleChange: () => {}, 53 + }) 54 + 55 + const TargetContext = createContext<TargetContextType>({ 56 + targetMeasurements: undefined, 57 + targetRef: {current: null}, 58 + }) 59 + 60 + export function Outer({ 61 + children, 62 + position = 'bottom', 63 + visible: requestVisible, 64 + onVisibleChange, 65 + }: { 66 + children: React.ReactNode 67 + position?: 'top' | 'bottom' 68 + visible: boolean 69 + onVisibleChange: (visible: boolean) => void 70 + }) { 71 + /** 72 + * Whether we have measured the target and are ready to show the tooltip. 73 + */ 74 + const [ready, setReady] = useState(false) 75 + /** 76 + * Lagging state to track the externally-controlled visibility of the 77 + * tooltip. 78 + */ 79 + const [prevRequestVisible, setPrevRequestVisible] = useState< 80 + boolean | undefined 81 + >() 82 + /** 83 + * Needs to reference the element this Tooltip is attached to. 84 + */ 85 + const targetRef = useRef<View>(null) 86 + const [targetMeasurements, setTargetMeasurements] = useState< 87 + | { 88 + x: number 89 + y: number 90 + width: number 91 + height: number 92 + } 93 + | undefined 94 + >(undefined) 95 + 96 + if (requestVisible && !prevRequestVisible) { 97 + setPrevRequestVisible(true) 98 + 99 + if (targetRef.current) { 100 + /* 101 + * Once opened, measure the dimensions and position of the target 102 + */ 103 + targetRef.current.measure((_x, _y, width, height, pageX, pageY) => { 104 + if (pageX !== undefined && pageY !== undefined && width && height) { 105 + setTargetMeasurements({x: pageX, y: pageY, width, height}) 106 + setReady(true) 107 + } 108 + }) 109 + } 110 + } else if (!requestVisible && prevRequestVisible) { 111 + setPrevRequestVisible(false) 112 + setTargetMeasurements(undefined) 113 + setReady(false) 114 + } 115 + 116 + const ctx = useMemo( 117 + () => ({position, ready, onVisibleChange}), 118 + [position, ready, onVisibleChange], 119 + ) 120 + const targetCtx = useMemo( 121 + () => ({targetMeasurements, targetRef}), 122 + [targetMeasurements, targetRef], 123 + ) 124 + 125 + return ( 126 + <TooltipContext.Provider value={ctx}> 127 + <TargetContext.Provider value={targetCtx}> 128 + {children} 129 + </TargetContext.Provider> 130 + </TooltipContext.Provider> 131 + ) 132 + } 133 + 134 + export function Target({children}: {children: React.ReactNode}) { 135 + const {targetRef} = useContext(TargetContext) 136 + 137 + return ( 138 + <View collapsable={false} ref={targetRef}> 139 + {children} 140 + </View> 141 + ) 142 + } 143 + 144 + export function Content({ 145 + children, 146 + label, 147 + }: { 148 + children: React.ReactNode 149 + label: string 150 + }) { 151 + const {position, ready, onVisibleChange} = useContext(TooltipContext) 152 + const {targetMeasurements} = useContext(TargetContext) 153 + const requestClose = useCallback(() => { 154 + onVisibleChange(false) 155 + }, [onVisibleChange]) 156 + 157 + if (!ready || !targetMeasurements) return null 158 + 159 + return ( 160 + <Portal> 161 + <Bubble 162 + label={label} 163 + position={position} 164 + /* 165 + * Gotta pass these in here. Inside the Bubble, we're Potal-ed outside 166 + * the context providers. 167 + */ 168 + targetMeasurements={targetMeasurements} 169 + requestClose={requestClose}> 170 + {children} 171 + </Bubble> 172 + </Portal> 173 + ) 174 + } 175 + 176 + function Bubble({ 177 + children, 178 + label, 179 + position, 180 + requestClose, 181 + targetMeasurements, 182 + }: { 183 + children: React.ReactNode 184 + label: string 185 + position: TooltipContextType['position'] 186 + requestClose: () => void 187 + targetMeasurements: Exclude< 188 + TargetContextType['targetMeasurements'], 189 + undefined 190 + > 191 + }) { 192 + const t = useTheme() 193 + const insets = useSafeAreaInsets() 194 + const dimensions = useWindowDimensions() 195 + const [bubbleMeasurements, setBubbleMeasurements] = useState< 196 + | { 197 + width: number 198 + height: number 199 + } 200 + | undefined 201 + >(undefined) 202 + const coords = useMemo(() => { 203 + if (!bubbleMeasurements) 204 + return { 205 + top: 0, 206 + bottom: 0, 207 + left: 0, 208 + right: 0, 209 + tipTop: 0, 210 + tipLeft: 0, 211 + } 212 + 213 + const {width: ww, height: wh} = dimensions 214 + const maxTop = insets.top 215 + const maxBottom = wh - insets.bottom 216 + const {width: cw, height: ch} = bubbleMeasurements 217 + const minLeft = MIN_EDGE_SPACE 218 + const maxLeft = ww - minLeft 219 + 220 + let computedPosition: 'top' | 'bottom' = position 221 + let top = targetMeasurements.y + targetMeasurements.height 222 + let left = Math.max( 223 + minLeft, 224 + targetMeasurements.x + targetMeasurements.width / 2 - cw / 2, 225 + ) 226 + const tipTranslate = ARROW_HALF_SIZE * -1 227 + let tipTop = tipTranslate 228 + 229 + if (left + cw > maxLeft) { 230 + left -= left + cw - maxLeft 231 + } 232 + 233 + let tipLeft = 234 + targetMeasurements.x - 235 + left + 236 + targetMeasurements.width / 2 - 237 + ARROW_HALF_SIZE 238 + 239 + let bottom = top + ch 240 + 241 + function positionTop() { 242 + top = top - ch - targetMeasurements.height 243 + bottom = top + ch 244 + tipTop = tipTop + ch 245 + computedPosition = 'top' 246 + } 247 + 248 + function positionBottom() { 249 + top = targetMeasurements.y + targetMeasurements.height 250 + bottom = top + ch 251 + tipTop = tipTranslate 252 + computedPosition = 'bottom' 253 + } 254 + 255 + if (position === 'top') { 256 + positionTop() 257 + if (top < maxTop) { 258 + positionBottom() 259 + } 260 + } else { 261 + if (bottom > maxBottom) { 262 + positionTop() 263 + } 264 + } 265 + 266 + if (computedPosition === 'bottom') { 267 + top += ARROW_VISUAL_OFFSET 268 + bottom += ARROW_VISUAL_OFFSET 269 + } else { 270 + top -= ARROW_VISUAL_OFFSET 271 + bottom -= ARROW_VISUAL_OFFSET 272 + } 273 + 274 + return { 275 + computedPosition, 276 + top, 277 + bottom, 278 + left, 279 + right: left + cw, 280 + tipTop, 281 + tipLeft, 282 + } 283 + }, [position, targetMeasurements, bubbleMeasurements, insets, dimensions]) 284 + 285 + const requestCloseWrapped = useCallback(() => { 286 + setBubbleMeasurements(undefined) 287 + requestClose() 288 + }, [requestClose]) 289 + 290 + useOnGesture( 291 + useCallback( 292 + e => { 293 + const {x, y} = e 294 + const isInside = 295 + x > coords.left && 296 + x < coords.right && 297 + y > coords.top && 298 + y < coords.bottom 299 + 300 + if (!isInside) { 301 + requestCloseWrapped() 302 + } 303 + }, 304 + [coords, requestCloseWrapped], 305 + ), 306 + ) 307 + 308 + return ( 309 + <View 310 + accessible 311 + role="alert" 312 + accessibilityHint="" 313 + accessibilityLabel={label} 314 + // android 315 + importantForAccessibility="yes" 316 + // ios 317 + accessibilityViewIsModal 318 + style={[ 319 + a.absolute, 320 + a.align_start, 321 + { 322 + width: BUBBLE_MAX_WIDTH, 323 + opacity: bubbleMeasurements ? 1 : 0, 324 + top: coords.top, 325 + left: coords.left, 326 + }, 327 + ]}> 328 + <Animated.View 329 + entering={ZoomIn.easing(Easing.out(Easing.exp))} 330 + style={{transformOrigin: oppposite(position)}}> 331 + <View 332 + style={[ 333 + a.absolute, 334 + a.top_0, 335 + a.z_10, 336 + t.atoms.bg, 337 + select(t.name, { 338 + light: t.atoms.bg, 339 + dark: t.atoms.bg_contrast_100, 340 + dim: t.atoms.bg_contrast_100, 341 + }), 342 + { 343 + borderTopLeftRadius: a.rounded_2xs.borderRadius, 344 + borderBottomRightRadius: a.rounded_2xs.borderRadius, 345 + width: ARROW_SIZE, 346 + height: ARROW_SIZE, 347 + transform: [{rotate: '45deg'}], 348 + top: coords.tipTop, 349 + left: coords.tipLeft, 350 + }, 351 + ]} 352 + /> 353 + <View 354 + style={[ 355 + a.px_md, 356 + a.py_sm, 357 + a.rounded_sm, 358 + select(t.name, { 359 + light: t.atoms.bg, 360 + dark: t.atoms.bg_contrast_100, 361 + dim: t.atoms.bg_contrast_100, 362 + }), 363 + t.atoms.shadow_md, 364 + { 365 + shadowOpacity: 0.2, 366 + shadowOffset: { 367 + width: 0, 368 + height: 369 + BUBBLE_SHADOW_OFFSET * 370 + (coords.computedPosition === 'bottom' ? -1 : 1), 371 + }, 372 + }, 373 + ]} 374 + onLayout={e => { 375 + setBubbleMeasurements({ 376 + width: e.nativeEvent.layout.width, 377 + height: e.nativeEvent.layout.height, 378 + }) 379 + }}> 380 + {children} 381 + </View> 382 + </Animated.View> 383 + </View> 384 + ) 385 + } 386 + 387 + function oppposite(position: 'top' | 'bottom') { 388 + switch (position) { 389 + case 'top': 390 + return 'center bottom' 391 + case 'bottom': 392 + return 'center top' 393 + default: 394 + return 'center' 395 + } 396 + } 397 + 398 + export function TextBubble({children}: {children: React.ReactNode}) { 399 + const c = Children.toArray(children) 400 + return ( 401 + <Content label={c.join(' ')}> 402 + <View style={[a.gap_xs]}> 403 + {c.map((child, i) => ( 404 + <Text key={i} style={[a.text_sm, a.leading_snug]}> 405 + {child} 406 + </Text> 407 + ))} 408 + </View> 409 + </Content> 410 + ) 411 + }
+112
src/components/Tooltip/index.web.tsx
··· 1 + import {Children, createContext, useContext, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Popover} from 'radix-ui' 4 + 5 + import {atoms as a, flatten, select, useTheme} from '#/alf' 6 + import {transparentifyColor} from '#/alf/util/colorGeneration' 7 + import { 8 + ARROW_SIZE, 9 + BUBBLE_MAX_WIDTH, 10 + MIN_EDGE_SPACE, 11 + } from '#/components/Tooltip/const' 12 + import {Text} from '#/components/Typography' 13 + 14 + type TooltipContextType = { 15 + position: 'top' | 'bottom' 16 + } 17 + 18 + const TooltipContext = createContext<TooltipContextType>({ 19 + position: 'bottom', 20 + }) 21 + 22 + export function Outer({ 23 + children, 24 + position = 'bottom', 25 + visible, 26 + onVisibleChange, 27 + }: { 28 + children: React.ReactNode 29 + position?: 'top' | 'bottom' 30 + visible: boolean 31 + onVisibleChange: (visible: boolean) => void 32 + }) { 33 + const ctx = useMemo(() => ({position}), [position]) 34 + return ( 35 + <Popover.Root open={visible} onOpenChange={onVisibleChange}> 36 + <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> 37 + </Popover.Root> 38 + ) 39 + } 40 + 41 + export function Target({children}: {children: React.ReactNode}) { 42 + return ( 43 + <Popover.Trigger asChild> 44 + <View collapsable={false}>{children}</View> 45 + </Popover.Trigger> 46 + ) 47 + } 48 + 49 + export function Content({ 50 + children, 51 + label, 52 + }: { 53 + children: React.ReactNode 54 + label: string 55 + }) { 56 + const t = useTheme() 57 + const {position} = useContext(TooltipContext) 58 + return ( 59 + <Popover.Portal> 60 + <Popover.Content 61 + className="radix-popover-content" 62 + aria-label={label} 63 + side={position} 64 + sideOffset={4} 65 + collisionPadding={MIN_EDGE_SPACE} 66 + style={flatten([ 67 + a.rounded_sm, 68 + select(t.name, { 69 + light: t.atoms.bg, 70 + dark: t.atoms.bg_contrast_100, 71 + dim: t.atoms.bg_contrast_100, 72 + }), 73 + { 74 + minWidth: 'max-content', 75 + boxShadow: select(t.name, { 76 + light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`, 77 + dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`, 78 + dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`, 79 + }), 80 + }, 81 + ])}> 82 + <Popover.Arrow 83 + width={ARROW_SIZE} 84 + height={ARROW_SIZE / 2} 85 + fill={select(t.name, { 86 + light: t.atoms.bg.backgroundColor, 87 + dark: t.atoms.bg_contrast_100.backgroundColor, 88 + dim: t.atoms.bg_contrast_100.backgroundColor, 89 + })} 90 + /> 91 + <View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}> 92 + {children} 93 + </View> 94 + </Popover.Content> 95 + </Popover.Portal> 96 + ) 97 + } 98 + 99 + export function TextBubble({children}: {children: React.ReactNode}) { 100 + const c = Children.toArray(children) 101 + return ( 102 + <Content label={c.join(' ')}> 103 + <View style={[a.gap_xs]}> 104 + {c.map((child, i) => ( 105 + <Text key={i} style={[a.text_sm, a.leading_snug]}> 106 + {child} 107 + </Text> 108 + ))} 109 + </View> 110 + </Content> 111 + ) 112 + }
+24
src/components/hooks/useOnGesture/index.ts
··· 1 + import {useEffect} from 'react' 2 + 3 + import { 4 + type GlobalGestureEvents, 5 + useGlobalGestureEvents, 6 + } from '#/state/global-gesture-events' 7 + 8 + /** 9 + * Listen for global gesture events. Callback should be wrapped with 10 + * `useCallback` or otherwise memoized to avoid unnecessary re-renders. 11 + */ 12 + export function useOnGesture( 13 + onGestureCallback: (e: GlobalGestureEvents['begin']) => void, 14 + ) { 15 + const ctx = useGlobalGestureEvents() 16 + useEffect(() => { 17 + ctx.register() 18 + ctx.events.on('begin', onGestureCallback) 19 + return () => { 20 + ctx.unregister() 21 + ctx.events.off('begin', onGestureCallback) 22 + } 23 + }, [ctx, onGestureCallback]) 24 + }
+1
src/components/hooks/useOnGesture/index.web.ts
··· 1 + export function useOnGesture() {}
+83
src/state/global-gesture-events/index.tsx
··· 1 + import {createContext, useContext, useMemo, useRef, useState} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + Gesture, 5 + GestureDetector, 6 + type GestureStateChangeEvent, 7 + type GestureUpdateEvent, 8 + type PanGestureHandlerEventPayload, 9 + } from 'react-native-gesture-handler' 10 + import EventEmitter from 'eventemitter3' 11 + 12 + export type GlobalGestureEvents = { 13 + begin: GestureStateChangeEvent<PanGestureHandlerEventPayload> 14 + update: GestureUpdateEvent<PanGestureHandlerEventPayload> 15 + end: GestureStateChangeEvent<PanGestureHandlerEventPayload> 16 + finalize: GestureStateChangeEvent<PanGestureHandlerEventPayload> 17 + } 18 + 19 + const Context = createContext<{ 20 + events: EventEmitter<GlobalGestureEvents> 21 + register: () => void 22 + unregister: () => void 23 + }>({ 24 + events: new EventEmitter<GlobalGestureEvents>(), 25 + register: () => {}, 26 + unregister: () => {}, 27 + }) 28 + 29 + export function GlobalGestureEventsProvider({ 30 + children, 31 + }: { 32 + children: React.ReactNode 33 + }) { 34 + const refCount = useRef(0) 35 + const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) 36 + const [enabled, setEnabled] = useState(false) 37 + const ctx = useMemo( 38 + () => ({ 39 + events, 40 + register() { 41 + refCount.current += 1 42 + if (refCount.current === 1) { 43 + setEnabled(true) 44 + } 45 + }, 46 + unregister() { 47 + refCount.current -= 1 48 + if (refCount.current === 0) { 49 + setEnabled(false) 50 + } 51 + }, 52 + }), 53 + [events, setEnabled], 54 + ) 55 + const gesture = Gesture.Pan() 56 + .runOnJS(true) 57 + .enabled(enabled) 58 + .simultaneousWithExternalGesture() 59 + .onBegin(e => { 60 + events.emit('begin', e) 61 + }) 62 + .onUpdate(e => { 63 + events.emit('update', e) 64 + }) 65 + .onEnd(e => { 66 + events.emit('end', e) 67 + }) 68 + .onFinalize(e => { 69 + events.emit('finalize', e) 70 + }) 71 + 72 + return ( 73 + <Context.Provider value={ctx}> 74 + <GestureDetector gesture={gesture}> 75 + <View collapsable={false}>{children}</View> 76 + </GestureDetector> 77 + </Context.Provider> 78 + ) 79 + } 80 + 81 + export function useGlobalGestureEvents() { 82 + return useContext(Context) 83 + }
+9
src/state/global-gesture-events/index.web.tsx
··· 1 + export function GlobalGestureEventsProvider(_props: { 2 + children: React.ReactNode 3 + }) { 4 + throw new Error('GlobalGestureEventsProvider is not supported on web.') 5 + } 6 + 7 + export function useGlobalGestureEvents() { 8 + throw new Error('useGlobalGestureEvents is not supported on web.') 9 + }
+35
src/style.css
··· 334 334 min-width: var(--radix-select-trigger-width); 335 335 max-height: var(--radix-select-content-available-height); 336 336 } 337 + 338 + /* 339 + * #/components/Tooltip/index.web.tsx 340 + */ 341 + .radix-popover-content { 342 + animation-duration: 300ms; 343 + animation-timing-function: cubic-bezier(0.17, 0.73, 0.14, 1); 344 + will-change: transform, opacity; 345 + } 346 + .radix-popover-content[data-state='open'][data-side='top'] { 347 + animation-name: radixPopoverSlideUpAndFade; 348 + } 349 + .radix-popover-content[data-state='open'][data-side='bottom'] { 350 + animation-name: radixPopoverSlideDownAndFade; 351 + } 352 + @keyframes radixPopoverSlideUpAndFade { 353 + from { 354 + opacity: 0; 355 + transform: translateY(2px); 356 + } 357 + to { 358 + opacity: 1; 359 + transform: translateY(0); 360 + } 361 + } 362 + @keyframes radixPopoverSlideDownAndFade { 363 + from { 364 + opacity: 0; 365 + transform: translateY(-2px); 366 + } 367 + to { 368 + opacity: 1; 369 + transform: translateY(0); 370 + } 371 + }