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>
This commit is contained in:
+8
-5
@@ -33,6 +33,7 @@ import {
|
|||||||
ensureGeolocationResolved,
|
ensureGeolocationResolved,
|
||||||
Provider as GeolocationProvider,
|
Provider as GeolocationProvider,
|
||||||
} from '#/state/geolocation'
|
} from '#/state/geolocation'
|
||||||
|
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
@@ -154,11 +155,13 @@ function InnerApp() {
|
|||||||
<HideBottomBarBorderProvider>
|
<HideBottomBarBorderProvider>
|
||||||
<GestureHandlerRootView
|
<GestureHandlerRootView
|
||||||
style={s.h100pct}>
|
style={s.h100pct}>
|
||||||
<IntentDialogProvider>
|
<GlobalGestureEventsProvider>
|
||||||
<TestCtrls />
|
<IntentDialogProvider>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
<NuxDialogs />
|
<Shell />
|
||||||
</IntentDialogProvider>
|
<NuxDialogs />
|
||||||
|
</IntentDialogProvider>
|
||||||
|
</GlobalGestureEventsProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</HideBottomBarBorderProvider>
|
</HideBottomBarBorderProvider>
|
||||||
</ServiceAccountManager>
|
</ServiceAccountManager>
|
||||||
|
|||||||
@@ -983,6 +983,10 @@ export const atoms = {
|
|||||||
transition_none: web({
|
transition_none: web({
|
||||||
transitionProperty: 'none',
|
transitionProperty: 'none',
|
||||||
}),
|
}),
|
||||||
|
transition_timing_default: web({
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||||
|
transitionDuration: '100ms',
|
||||||
|
}),
|
||||||
transition_all: web({
|
transition_all: web({
|
||||||
transitionProperty: 'all',
|
transitionProperty: 'all',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
|
export const BUBBLE_MAX_WIDTH = 240
|
||||||
|
export const ARROW_SIZE = 12
|
||||||
|
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
|
||||||
|
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import {
|
||||||
|
Children,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
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 {atoms as a, select, useTheme} from '#/alf'
|
||||||
|
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
||||||
|
import {Portal} from '#/components/Portal'
|
||||||
|
import {
|
||||||
|
ARROW_HALF_SIZE,
|
||||||
|
ARROW_SIZE,
|
||||||
|
BUBBLE_MAX_WIDTH,
|
||||||
|
MIN_EDGE_SPACE,
|
||||||
|
} from '#/components/Tooltip/const'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
ready: boolean
|
||||||
|
onVisibleChange: (visible: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetContextType = {
|
||||||
|
targetMeasurements:
|
||||||
|
| {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
targetRef: React.RefObject<View>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TooltipContext = createContext<TooltipContextType>({
|
||||||
|
position: 'bottom',
|
||||||
|
ready: false,
|
||||||
|
onVisibleChange: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TargetContext = createContext<TargetContextType>({
|
||||||
|
targetMeasurements: undefined,
|
||||||
|
targetRef: {current: null},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
position = 'bottom',
|
||||||
|
visible: requestVisible,
|
||||||
|
onVisibleChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
visible: boolean
|
||||||
|
onVisibleChange: (visible: boolean) => void
|
||||||
|
}) {
|
||||||
|
/**
|
||||||
|
* Whether we have measured the target and are ready to show the tooltip.
|
||||||
|
*/
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
/**
|
||||||
|
* Lagging state to track the externally-controlled visibility of the
|
||||||
|
* tooltip.
|
||||||
|
*/
|
||||||
|
const [prevRequestVisible, setPrevRequestVisible] = useState<
|
||||||
|
boolean | undefined
|
||||||
|
>()
|
||||||
|
/**
|
||||||
|
* Needs to reference the element this Tooltip is attached to.
|
||||||
|
*/
|
||||||
|
const targetRef = useRef<View>(null)
|
||||||
|
const [targetMeasurements, setTargetMeasurements] = useState<
|
||||||
|
| {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
if (requestVisible && !prevRequestVisible) {
|
||||||
|
setPrevRequestVisible(true)
|
||||||
|
|
||||||
|
if (targetRef.current) {
|
||||||
|
/*
|
||||||
|
* Once opened, measure the dimensions and position of the target
|
||||||
|
*/
|
||||||
|
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
|
||||||
|
if (pageX !== undefined && pageY !== undefined && width && height) {
|
||||||
|
setTargetMeasurements({x: pageX, y: pageY, width, height})
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (!requestVisible && prevRequestVisible) {
|
||||||
|
setPrevRequestVisible(false)
|
||||||
|
setTargetMeasurements(undefined)
|
||||||
|
setReady(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({position, ready, onVisibleChange}),
|
||||||
|
[position, ready, onVisibleChange],
|
||||||
|
)
|
||||||
|
const targetCtx = useMemo(
|
||||||
|
() => ({targetMeasurements, targetRef}),
|
||||||
|
[targetMeasurements, targetRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipContext.Provider value={ctx}>
|
||||||
|
<TargetContext.Provider value={targetCtx}>
|
||||||
|
{children}
|
||||||
|
</TargetContext.Provider>
|
||||||
|
</TooltipContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Target({children}: {children: React.ReactNode}) {
|
||||||
|
const {targetRef} = useContext(TargetContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View collapsable={false} ref={targetRef}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Content({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const {position, ready, onVisibleChange} = useContext(TooltipContext)
|
||||||
|
const {targetMeasurements} = useContext(TargetContext)
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
onVisibleChange(false)
|
||||||
|
}, [onVisibleChange])
|
||||||
|
|
||||||
|
if (!ready || !targetMeasurements) return null
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {Children, createContext, useContext, useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Popover} from 'radix-ui'
|
||||||
|
|
||||||
|
import {atoms as a, flatten, select, useTheme} from '#/alf'
|
||||||
|
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||||
|
import {
|
||||||
|
ARROW_SIZE,
|
||||||
|
BUBBLE_MAX_WIDTH,
|
||||||
|
MIN_EDGE_SPACE,
|
||||||
|
} from '#/components/Tooltip/const'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type TooltipContextType = {
|
||||||
|
position: 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TooltipContext = createContext<TooltipContextType>({
|
||||||
|
position: 'bottom',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
position = 'bottom',
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
visible: boolean
|
||||||
|
onVisibleChange: (visible: boolean) => void
|
||||||
|
}) {
|
||||||
|
const ctx = useMemo(() => ({position}), [position])
|
||||||
|
return (
|
||||||
|
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||||
|
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||||
|
</Popover.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Target({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<View collapsable={false}>{children}</View>
|
||||||
|
</Popover.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Content({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {position} = useContext(TooltipContext)
|
||||||
|
return (
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className="radix-popover-content"
|
||||||
|
aria-label={label}
|
||||||
|
side={position}
|
||||||
|
sideOffset={4}
|
||||||
|
collisionPadding={MIN_EDGE_SPACE}
|
||||||
|
style={flatten([
|
||||||
|
a.rounded_sm,
|
||||||
|
select(t.name, {
|
||||||
|
light: t.atoms.bg,
|
||||||
|
dark: t.atoms.bg_contrast_100,
|
||||||
|
dim: t.atoms.bg_contrast_100,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
minWidth: 'max-content',
|
||||||
|
boxShadow: select(t.name, {
|
||||||
|
light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||||
|
dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||||
|
dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])}>
|
||||||
|
<Popover.Arrow
|
||||||
|
width={ARROW_SIZE}
|
||||||
|
height={ARROW_SIZE / 2}
|
||||||
|
fill={select(t.name, {
|
||||||
|
light: t.atoms.bg.backgroundColor,
|
||||||
|
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||||
|
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GlobalGestureEvents,
|
||||||
|
useGlobalGestureEvents,
|
||||||
|
} from '#/state/global-gesture-events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for global gesture events. Callback should be wrapped with
|
||||||
|
* `useCallback` or otherwise memoized to avoid unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export function useOnGesture(
|
||||||
|
onGestureCallback: (e: GlobalGestureEvents['begin']) => void,
|
||||||
|
) {
|
||||||
|
const ctx = useGlobalGestureEvents()
|
||||||
|
useEffect(() => {
|
||||||
|
ctx.register()
|
||||||
|
ctx.events.on('begin', onGestureCallback)
|
||||||
|
return () => {
|
||||||
|
ctx.unregister()
|
||||||
|
ctx.events.off('begin', onGestureCallback)
|
||||||
|
}
|
||||||
|
}, [ctx, onGestureCallback])
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export function useOnGesture() {}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {createContext, useContext, useMemo, useRef, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
type GestureStateChangeEvent,
|
||||||
|
type GestureUpdateEvent,
|
||||||
|
type PanGestureHandlerEventPayload,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
export type GlobalGestureEvents = {
|
||||||
|
begin: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||||
|
update: GestureUpdateEvent<PanGestureHandlerEventPayload>
|
||||||
|
end: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||||
|
finalize: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext<{
|
||||||
|
events: EventEmitter<GlobalGestureEvents>
|
||||||
|
register: () => void
|
||||||
|
unregister: () => void
|
||||||
|
}>({
|
||||||
|
events: new EventEmitter<GlobalGestureEvents>(),
|
||||||
|
register: () => {},
|
||||||
|
unregister: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function GlobalGestureEventsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const refCount = useRef(0)
|
||||||
|
const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), [])
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({
|
||||||
|
events,
|
||||||
|
register() {
|
||||||
|
refCount.current += 1
|
||||||
|
if (refCount.current === 1) {
|
||||||
|
setEnabled(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unregister() {
|
||||||
|
refCount.current -= 1
|
||||||
|
if (refCount.current === 0) {
|
||||||
|
setEnabled(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[events, setEnabled],
|
||||||
|
)
|
||||||
|
const gesture = Gesture.Pan()
|
||||||
|
.runOnJS(true)
|
||||||
|
.enabled(enabled)
|
||||||
|
.simultaneousWithExternalGesture()
|
||||||
|
.onBegin(e => {
|
||||||
|
events.emit('begin', e)
|
||||||
|
})
|
||||||
|
.onUpdate(e => {
|
||||||
|
events.emit('update', e)
|
||||||
|
})
|
||||||
|
.onEnd(e => {
|
||||||
|
events.emit('end', e)
|
||||||
|
})
|
||||||
|
.onFinalize(e => {
|
||||||
|
events.emit('finalize', e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={ctx}>
|
||||||
|
<GestureDetector gesture={gesture}>
|
||||||
|
<View collapsable={false}>{children}</View>
|
||||||
|
</GestureDetector>
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalGestureEvents() {
|
||||||
|
return useContext(Context)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function GlobalGestureEventsProvider(_props: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
throw new Error('GlobalGestureEventsProvider is not supported on web.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalGestureEvents() {
|
||||||
|
throw new Error('useGlobalGestureEvents is not supported on web.')
|
||||||
|
}
|
||||||
@@ -334,3 +334,38 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
|
|||||||
min-width: var(--radix-select-trigger-width);
|
min-width: var(--radix-select-trigger-width);
|
||||||
max-height: var(--radix-select-content-available-height);
|
max-height: var(--radix-select-content-available-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* #/components/Tooltip/index.web.tsx
|
||||||
|
*/
|
||||||
|
.radix-popover-content {
|
||||||
|
animation-duration: 300ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.17, 0.73, 0.14, 1);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
.radix-popover-content[data-state='open'][data-side='top'] {
|
||||||
|
animation-name: radixPopoverSlideUpAndFade;
|
||||||
|
}
|
||||||
|
.radix-popover-content[data-state='open'][data-side='bottom'] {
|
||||||
|
animation-name: radixPopoverSlideDownAndFade;
|
||||||
|
}
|
||||||
|
@keyframes radixPopoverSlideUpAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes radixPopoverSlideDownAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user