Files
bsky-social-app/src/components/Tooltip/index.tsx
T
Samuel Newman b422765aed [Threadgate] Tweak threadgate buttons (#9173)
* tweak in-post threadgate button

* tweak composer threadgate button

* reduce date length slightly

* pressed styles

* make date length depend on breakpoint

* add chevron to label btn

* add tiny chevron, special-case button icon width

* [Threadgate] Add hint (#9350)

* get tooltip working on web

* add compatibility layer for working in iOS sheets

* add timeout to profile tooltip now that it appears instantly

* rm debug code

* Update ThreadgateBtn.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* remeasure when keyboard changes

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix types

* [Threadgate] Refresh dialog (#9342)

* wip new ui

* update threadgate dialog with new designs

* restore nobody option

* relayout android sheet when ratio changes

* fix ratio changing case in bottom sheet

* timebox reached, use setTimeout

* update panel styles

* missing imports

* extract out Panel

* tweak layout animation

* fix icon color

* use same color mechamism for icon as text

* restore the header

* refreshed toggle styles (#9343)

* [Threadgate] Persist settings (#9341)

* add persist toggle to threadgate dialog

* move state back down

* sort out spacing

* wire up query

* @surfdude29 tweaks

* use tiny chevron in WhoCanReply

* wait for prefetch before opening

* move Panel into the Toggle namespace

* default -> pref

* use medium date length

* rm hover state from web selects, fix border radius

* fix key issue in Selects

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-11-14 06:21:37 -08:00

468 lines
12 KiB
TypeScript

import {
Children,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture'
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
import {
ARROW_HALF_SIZE,
ARROW_SIZE,
BUBBLE_MAX_WIDTH,
MIN_EDGE_SPACE,
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
const TooltipPortal = createPortalGroup()
const TooltipProviderContext =
createContext<React.RefObject<View | null> | null>(null)
/**
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
* such as in the composer sheet.
*
* Only really necessary on iOS but can work on Android.
*/
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
const ref = useRef<View | null>(null)
return (
<GlobalGestureEventsProvider style={[a.flex_1]}>
<TooltipPortal.Provider>
<View ref={ref} collapsable={false} style={[a.flex_1]}>
<TooltipProviderContext value={ref}>
{children}
</TooltipProviderContext>
</View>
<TooltipPortal.Outlet />
</TooltipPortal.Provider>
</GlobalGestureEventsProvider>
)
}
SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
/**
* These are native specific values, not shared with web
*/
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
type TooltipContextType = {
position: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}
type TargetMeasurements = {
x: number
y: number
width: number
height: number
}
type TargetContextType = {
targetMeasurements: TargetMeasurements | undefined
setTargetMeasurements: (measurements: TargetMeasurements) => void
shouldMeasure: boolean
}
const TooltipContext = createContext<TooltipContextType>({
position: 'bottom',
visible: false,
onVisibleChange: () => {},
})
TooltipContext.displayName = 'TooltipContext'
const TargetContext = createContext<TargetContextType>({
targetMeasurements: undefined,
setTargetMeasurements: () => {},
shouldMeasure: false,
})
TargetContext.displayName = 'TargetContext'
export function Outer({
children,
position = 'bottom',
visible: requestVisible,
onVisibleChange,
}: {
children: React.ReactNode
position?: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
/**
* Lagging state to track the externally-controlled visibility of the
* tooltip, which needs to wait for the target to be measured before
* actually being shown.
*/
const [visible, setVisible] = useState<boolean>(false)
const [targetMeasurements, setTargetMeasurements] = useState<
| {
x: number
y: number
width: number
height: number
}
| undefined
>(undefined)
if (requestVisible && !visible && targetMeasurements) {
setVisible(true)
} else if (!requestVisible && visible) {
setVisible(false)
setTargetMeasurements(undefined)
}
const ctx = useMemo(
() => ({position, visible, onVisibleChange}),
[position, visible, onVisibleChange],
)
const targetCtx = useMemo(
() => ({
targetMeasurements,
setTargetMeasurements,
shouldMeasure: requestVisible,
}),
[requestVisible, targetMeasurements, setTargetMeasurements],
)
return (
<TooltipContext.Provider value={ctx}>
<TargetContext.Provider value={targetCtx}>
{children}
</TargetContext.Provider>
</TooltipContext.Provider>
)
}
export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const [hasLayedOut, setHasLayedOut] = useState(false)
const targetRef = useRef<View>(null)
const containerRef = useContext(TooltipProviderContext)
const keyboardIsOpen = useIsKeyboardVisible()
useEffect(() => {
if (!shouldMeasure || !hasLayedOut) return
/*
* Once opened, measure the dimensions and position of the target
*/
if (containerRef?.current) {
targetRef.current?.measureLayout(
containerRef.current,
(x, y, width, height) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
},
)
} else {
targetRef.current?.measure((_x, _y, width, height, x, y) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
})
}
}, [
shouldMeasure,
setTargetMeasurements,
hasLayedOut,
containerRef,
keyboardIsOpen,
])
return (
<View
collapsable={false}
ref={targetRef}
onLayout={() => setHasLayedOut(true)}>
{children}
</View>
)
}
export function Content({
children,
label,
}: {
children: React.ReactNode
label: string
}) {
const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const isWithinProvider = !!useContext(TooltipProviderContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
return (
<Portal>
<Bubble
label={label}
position={position}
/*
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
* the context providers.
*/
targetMeasurements={targetMeasurements}
requestClose={requestClose}>
{children}
</Bubble>
</Portal>
)
}
function Bubble({
children,
label,
position,
requestClose,
targetMeasurements,
}: {
children: React.ReactNode
label: string
position: TooltipContextType['position']
requestClose: () => void
targetMeasurements: Exclude<
TargetContextType['targetMeasurements'],
undefined
>
}) {
const t = useTheme()
const insets = useSafeAreaInsets()
const dimensions = useWindowDimensions()
const [bubbleMeasurements, setBubbleMeasurements] = useState<
| {
width: number
height: number
}
| undefined
>(undefined)
const coords = useMemo(() => {
if (!bubbleMeasurements)
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
tipTop: 0,
tipLeft: 0,
}
const {width: ww, height: wh} = dimensions
const maxTop = insets.top
const maxBottom = wh - insets.bottom
const {width: cw, height: ch} = bubbleMeasurements
const minLeft = MIN_EDGE_SPACE
const maxLeft = ww - minLeft
let computedPosition: 'top' | 'bottom' = position
let top = targetMeasurements.y + targetMeasurements.height
let left = Math.max(
minLeft,
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
)
const tipTranslate = ARROW_HALF_SIZE * -1
let tipTop = tipTranslate
if (left + cw > maxLeft) {
left -= left + cw - maxLeft
}
let tipLeft =
targetMeasurements.x -
left +
targetMeasurements.width / 2 -
ARROW_HALF_SIZE
let bottom = top + ch
function positionTop() {
top = top - ch - targetMeasurements.height
bottom = top + ch
tipTop = tipTop + ch
computedPosition = 'top'
}
function positionBottom() {
top = targetMeasurements.y + targetMeasurements.height
bottom = top + ch
tipTop = tipTranslate
computedPosition = 'bottom'
}
if (position === 'top') {
positionTop()
if (top < maxTop) {
positionBottom()
}
} else {
if (bottom > maxBottom) {
positionTop()
}
}
if (computedPosition === 'bottom') {
top += ARROW_VISUAL_OFFSET
bottom += ARROW_VISUAL_OFFSET
} else {
top -= ARROW_VISUAL_OFFSET
bottom -= ARROW_VISUAL_OFFSET
}
return {
computedPosition,
top,
bottom,
left,
right: left + cw,
tipTop,
tipLeft,
}
}, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
const requestCloseWrapped = useCallback(() => {
setBubbleMeasurements(undefined)
requestClose()
}, [requestClose])
useOnGesture(
useCallback(
e => {
const {x, y} = e
const isInside =
x > coords.left &&
x < coords.right &&
y > coords.top &&
y < coords.bottom
if (!isInside) {
requestCloseWrapped()
}
},
[coords, requestCloseWrapped],
),
)
return (
<View
accessible
role="alert"
accessibilityHint=""
accessibilityLabel={label}
// android
importantForAccessibility="yes"
// ios
accessibilityViewIsModal
style={[
a.absolute,
a.align_start,
{
width: BUBBLE_MAX_WIDTH,
opacity: bubbleMeasurements ? 1 : 0,
top: coords.top,
left: coords.left,
},
]}>
<Animated.View
entering={ZoomIn.easing(Easing.out(Easing.exp))}
style={{transformOrigin: oppposite(position)}}>
<View
style={[
a.absolute,
a.top_0,
a.z_10,
t.atoms.bg,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{
borderTopLeftRadius: a.rounded_2xs.borderRadius,
borderBottomRightRadius: a.rounded_2xs.borderRadius,
width: ARROW_SIZE,
height: ARROW_SIZE,
transform: [{rotate: '45deg'}],
top: coords.tipTop,
left: coords.tipLeft,
},
]}
/>
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
t.atoms.shadow_md,
{
shadowOpacity: 0.2,
shadowOffset: {
width: 0,
height:
BUBBLE_SHADOW_OFFSET *
(coords.computedPosition === 'bottom' ? -1 : 1),
},
},
]}
onLayout={e => {
setBubbleMeasurements({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height,
})
}}>
{children}
</View>
</Animated.View>
</View>
)
}
function oppposite(position: 'top' | 'bottom') {
switch (position) {
case 'top':
return 'center bottom'
case 'bottom':
return 'center top'
default:
return 'center'
}
}
export function TextBubble({children}: {children: React.ReactNode}) {
const c = Children.toArray(children)
return (
<Content label={c.join(' ')}>
<View style={[a.gap_xs]}>
{c.map((child, i) => (
<Text key={i} style={[a.text_sm, a.leading_snug]}>
{child}
</Text>
))}
</View>
</Content>
)
}