From 4778142ee8c3f3dccf3fad14abcda3c31ab59dd9 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:57:11 -0700 Subject: [PATCH] Create primary tooltip variant (#11140) --- oxlint-suppressions.json | 10 --- src/components/Tooltip/const.ts | 37 +++++++- src/components/Tooltip/index.e2e.tsx | 2 +- src/components/Tooltip/index.tsx | 86 ++++++++++--------- src/components/Tooltip/index.web.tsx | 63 ++++++++------ .../SubscribeProfileButton.tsx | 18 ++-- .../com/composer/threadgate/ThreadgateBtn.tsx | 26 +++--- src/view/screens/Storybook/Storybook.tsx | 2 + src/view/screens/Storybook/Tooltips.tsx | 55 ++++++++++++ 9 files changed, 190 insertions(+), 109 deletions(-) create mode 100644 src/view/screens/Storybook/Tooltips.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 685e7144c3..21dd9f702b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -406,16 +406,6 @@ "count": 1 } }, - "src/components/Tooltip/index.tsx": { - "typescript/no-base-to-string": { - "count": 1 - } - }, - "src/components/Tooltip/index.web.tsx": { - "typescript/no-base-to-string": { - "count": 1 - } - }, "src/components/WhoCanReply.tsx": { "typescript/no-floating-promises": { "count": 1 diff --git a/src/components/Tooltip/const.ts b/src/components/Tooltip/const.ts index cad7e0106c..eaa24f24a3 100644 --- a/src/components/Tooltip/const.ts +++ b/src/components/Tooltip/const.ts @@ -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, + }, + } +} diff --git a/src/components/Tooltip/index.e2e.tsx b/src/components/Tooltip/index.e2e.tsx index 905da174c1..ae8a9c4896 100644 --- a/src/components/Tooltip/index.e2e.tsx +++ b/src/components/Tooltip/index.e2e.tsx @@ -14,6 +14,6 @@ export function Content() { return null } -export function TextBubble() { +export function BubbleText() { return null } diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index e916ee0ed3..acabffc267 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -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({ 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(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}) { setHasLayedOut(true)}> + onLayout={() => setHasLaidOut(true)}> {children} ) @@ -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({ 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({ ]}> + style={{transformOrigin: opposite(position)}}> { @@ -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 ( - + - {c.map((child, i) => ( - - {child} - - ))} + + {children} + ) diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx index 9d36975aaa..fd017b43ca 100644 --- a/src/components/Tooltip/index.web.tsx +++ b/src/components/Tooltip/index.web.tsx @@ -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>({ +const TooltipContext = createContext< + Pick +>({ 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 ( {children} @@ -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 ( {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 ( - + - {c.map((child, i) => ( - - {child} - - ))} + + {children} + ) diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx index 7bbc677d47..ce9dcd05ae 100644 --- a/src/components/activity-notifications/SubscribeProfileButton.tsx +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -1,8 +1,6 @@ import {useCallback, useEffect, useState} from 'react' import {type ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' @@ -11,7 +9,6 @@ import {useDialogControl} from '#/components/Dialog' import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import * as Tooltip from '#/components/Tooltip' -import {Text} from '#/components/Typography' import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' import type * as bsky from '#/types/bsky' import {SubscribeProfileDialog} from './SubscribeProfileDialog' @@ -25,7 +22,7 @@ export function SubscribeProfileButton({ moderationOpts: ModerationOpts disableHint?: boolean }) { - const {_} = useLingui() + const {t: l} = useLingui() const requireEmailVerification = useRequireEmailVerification() const subscribeDialogControl = useDialogControl() const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = @@ -84,18 +81,15 @@ export function SubscribeProfileButton({ size="small" color={tooltipVisible ? 'primary_subtle' : 'secondary'} shape="round" - label={_(msg`Get notified when ${name} posts`)} + label={l`Get notified when ${name} posts`} onPress={wrappedOnPress}> - - - Get notified about new posts - - + + Get notified about new posts + - > }) { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const control = Dialog.useDialogControl() const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() const [showTooltip, setShowTooltip] = useState(false) + // eslint-disable-next-line react/hook-use-state const [tooltipWasShown] = useState(!threadgateNudged) useEffect(() => { @@ -134,8 +132,8 @@ export function ThreadgateBtn({ !postgate.embeddingRules || postgate.embeddingRules.length === 0 const anyoneCanInteract = anyoneCanReply && anyoneCanQuote const label = anyoneCanInteract - ? _(msg`Anyone can interact`) - : _(msg`Interaction limited`) + ? l`Anyone can interact` + : l`Interaction limited` return ( <> @@ -150,9 +148,7 @@ export function ThreadgateBtn({ testID="openReplyGateButton" onPress={onPress} label={label} - accessibilityHint={_( - msg`Opens a dialog to choose who can interact with this post`, - )}> + accessibilityHint={l`Opens a dialog to choose who can interact with this post`}> {label} @@ -160,13 +156,11 @@ export function ThreadgateBtn({ - - - Psst! You can edit who can interact with this post. - - + + Psst! You can edit who can interact with this post. + - { diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index cc11332126..1583e923f5 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -26,6 +26,7 @@ import {Shadows} from './Shadows' import {Spacing} from './Spacing' import {Theming} from './Theming' import {Toasts} from './Toasts' +import {Tooltips} from './Tooltips' import {Typography} from './Typography' export default function Storybook() { @@ -127,6 +128,7 @@ export default function Storybook() { + diff --git a/src/view/screens/Storybook/Tooltips.tsx b/src/view/screens/Storybook/Tooltips.tsx new file mode 100644 index 0000000000..d9955ec9d9 --- /dev/null +++ b/src/view/screens/Storybook/Tooltips.tsx @@ -0,0 +1,55 @@ +import {useState} from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Tooltip from '#/components/Tooltip' +import {H1} from '#/components/Typography' + +export function Tooltips() { + const [defaultVisible, setDefaultVisible] = useState(false) + const [primaryVisible, setPrimaryVisible] = useState(false) + + return ( + +

Tooltips

+ + + + + + + + This is a default tooltip. + + + + + + + + + This is a primary tooltip. + + + +
+ ) +}