Create primary tooltip variant (#11140)
This commit is contained in:
@@ -1,6 +1,41 @@
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, select, type Theme} from '#/alf'
|
||||
|
||||
/**
|
||||
* Visual variant for the tooltip surface. `default` is a neutral floating card;
|
||||
* `primary` is a subtle blue surface matching the `primary_subtle` button.
|
||||
*/
|
||||
export type TooltipColor = 'default' | 'primary'
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Resolves the surface (background/arrow fill) and text colors for a tooltip
|
||||
* variant. Kept here so the native and web implementations stay in sync.
|
||||
*/
|
||||
export function getTooltipStyle(t: Theme, color: TooltipColor) {
|
||||
if (color === 'primary') {
|
||||
return {
|
||||
surface: t.palette.primary_50,
|
||||
text: t.palette.primary_600,
|
||||
border: {
|
||||
color: t.atoms.border_contrast_low.borderColor,
|
||||
width: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
surface: select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||
}),
|
||||
text: t.atoms.text.color,
|
||||
border: {
|
||||
color: undefined,
|
||||
width: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ export function Content() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function TextBubble() {
|
||||
export function BubbleText() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -14,14 +13,16 @@ 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 {atoms as a, 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,
|
||||
getTooltipStyle,
|
||||
MIN_EDGE_SPACE,
|
||||
type TooltipColor,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -56,10 +57,10 @@ 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'
|
||||
color: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}
|
||||
@@ -79,6 +80,7 @@ type TargetContextType = {
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
color: 'default',
|
||||
visible: false,
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
@@ -94,11 +96,13 @@ TargetContext.displayName = 'TargetContext'
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
color = 'default',
|
||||
visible: requestVisible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
color?: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
@@ -126,8 +130,8 @@ export function Outer({
|
||||
}
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({position, visible, onVisibleChange}),
|
||||
[position, visible, onVisibleChange],
|
||||
() => ({position, color, visible, onVisibleChange}),
|
||||
[position, color, visible, onVisibleChange],
|
||||
)
|
||||
const targetCtx = useMemo(
|
||||
() => ({
|
||||
@@ -149,13 +153,13 @@ export function Outer({
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||
const [hasLayedOut, setHasLayedOut] = useState(false)
|
||||
const [hasLaidOut, setHasLaidOut] = useState(false)
|
||||
const targetRef = useRef<View>(null)
|
||||
const containerRef = useContext(TooltipProviderContext)
|
||||
const keyboardIsOpen = useIsKeyboardVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldMeasure || !hasLayedOut) return
|
||||
if (!shouldMeasure || !hasLaidOut) return
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
@@ -179,7 +183,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
||||
}, [
|
||||
shouldMeasure,
|
||||
setTargetMeasurements,
|
||||
hasLayedOut,
|
||||
hasLaidOut,
|
||||
containerRef,
|
||||
keyboardIsOpen,
|
||||
])
|
||||
@@ -188,7 +192,7 @@ export function Target({children}: {children: React.ReactNode}) {
|
||||
<View
|
||||
collapsable={false}
|
||||
ref={targetRef}
|
||||
onLayout={() => setHasLayedOut(true)}>
|
||||
onLayout={() => setHasLaidOut(true)}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -201,7 +205,7 @@ export function Content({
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {position, color, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {targetMeasurements} = useContext(TargetContext)
|
||||
const isWithinProvider = !!useContext(TooltipProviderContext)
|
||||
const requestClose = useCallback(() => {
|
||||
@@ -217,8 +221,9 @@ export function Content({
|
||||
<Bubble
|
||||
label={label}
|
||||
position={position}
|
||||
color={color}
|
||||
/*
|
||||
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
|
||||
* Gotta pass these in here. Inside the Bubble, we're Portal-ed outside
|
||||
* the context providers.
|
||||
*/
|
||||
targetMeasurements={targetMeasurements}
|
||||
@@ -233,12 +238,14 @@ function Bubble({
|
||||
children,
|
||||
label,
|
||||
position,
|
||||
color,
|
||||
requestClose,
|
||||
targetMeasurements,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
position: TooltipContextType['position']
|
||||
color: TooltipColor
|
||||
requestClose: () => void
|
||||
targetMeasurements: Exclude<
|
||||
TargetContextType['targetMeasurements'],
|
||||
@@ -246,6 +253,7 @@ function Bubble({
|
||||
>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const style = getTooltipStyle(t, color)
|
||||
const insets = useSafeAreaInsets()
|
||||
const dimensions = useWindowDimensions()
|
||||
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
||||
@@ -383,21 +391,19 @@ function Bubble({
|
||||
]}>
|
||||
<Animated.View
|
||||
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
||||
style={{transformOrigin: oppposite(position)}}>
|
||||
style={{transformOrigin: opposite(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,
|
||||
}),
|
||||
{
|
||||
backgroundColor: style.surface,
|
||||
borderTopLeftRadius: a.rounded_2xs.borderRadius,
|
||||
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
||||
borderColor: style.border.color,
|
||||
borderTopWidth: style.border.width,
|
||||
borderLeftWidth: style.border.width,
|
||||
width: ARROW_SIZE,
|
||||
height: ARROW_SIZE,
|
||||
transform: [{rotate: '45deg'}],
|
||||
@@ -410,21 +416,12 @@ function Bubble({
|
||||
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,
|
||||
a.rounded_md,
|
||||
t.atoms.shadow_xs,
|
||||
{
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height:
|
||||
BUBBLE_SHADOW_OFFSET *
|
||||
(coords.computedPosition === 'bottom' ? -1 : 1),
|
||||
},
|
||||
backgroundColor: style.surface,
|
||||
borderColor: style.border.color,
|
||||
borderWidth: style.border.width,
|
||||
},
|
||||
]}
|
||||
onLayout={e => {
|
||||
@@ -440,7 +437,7 @@ function Bubble({
|
||||
)
|
||||
}
|
||||
|
||||
function oppposite(position: 'top' | 'bottom') {
|
||||
function opposite(position: 'top' | 'bottom') {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'center bottom'
|
||||
@@ -451,16 +448,23 @@ function oppposite(position: 'top' | 'bottom') {
|
||||
}
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
export function BubbleText({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {color} = useContext(TooltipContext)
|
||||
const style = getTooltipStyle(t, color)
|
||||
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||
return (
|
||||
<Content label={c.join(' ')}>
|
||||
<Content label={label}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
</Text>
|
||||
))}
|
||||
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {Children, createContext, useContext, useMemo} from 'react'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {utils} from '@bsky.app/alf'
|
||||
import {Popover} from 'radix-ui'
|
||||
|
||||
import {atoms as a, flatten, select, useTheme} from '#/alf'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
getTooltipStyle,
|
||||
MIN_EDGE_SPACE,
|
||||
type TooltipColor,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -19,26 +21,32 @@ Provider.displayName = 'TooltipProvider'
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
color: TooltipColor
|
||||
onVisibleChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
|
||||
const TooltipContext = createContext<
|
||||
Pick<TooltipContextType, 'position' | 'color'>
|
||||
>({
|
||||
position: 'bottom',
|
||||
color: 'default',
|
||||
})
|
||||
TooltipContext.displayName = 'TooltipContext'
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
color = 'default',
|
||||
visible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
color?: TooltipColor
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
const ctx = useMemo(() => ({position}), [position])
|
||||
const ctx = useMemo(() => ({position, color}), [position, color])
|
||||
return (
|
||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||
@@ -62,7 +70,8 @@ export function Content({
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {position} = useContext(TooltipContext)
|
||||
const {position, color} = useContext(TooltipContext)
|
||||
const style = getTooltipStyle(t, color)
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -78,28 +87,19 @@ export function Content({
|
||||
}}
|
||||
style={flatten([
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
light: t.atoms.bg,
|
||||
dark: t.atoms.bg_contrast_100,
|
||||
dim: t.atoms.bg_contrast_100,
|
||||
}),
|
||||
{
|
||||
backgroundColor: style.surface,
|
||||
borderColor: style.border.color,
|
||||
borderWidth: style.border.width,
|
||||
borderStyle: 'solid',
|
||||
minWidth: 'max-content',
|
||||
boxShadow: select(t.name, {
|
||||
light: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
||||
dark: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
||||
dim: `0 0 24px ${utils.alpha(t.palette.black, 0.2)}`,
|
||||
}),
|
||||
boxShadow: `0 0 24px ${utils.alpha(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,
|
||||
})}
|
||||
fill={style.surface}
|
||||
/>
|
||||
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
||||
{children}
|
||||
@@ -109,16 +109,23 @@ export function Content({
|
||||
)
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
export function BubbleText({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {color} = useContext(TooltipContext)
|
||||
const style = getTooltipStyle(t, color)
|
||||
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||
return (
|
||||
<Content label={c.join(' ')}>
|
||||
<Content label={label}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
</Text>
|
||||
))}
|
||||
<Text style={[a.text_sm, a.leading_snug, {color: style.text}]}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user