Compare commits

...

19 Commits

Author SHA1 Message Date
Eric Bailey cc6a90b0b7 Revert testing edits 2025-06-24 21:52:58 -05:00
Eric Bailey fffd258a68 Adjust overlap, clarify intent of consts 2025-06-24 21:49:16 -05:00
Eric Bailey 5c552be275 Use dimensions hook 2025-06-23 13:26:10 -05:00
Eric Bailey d7a644e759 Ok diff interface to avoid collapsed views 2025-06-23 13:24:38 -05:00
Eric Bailey bff64b78d1 Include demo for now 2025-06-23 12:38:21 -05:00
Eric Bailey ee2a579d1a Cleanup and remove provider 2025-06-23 12:38:21 -05:00
Eric Bailey 4bea63faf7 Fix animation 2025-06-23 12:38:21 -05:00
Eric Bailey ec4cdf1189 Web 2025-06-23 12:38:21 -05:00
Eric Bailey a2d8d3b90e Add easier abstraction 2025-06-23 12:38:21 -05:00
Eric Bailey 981409fbcc Explicitly wrap gesture handler 2025-06-23 12:38:21 -05:00
Eric Bailey 42ec49c81a Improve colors 2025-06-23 12:38:21 -05:00
Eric Bailey 2a69ee8fac Some a11y 2025-06-23 12:38:21 -05:00
Samuel Newman ca4e7f9cc0 add transform origin to animation 2025-06-23 12:38:21 -05:00
Samuel Newman af70a8a9e0 add animation 2025-06-23 12:38:21 -05:00
Eric Bailey dbe86dde43 Cleanup 2025-06-23 12:38:21 -05:00
Eric Bailey 2b23499a15 Cleanup 2025-06-23 12:38:21 -05:00
Eric Bailey 996e550c0a Ok pretty good on native 2025-06-23 12:38:21 -05:00
Eric Bailey 5c1f21623d Ok working with no overlay and global gesture handler 2025-06-23 12:38:21 -05:00
Eric Bailey 19ec70b8f0 Working overlay, WIP 2025-06-23 12:38:21 -05:00
10 changed files with 693 additions and 5 deletions
+8 -5
View File
@@ -33,6 +33,7 @@ import {
ensureGeolocationResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -154,11 +155,13 @@ function InnerApp() {
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
+4
View File
@@ -983,6 +983,10 @@ export const atoms = {
transition_none: web({
transitionProperty: 'none',
}),
transition_timing_default: web({
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+6
View File
@@ -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
+411
View File
@@ -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>
)
}
+112
View File
@@ -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() {}
+83
View File
@@ -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.')
}
+35
View File
@@ -334,3 +334,38 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
min-width: var(--radix-select-trigger-width);
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);
}
}