feat(admin-sidebar): one pointer-following tooltip over locked pages; logical tooltip sides (#15926)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-10-12 23:25:32 +00:00
1 parent e540e6f37f
commit c9df4da344
26 files changed
+185 -77

No files matched your search

@@ -89,14 +89,14 @@ export const TooltipSides: Story = {
<LinkButton href="/" tooltip="Tooltip on top" tooltipSide="top">
top
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on right" tooltipSide="right">
right
<LinkButton href="/" tooltip="Tooltip at the end" tooltipSide="end">
end
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on bottom" tooltipSide="bottom">
bottom
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on left" tooltipSide="left">
left
<LinkButton href="/" tooltip="Tooltip at the start" tooltipSide="start">
start
</LinkButton>
</div>
),
@@ -96,7 +96,7 @@ function FoldedTooltip({ label, folded, children }: FoldedTooltipProps) {
/* `suppressed`, not a controlled `open`: hover stays Radix's to track, so an
unfolded tab keeps no hover state of its own that a later fold could act on. */
return (
<Tooltip tooltip={label} side="right" suppressed={!effectiveFolded}>
<Tooltip tooltip={label} side="end" suppressed={!effectiveFolded}>
{children}
</Tooltip>
);
@@ -196,7 +196,7 @@ function SidebarTab({
const trigger =
overlay &&
(tooltip ? (
<Tooltip tooltip={tooltip} side="right">
<Tooltip tooltip={tooltip} side="end">
{overlay}
</Tooltip>
) : label !== undefined ? (
@@ -168,7 +168,7 @@ function InputPasswordTypeIn({
variant={isRevealed ? "action" : undefined}
prominence="tertiary"
size="sm"
tooltipSide="left"
tooltipSide="start"
tooltip={toggleLabel}
aria-label={toggleLabel}
/>
+1 -1
View File
@@ -164,7 +164,7 @@ Forwards all [Radix Tabs.Root](https://www.radix-ui.com/docs/primitives/componen
| `value` | `string` | **required** | Tab value |
| `icon` | `FunctionComponent<IconProps>` | — | Icon before the label |
| `tooltip` | `string` | — | Tooltip on hover |
| `tooltipSide` | `"top" \| "bottom" \| "left" \| "right"` | `"top"` | Tooltip placement |
| `tooltipSide` | `"top" \| "bottom" \| "start" \| "end"` | `"top"` | Tooltip placement; `start`/`end` follow the text direction |
| `disabled` | `boolean` | — | Disables the tab (tooltip still shows) |
| `isLoading` | `boolean` | — | Shows a spinner after the label |
@@ -14,7 +14,7 @@ import { spacingToRem } from "@opal/shared";
// instead of the barrel's RTL-mirrored wrappers.
import SvgChevronLeft from "@opal/icons/chevron-left";
import SvgChevronRight from "@opal/icons/chevron-right";
import { Tooltip, Button } from "@opal/components";
import { Tooltip, Button, type TooltipSide } from "@opal/components";
import { Content } from "@opal/layouts";
import {
TabsContext,
@@ -250,7 +250,7 @@ interface TabsTriggerProps extends WithoutStyles<
/** Tooltip shown on hover. */
tooltip?: string;
/** Side where the tooltip appears. @default "top" */
tooltipSide?: "top" | "bottom" | "left" | "right";
tooltipSide?: TooltipSide;
/** Icon rendered before the label. */
icon?: IconFunctionComponent;
/** Show a loading spinner after the label. */
@@ -13,8 +13,8 @@ off while keeping the trigger mounted.
| Prop | Type | Default | Description |
| --------------- | ---------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- |
| `tooltip` | `ReactNode \| RichStr` | — | Tooltip content. `string`/`RichStr` rendered via `Text`; `ReactNode` rendered as-is. `undefined` = no tooltip. |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `"right"` | Which side the tooltip appears on |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along the tooltip's side axis |
| `side` | `"top" \| "bottom" \| "start" \| "end"` | `"end"` | Which side the tooltip appears on. `start`/`end` are logical: in a right-to-left layout `start` is the right side |
| `align` | `"start" \| "center" \| "end" \| "follow"` | `"center"` | Alignment along the tooltip's side axis. `follow` slides it to stay level with the pointer, for a tall or wide trigger |
| `suppressed` | `boolean` | `false` | Shows nothing on hover, but keeps the trigger mounted |
| `delayDuration` | `number` | — | Delay in ms before the tooltip appears on hover |
| `sideOffset` | `number` | `4` | Distance in pixels between the trigger and the tooltip |
@@ -64,3 +64,22 @@ it("does not reveal a hover the pointer already left when suppression lifts", as
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
it('keeps showing and tracking the pointer with align="follow"', async () => {
const user = userEvent.setup();
render(
<TooltipPrimitive.Provider delayDuration={0}>
<Tooltip tooltip="Locked" align="follow">
<div>Block</div>
</Tooltip>
</TooltipPrimitive.Provider>
);
const block = screen.getByText("Block");
await user.hover(block);
await user.pointer({ target: block, coords: { clientX: 4, clientY: 40 } });
await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
"use client";
import "@opal/components/tooltip/styles.css";
import { useRef, useState } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { RichStr } from "@opal/types";
import { Text } from "@opal/components";
@@ -10,8 +11,17 @@ import { isRichStr } from "@opal/components/text/InlineMarkdown";
// Types
// ---------------------------------------------------------------------------
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "start" | "center" | "end";
/**
* Which side of the trigger the tooltip appears on. `start` and `end` are
* logical: in a right-to-left layout `start` is the right side.
*/
type TooltipSide = "top" | "bottom" | "start" | "end";
/**
* Where the tooltip sits along its side. `follow` slides it to stay level
* with the pointer: vertically for `start`/`end` sides, horizontally for
* `top`/`bottom`. For a tall or wide trigger, such as a block of items.
*/
type TooltipAlign = "start" | "center" | "end" | "follow";
interface TooltipProps {
/**
@@ -23,7 +33,7 @@ interface TooltipProps {
*/
tooltip?: React.ReactNode | RichStr;
/** Which side the tooltip appears on. @default "right" */
/** Which side the tooltip appears on. @default "end" */
side?: TooltipSide;
/** Alignment along the tooltip's side axis. @default "center" */
@@ -55,6 +65,23 @@ interface TooltipProps {
children: React.ReactElement;
}
// ---------------------------------------------------------------------------
// Direction
// ---------------------------------------------------------------------------
/** Whether the page reads right to left. The root layout sets `dir`. */
function isRtl(): boolean {
return (
typeof document !== "undefined" && document.documentElement.dir === "rtl"
);
}
/** Radix positions by physical side, so `start` and `end` follow `dir`. */
function physicalSide(side: TooltipSide): "top" | "bottom" | "left" | "right" {
if (side === "top" || side === "bottom") return side;
return (side === "start") !== isRtl() ? "left" : "right";
}
// ---------------------------------------------------------------------------
// Tooltip
// ---------------------------------------------------------------------------
@@ -87,15 +114,42 @@ interface TooltipProps {
*/
function Tooltip({
tooltip,
side = "right",
side = "end",
align = "center",
suppressed,
delayDuration,
sideOffset = 4,
children,
}: TooltipProps) {
// With `align="follow"`: the pointer's distance from the trigger's start
// along the side's axis, and the tooltip's size on that axis.
const [pointerOffset, setPointerOffset] = useState<number | null>(null);
const [contentSize, setContentSize] = useState<number>(0);
const contentRef = useRef<HTMLDivElement>(null);
const alongY: boolean = side === "start" || side === "end";
if (tooltip == null) return children;
function trackPointer(event: React.PointerEvent<HTMLElement>) {
const rect = event.currentTarget.getBoundingClientRect();
// Measured from the trigger's start edge, which `align="start"` uses.
setPointerOffset(
alongY
? event.clientY - rect.top
: isRtl()
? rect.right - event.clientX
: event.clientX - rect.left
);
const content = contentRef.current;
if (content) {
setContentSize(alongY ? content.offsetHeight : content.offsetWidth);
}
}
// Aligned to the trigger's start, then moved so the tooltip's middle
// meets the pointer. Radix still shifts it back into view at an edge.
const following: boolean = align === "follow" && pointerOffset !== null;
const content =
typeof tooltip === "string" || isRichStr(tooltip) ? (
<Text font="secondary-body" color="inherit" as="p">
@@ -115,14 +169,24 @@ function Tooltip({
appears the moment the suppression lifts. */
disableHoverableContent={suppressed}
>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Trigger
asChild
onPointerMove={align === "follow" ? trackPointer : undefined}
>
{children}
</TooltipPrimitive.Trigger>
{!suppressed && (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
/* Dark surface in both themes, so the content resolves dark tokens. */
ref={contentRef}
className="dark opal-tooltip"
side={side}
align={align}
side={physicalSide(side)}
// Until the pointer moves, a follower sits centered.
align={following ? "start" : align === "follow" ? "center" : align}
alignOffset={
following ? (pointerOffset ?? 0) - contentSize / 2 : undefined
}
sideOffset={sideOffset}
>
{content}
+2 -1
View File
@@ -22,7 +22,8 @@ disabled: screen readers read the reason in the region and announce it when it c
| `disabled` | `boolean` | `false` | Applies disabled styling when truthy |
| `allowClick` | `boolean` | `false` | Re-enables pointer events and keyboard input while keeping disabled visuals |
| `tooltip` | `string \| RichStr` | — | Tooltip shown on hover when disabled (implies `allowClick`). Supports `markdown()`. |
| `tooltipSide` | `"top" \| "bottom" \| "left" \| "right"` | `"right"` | Which side the tooltip appears on |
| `tooltipSide` | `"top" \| "bottom" \| "start" \| "end"` | `"end"` | Which side the tooltip appears on |
| `tooltipAlign` | `TooltipAlign` | `"center"` | Where the tooltip sits along its side; `"follow"` keeps it level with the pointer. See `Tooltip`'s `align` |
## CSS behavior
@@ -1,6 +1,6 @@
import "@opal/core/disabled/styles.css";
import React from "react";
import { Tooltip, type TooltipSide } from "@opal/components";
import { Tooltip, type TooltipAlign, type TooltipSide } from "@opal/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import type { RichStr, WithoutStyles } from "@opal/types";
@@ -37,6 +37,9 @@ interface DisabledProps extends WithoutStyles<
/** Which side the tooltip appears on. @default "right" */
tooltipSide?: TooltipSide;
/** Where the tooltip sits along its side. See `Tooltip`'s `align`. @default "center" */
tooltipAlign?: TooltipAlign;
children?: React.ReactNode;
}
@@ -69,7 +72,8 @@ function Disabled({
disabled,
allowClick,
tooltip,
tooltipSide = "right",
tooltipSide = "end",
tooltipAlign = "center",
ref,
children,
...rest
@@ -112,7 +116,7 @@ function Disabled({
if (!showTooltip) return wrapper;
return (
<Tooltip tooltip={tooltip} side={tooltipSide}>
<Tooltip tooltip={tooltip} side={tooltipSide} align={tooltipAlign}>
{wrapper}
</Tooltip>
);
@@ -190,7 +190,7 @@ function ContentLg({
prominence="internal"
size={config.editButtonSize}
tooltip={strings.edit}
tooltipSide="right"
tooltipSide="end"
onClick={startEditing}
/>
</div>
@@ -317,7 +317,7 @@ function ContentMd({
prominence="internal"
size={config.editButtonSize}
tooltip={strings.edit}
tooltipSide="right"
tooltipSide="end"
onClick={startEditing}
/>
</div>
@@ -239,7 +239,7 @@ function ContentXl({
prominence="internal"
size={config.editButtonSize}
tooltip={strings.edit}
tooltipSide="right"
tooltipSide="end"
onClick={startEditing}
/>
</div>
+9 -4
View File
@@ -33,10 +33,10 @@ Sidebar entry point. Handles three viewport sizes:
- **Mobile** (`≤ 724 px`) — full-height fixed overlay with a tinted and
blurred backdrop that closes on click.
| Prop | Type | Default | Description |
| ---------- | ----------- | ------- | ------------------------------------- |
| `foldable` | `boolean` | `false` | Enable fold/unfold on desktop |
| `children` | `ReactNode` | — | `Header`, `Body`, `Footer`, `Section` |
| Prop | Type | Default | Description |
| ---------- | ----------- | ------- | ------------------------------------------------ |
| `foldable` | `boolean` | `false` | Enable fold/unfold on desktop |
| `children` | `ReactNode` | — | `Header`, `Body`, `Footer`, `Section`, `Divider` |
### `SidebarLayouts.Header`
@@ -75,6 +75,11 @@ optional hover-revealed action.
| `disabled` | `boolean` | — | Dims the section header to indicate it is unavailable |
| `children` | `ReactNode` | — | |
### `SidebarLayouts.Divider`
Horizontal rule between groups in the sidebar. Takes no props. It has
`0.5rem` padding on all sides.
## Hooks
### `useSidebarState()`
@@ -11,7 +11,14 @@ import {
useRef,
} from "react";
import { usePathname } from "next/navigation";
import { Button, ShadowDiv, Spacer, Text, Tooltip } from "@opal/components";
import {
Button,
Divider,
ShadowDiv,
Spacer,
Text,
Tooltip,
} from "@opal/components";
import { iconWrapper } from "@opal/components/buttons/icon-wrapper";
import { Disabled, Hoverable, Interactive } from "@opal/core";
import { SvgSidebar } from "@opal/icons";
@@ -183,7 +190,7 @@ function SidebarHeader({
prominence="tertiary"
aria-label={foldLabel}
tooltip={foldLabel}
tooltipSide={folded ? "right" : "bottom"}
tooltipSide={folded ? "end" : "bottom"}
size="md"
onClick={toggleFolded}
/>
@@ -199,7 +206,7 @@ function SidebarHeader({
// sidebar can still be opened where there is no hover to reveal anything —
// touch devices and keyboard navigation.
const foldedLogoButton = (
<Tooltip tooltip={foldLabel} side="right">
<Tooltip tooltip={foldLabel} side="end">
<Interactive.Stateless
prominence="tertiary"
type="button"
@@ -370,6 +377,14 @@ function SidebarSection({
);
}
// ---------------------------------------------------------------------------
// Divider — horizontal rule between groups in the sidebar
// ---------------------------------------------------------------------------
function SidebarDivider() {
return <Divider paddingParallel={2} paddingPerpendicular={2} />;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
@@ -380,5 +395,6 @@ export {
SidebarBody as Body,
SidebarFooter as Footer,
SidebarSection as Section,
SidebarDivider as Divider,
};
export type { SidebarRootProps };
+2 -2
View File
@@ -12,7 +12,7 @@ import {
import { FileUpload } from "@/components/admin/connectors/FileUpload";
import * as Yup from "yup";
import { FormBodyBuilder } from "./admin/connectors/types";
import { InputSingleSelect } from "@opal/components";
import { InputSingleSelect, type TooltipSide } from "@opal/components";
import { FiInfo, FiX } from "react-icons/fi";
import { useTranslations } from "next-intl";
import ReactMarkdown from "react-markdown";
@@ -707,7 +707,7 @@ interface BooleanFormFieldProps {
optional?: boolean;
tooltip?: string;
disabledTooltip?: string;
disabledTooltipSide?: "top" | "bottom" | "left" | "right";
disabledTooltipSide?: TooltipSide;
onChange?: (checked: boolean) => void;
}
+3 -1
View File
@@ -17,7 +17,9 @@ export const HoverPopup = ({
return (
<Tooltip
tooltip={popupContent}
side={direction === "left-top" ? "left" : direction}
side={
direction === "left" || direction === "left-top" ? "start" : direction
}
>
<div>{mainContent}</div>
</Tooltip>
@@ -64,7 +64,7 @@ export const CustomTooltipSide: Story = {
"Hover to see the tooltip appear on the right side instead of the default top.",
mainUiBody: true,
text04: true,
side: "right",
side: "end",
},
decorators: [
(Story) => (
@@ -2,7 +2,7 @@
import React, { useState, useRef, useCallback, useLayoutEffect } from "react";
import { TextProps } from "@/refresh-components/texts/Text";
import { Tooltip } from "@opal/components";
import { Tooltip, type TooltipSide } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@opal/utils";
@@ -37,7 +37,7 @@ function useTruncated(children: React.ReactNode) {
}
export interface TruncatedProps extends TextProps {
side?: "top" | "right" | "bottom" | "left";
side?: TooltipSide;
sideOffset?: number;
disable?: boolean;
}
@@ -93,7 +93,7 @@ export default function ChatDocumentDisplay({
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
)}
<Truncated className="line-clamp-2" side="left">
<Truncated className="line-clamp-2" side="start">
{title}
</Truncated>
</div>
+29 -32
View File
@@ -14,8 +14,9 @@ import { useSettings } from "@/lib/settings/hooks";
import { SidebarLayouts, useSidebarState } from "@opal/layouts";
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
import { useUser } from "@/providers/UserProvider";
import { Divider, InputTypeIn, SidebarTab } from "@opal/components";
import { InputTypeIn, SidebarTab } from "@opal/components";
import { SvgSearch, SvgX } from "@opal/icons";
import { Disabled } from "@opal/core";
import {
useBillingInformation,
useLicense,
@@ -163,42 +164,38 @@ export default function AdminSidebar() {
</React.Fragment>
))}
{disabledGroups.length > 0 && <SidebarLayouts.Divider />}
{disabledGroups.length > 0 && (
<>
<Divider paddingPerpendicular={0} />
{/* Empty div here just to add spacing (via the `gap` property on `SidebarLayouts.Body`) */}
<div />
</>
<Disabled
disabled
// One tooltip over a tall block: it stays level with the pointer.
tooltipAlign="follow"
tooltip={markdown(
disabled.every((item) => item.requiredTier === Tier.ENTERPRISE)
? t("adminSidebar.enterpriseOnly.tooltip")
: t("adminSidebar.businessOrEnterpriseOnly.tooltip")
)}
>
{disabledGroups.map((group, groupIndex) => (
<SidebarLayouts.Section
key={`disabled-${groupIndex}`}
title={
group.sectionId ? sectionLabels[group.sectionId] : undefined
}
>
{group.items.map(({ link, icon, nameId }) => (
<SidebarTab key={link} icon={icon}>
{navLabels[nameId]}
</SidebarTab>
))}
</SidebarLayouts.Section>
))}
</Disabled>
)}
{disabledGroups.map((group, groupIndex) => (
<React.Fragment key={`disabled-${groupIndex}`}>
<SidebarLayouts.Section
title={
group.sectionId ? sectionLabels[group.sectionId] : undefined
}
disabled
>
{group.items.map(({ link, icon, nameId, requiredTier }) => (
<SidebarTab
key={link}
disabled
icon={icon}
tooltip={markdown(
requiredTier === Tier.ENTERPRISE
? t("adminSidebar.enterpriseOnly.tooltip")
: t("adminSidebar.businessOrEnterpriseOnly.tooltip")
)}
>
{navLabels[nameId]}
</SidebarTab>
))}
</SidebarLayouts.Section>
</React.Fragment>
))}
</SidebarLayouts.Body>
<SidebarLayouts.Footer>
{!folded && <Divider paddingPerpendicular={2} />}
{!folded && <SidebarLayouts.Divider />}
<SidebarTab
icon={SvgX}
href={pathname?.startsWith("/admin/craft") ? "/craft/v1" : "/app"}
@@ -166,7 +166,7 @@ function CloudDisabled({
: tooltipProp;
return (
<Disabled disabled={isDisabled} tooltip={tooltip} tooltipSide="right">
<Disabled disabled={isDisabled} tooltip={tooltip} tooltipSide="end">
{children}
</Disabled>
);
@@ -298,7 +298,7 @@ export default function EditServiceAccountModal({
rightChildren={
<Tooltip
tooltip={t("editModal.removeGroupButton.tooltip")}
side="left"
side="start"
>
<SvgLogOut height={16} width={16} />
</Tooltip>
@@ -269,7 +269,7 @@ export default function EditUserModal({
rightChildren={
<Tooltip
tooltip={t("editModal.removeGroupButton.tooltip")}
side="left"
side="start"
>
<SvgLogOut height={16} width={16} />
</Tooltip>
+1 -1
View File
@@ -196,7 +196,7 @@ export default function GroupsCell({
icon={SvgEdit}
prominence="tertiary"
tooltip={t("groupsCell.editButton.tooltip")}
tooltipSide="left"
tooltipSide="start"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
@@ -43,7 +43,7 @@ function StatCell({ value, label, onFilter }: StatCellProps) {
prominence="tertiary"
icon={SvgFilterPlus}
tooltip={t("summary.filterButton.tooltip")}
tooltipSide="left"
tooltipSide="start"
onClick={(e) => {
e.stopPropagation();
onFilter();