diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.native.tsx | 13 | ||||
-rw-r--r-- | src/alf/atoms.ts | 4 | ||||
-rw-r--r-- | src/components/Tooltip/const.ts | 6 | ||||
-rw-r--r-- | src/components/Tooltip/index.tsx | 411 | ||||
-rw-r--r-- | src/components/Tooltip/index.web.tsx | 112 | ||||
-rw-r--r-- | src/components/hooks/useOnGesture/index.ts | 24 | ||||
-rw-r--r-- | src/components/hooks/useOnGesture/index.web.ts | 1 | ||||
-rw-r--r-- | src/state/global-gesture-events/index.tsx | 83 | ||||
-rw-r--r-- | src/state/global-gesture-events/index.web.tsx | 9 | ||||
-rw-r--r-- | src/style.css | 35 |
10 files changed, 693 insertions, 5 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 81d4a870e..c42b11746 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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> diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 79ec41679..440ac16ac 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -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)', diff --git a/src/components/Tooltip/const.ts b/src/components/Tooltip/const.ts new file mode 100644 index 000000000..cad7e0106 --- /dev/null +++ b/src/components/Tooltip/const.ts @@ -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 diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 000000000..446cf18fc --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -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> + ) +} diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx new file mode 100644 index 000000000..739a714cd --- /dev/null +++ b/src/components/Tooltip/index.web.tsx @@ -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> + ) +} diff --git a/src/components/hooks/useOnGesture/index.ts b/src/components/hooks/useOnGesture/index.ts new file mode 100644 index 000000000..6f0560661 --- /dev/null +++ b/src/components/hooks/useOnGesture/index.ts @@ -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]) +} diff --git a/src/components/hooks/useOnGesture/index.web.ts b/src/components/hooks/useOnGesture/index.web.ts new file mode 100644 index 000000000..6129fde10 --- /dev/null +++ b/src/components/hooks/useOnGesture/index.web.ts @@ -0,0 +1 @@ +export function useOnGesture() {} diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx new file mode 100644 index 000000000..f8d87a8f9 --- /dev/null +++ b/src/state/global-gesture-events/index.tsx @@ -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) +} diff --git a/src/state/global-gesture-events/index.web.tsx b/src/state/global-gesture-events/index.web.tsx new file mode 100644 index 000000000..5d6d53369 --- /dev/null +++ b/src/state/global-gesture-events/index.web.tsx @@ -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.') +} diff --git a/src/style.css b/src/style.css index a4a2c455f..35ffe0d3a 100644 --- a/src/style.css +++ b/src/style.css @@ -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); + } +} |