Skip to content

Commit 846813c

Browse files
Danelegendclaudejmelahman
authored
fix(opal): anchor SidebarTab tooltips to the control so the row never remounts (#14245) to release v4.6 (#14249)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jamison Lahman <jamison@lahman.dev>
1 parent a1a60b5 commit 846813c

6 files changed

Lines changed: 366 additions & 63 deletions

File tree

web/lib/opal/src/components/buttons/sidebar-tab/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A sidebar navigation tab built on `Interactive.Stateful` > `Interactive.Containe
1010
div.opal-sidebar-tab <- folded styling hook (see styles.css)
1111
└─ Interactive.Stateful <- variant (sidebar-heavy | sidebar-light), state, disabled
1212
└─ Interactive.Container <- rounding, height, width
13-
├─ Link | button? (absolute overlay — the click target)
13+
├─ Link | button? (absolute overlay — the click target; also the tooltip trigger)
1414
├─ rightChildren? (absolute, above the overlay for inline actions)
1515
└─ ContentAction (icon + title + truncation spacer)
1616
```
@@ -31,6 +31,8 @@ Pass `folded` only to override the sidebar — outside a sidebar, in Storybook,
3131

3232
The folded-name tooltip is the one part that stays in JS: CSS cannot arm a tooltip. It lives in a small wrapper that subscribes to the fold state on the tab's behalf, so a fold re-renders the wrapper and nothing below it. The wrapper keeps the tooltip mounted and passes `suppressed` while the tab is unfolded, so hover stays Radix's to track and an unfolded tab holds no hover state of its own.
3333

34+
Both tooltips (the folded name and an explicit `tooltip`) wrap the overlay control, not the tab. The tab's own tree shape therefore never depends on whether there is a tooltip, so `children` can switch from a label to an element — an inline rename input — without remounting the row and dropping focus. A disabled tab has no control, so it renders an inert overlay as the trigger when it has a tooltip.
35+
3436
## Props
3537

3638
| Prop | Type | Default | Description |

web/lib/opal/src/components/buttons/sidebar-tab/SidebarTab.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,60 @@ it("names the tab for assistive technology in both states", () => {
113113
// The label is hidden by CSS while folded, so the name comes from the link.
114114
expect(screen.getByLabelText("Settings")).toBeInTheDocument();
115115
});
116+
117+
it("keeps the same DOM node when children switch from a label to an element", () => {
118+
// An inline rename swaps the string label for an input and drops `href`.
119+
// A remount here would unmount that input before it could take focus.
120+
const { rerender } = render(
121+
<SidebarTab href="/app?chatId=1" onClick={() => {}}>
122+
My chat
123+
</SidebarTab>
124+
);
125+
const before = screen.getByText("My chat").closest(".opal-sidebar-tab");
126+
expect(before).not.toBeNull();
127+
128+
rerender(
129+
<SidebarTab>
130+
<input aria-label="Rename chat" defaultValue="My chat" />
131+
</SidebarTab>
132+
);
133+
const after = screen
134+
.getByRole("textbox", { name: "Rename chat" })
135+
.closest(".opal-sidebar-tab");
136+
expect(after).toBe(before);
137+
});
138+
139+
it("shows the folded label tooltip from the tab's control", async () => {
140+
const user = userEvent.setup();
141+
render(
142+
<TooltipPrimitive.Provider delayDuration={0}>
143+
<FoldedSidebar foldable />
144+
</TooltipPrimitive.Provider>
145+
);
146+
await user.hover(screen.getByLabelText("Settings"));
147+
await waitFor(() => {
148+
expect(screen.getByRole("tooltip")).toHaveTextContent("Settings");
149+
});
150+
});
151+
152+
it("shows an explicit tooltip on a disabled tab", async () => {
153+
// A disabled tab has no control, so an inert overlay is the trigger.
154+
const user = userEvent.setup();
155+
render(
156+
<TooltipPrimitive.Provider delayDuration={0}>
157+
<SidebarTab disabled tooltip="Enterprise only">
158+
Groups
159+
</SidebarTab>
160+
</TooltipPrimitive.Provider>
161+
);
162+
const tab = screen.getByText("Groups").closest(".opal-sidebar-tab")!;
163+
const overlay = tab.querySelector('[aria-hidden="true"].inset-0');
164+
expect(overlay).not.toBeNull();
165+
// Disabled is `aria-disabled` on a div, never a native disabled control:
166+
// browsers drop pointer events inside one, which would mute the trigger.
167+
expect(tab.querySelector("button[disabled]")).toBeNull();
168+
await user.hover(overlay!);
169+
await waitFor(() => {
170+
expect(screen.getByRole("tooltip")).toHaveTextContent("Enterprise only");
171+
});
172+
});

web/lib/opal/src/components/buttons/sidebar-tab/components.tsx

Lines changed: 58 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Interactive, type InteractiveStatefulVariant } from "@opal/core";
88
import { ContentAction } from "@opal/layouts";
99
import { useSidebarFolded } from "@opal/layouts/sidebar/context";
1010
import { Tooltip } from "@opal/components";
11+
import { cn } from "@opal/utils";
1112
import Link from "next/link";
1213

1314
// ---------------------------------------------------------------------------
@@ -81,11 +82,11 @@ interface FoldedTooltipProps {
8182
* This is the one part of the folded look that CSS cannot express, so it is
8283
* split out: this component subscribes to the fold state, and the tab does
8384
* not. On a fold toggle React re-renders this wrapper alone — `children` is
84-
* the same element it received before, so the tab below it never re-renders.
85+
* the same element it received before, so nothing below it re-renders.
8586
*
8687
* The tooltip stays mounted and is suppressed while unfolded instead of being
8788
* added and removed. Dropping it would change the tree shape on a fold, which
88-
* remounts the tab and cuts the label's fade short.
89+
* remounts the trigger.
8990
*/
9091
function FoldedTooltip({ label, folded, children }: FoldedTooltipProps) {
9192
const foldedFromSidebar = useSidebarFolded();
@@ -146,14 +147,6 @@ function SidebarTab({
146147
<div className="w-0 group-hover/SidebarTab:w-6" />
147148
);
148149

149-
/* The click target is an overlay that covers the whole row: a `Link` when
150-
`href` is set, a `button` otherwise. It stays a sibling of the content so that
151-
`rightChildren` and interactive icons remain valid nested controls. The focus
152-
outline is inset because the container clips its overflow. `cursor-pointer` is
153-
explicit because the UA stylesheet gives `button` a default cursor, which wins
154-
over the value inherited from `.interactive`. */
155-
const overlayClassName =
156-
"absolute z-99 inset-0 rounded-08 cursor-pointer outline-border-04 outline-offset-[-2px] focus-visible:outline-2";
157150
/* The overlay holds no text of its own, and a folded tab hides its label, so
158151
name the overlay explicitly. String children name it directly. Other content
159152
(truncated or animated titles) names it through the element that renders the
@@ -164,24 +157,58 @@ function SidebarTab({
164157
label !== undefined
165158
? { "aria-label": label }
166159
: { "aria-labelledby": labelId };
167-
const overlay = disabled ? null : href ? (
168-
<Link
169-
href={href as Route}
170-
scroll={false}
171-
onClick={onClick}
172-
{...labelProps}
173-
className={overlayClassName}
174-
/>
175-
) : onClick ? (
176-
<button
177-
type={type ?? "button"}
178-
onClick={onClick}
179-
{...labelProps}
180-
className={overlayClassName}
181-
/>
182-
) : null;
183-
184-
const content = (
160+
161+
/* The click target is an overlay that covers the whole row: a `Link` when
162+
`href` is set, a `button` otherwise. It stays a sibling of the content so that
163+
`rightChildren` and interactive icons remain valid nested controls. The focus
164+
outline is inset because the container clips its overflow. `cursor-pointer` is
165+
explicit because the UA stylesheet gives `button` a default cursor, which wins
166+
over the value inherited from `.interactive`. */
167+
const overlayClassName = "absolute z-99 inset-0 rounded-08";
168+
const controlClassName = cn(
169+
overlayClassName,
170+
"cursor-pointer outline-border-04 outline-offset-[-2px] focus-visible:outline-2"
171+
);
172+
/* The tooltip hangs off the overlay, not the tab, so the tab's tree shape
173+
never depends on whether there is one. Swapping `children` between a label and
174+
an element (an inline rename input) then re-renders the row instead of
175+
remounting it. A tab without a control gets an inert overlay as the trigger
176+
when it has a tooltip to show. */
177+
const overlay =
178+
!disabled && href ? (
179+
<Link
180+
href={href as Route}
181+
scroll={false}
182+
onClick={onClick}
183+
{...labelProps}
184+
className={controlClassName}
185+
/>
186+
) : !disabled && onClick ? (
187+
<button
188+
type={type ?? "button"}
189+
onClick={onClick}
190+
{...labelProps}
191+
className={controlClassName}
192+
/>
193+
) : tooltip || label !== undefined ? (
194+
<div aria-hidden="true" className={overlayClassName} />
195+
) : null;
196+
const trigger =
197+
overlay &&
198+
(tooltip ? (
199+
<Tooltip tooltip={tooltip} side="right">
200+
{overlay}
201+
</Tooltip>
202+
) : label !== undefined ? (
203+
// Only a string label can stand in as its own folded tooltip.
204+
<FoldedTooltip label={label} folded={folded}>
205+
{overlay}
206+
</FoldedTooltip>
207+
) : (
208+
overlay
209+
));
210+
211+
return (
185212
<div
186213
className="opal-sidebar-tab"
187214
data-folded={folded === undefined ? undefined : String(folded)}
@@ -194,16 +221,16 @@ function SidebarTab({
194221
group="group/SidebarTab"
195222
>
196223
<Interactive.Container rounding="sm" size="lg" width="full">
197-
{overlay}
224+
{trigger}
198225

199226
{rightChildren && (
200227
<div className="opal-sidebar-tab__actions">{rightChildren}</div>
201228
)}
202229

203-
{typeof children === "string" ? (
230+
{label !== undefined ? (
204231
<ContentAction
205232
icon={Icon ?? undefined}
206-
title={children}
233+
title={label}
207234
sizePreset="main-ui"
208235
variant="body"
209236
color="interactive"
@@ -231,23 +258,6 @@ function SidebarTab({
231258
</Interactive.Stateful>
232259
</div>
233260
);
234-
235-
if (tooltip) {
236-
return (
237-
<Tooltip tooltip={tooltip} side="right">
238-
{content}
239-
</Tooltip>
240-
);
241-
}
242-
243-
// Only a string label can stand in as its own tooltip.
244-
if (label === undefined) return content;
245-
246-
return (
247-
<FoldedTooltip label={label} folded={folded}>
248-
{content}
249-
</FoldedTooltip>
250-
);
251261
}
252262

253263
export { SidebarTab, type SidebarTabProps };

web/src/sections/sidebar/ChatButton.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,18 @@ const ChatButton = memo(
141141

142142
// Drag and drop setup for chat sessions
143143
const dragId = `${DRAG_TYPES.CHAT}-${chatSession.id}`;
144-
const { attributes, listeners, setNodeRef, transform, isDragging } =
145-
useDraggable({
146-
id: dragId,
147-
data: {
148-
type: DRAG_TYPES.CHAT,
149-
chatSession,
150-
projectId: project?.id,
151-
},
152-
disabled: !draggable || renaming,
153-
});
144+
// `attributes` is intentionally dropped: it turns the wrapper into a
145+
// focusable role="button", which adds a second tab stop per row and lets
146+
// Enter/Space start a keyboard drag that looks like the chat is disabled.
147+
const { listeners, setNodeRef, transform, isDragging } = useDraggable({
148+
id: dragId,
149+
data: {
150+
type: DRAG_TYPES.CHAT,
151+
chatSession,
152+
projectId: project?.id,
153+
},
154+
disabled: !draggable || renaming,
155+
});
154156

155157
// Sync local name state when chatSession.name changes (e.g., after auto-naming)
156158
useEffect(() => {
@@ -402,7 +404,7 @@ const ChatButton = memo(
402404
const rightMenu = (
403405
<>
404406
<Popover.Trigger asChild onClick={noProp()}>
405-
<div>
407+
<div data-testid="ChatButton/options">
406408
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
407409
<IconButton
408410
icon={SvgMoreHorizontal}
@@ -415,7 +417,12 @@ const ChatButton = memo(
415417
/>
416418
</div>
417419
</Popover.Trigger>
418-
<Popover.Content side="right" align="start" width="md">
420+
<Popover.Content
421+
data-testid="ChatButton/popover"
422+
side="right"
423+
align="start"
424+
width="md"
425+
>
419426
<PopoverMenu>{popoverItems}</PopoverMenu>
420427
</Popover.Content>
421428
</>
@@ -431,7 +438,7 @@ const ChatButton = memo(
431438
}
432439
}}
433440
>
434-
<Popover.Anchor>
441+
<Popover.Anchor data-testid="ChatButton">
435442
<SidebarTab
436443
/* While renaming, drop the click target so the input stays usable. */
437444
href={
@@ -528,7 +535,6 @@ const ChatButton = memo(
528535
: undefined,
529536
opacity: isDragging ? 0.5 : 1,
530537
}}
531-
{...(mounted ? attributes : {})}
532538
{...(mounted ? listeners : {})}
533539
>
534540
{popover}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Regression test for renaming a chat from the sidebar popout.
2+
//
3+
// SidebarTab must keep its tree shape stable when its label is swapped for
4+
// the inline rename editor. When the shape changed, React remounted the row
5+
// and the open popover; the remounted popover took focus back from the
6+
// editor, whose blur handler closed it before it could be used.
7+
import { test, expect } from "@playwright/test";
8+
import { loginAsWorkerUser } from "@tests/e2e/utils/auth";
9+
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
10+
import { AppSidebarPage } from "@tests/e2e/pages/AppSidebarPage";
11+
12+
const CHAT_NAME = "E2E Rename Target";
13+
const NEW_NAME = "E2E Renamed Chat";
14+
15+
test.describe("Sidebar chat rename", () => {
16+
let sidebar: AppSidebarPage;
17+
let chatId: string;
18+
19+
test.beforeEach(async ({ page }, testInfo) => {
20+
await page.context().clearCookies();
21+
await loginAsWorkerUser(page, testInfo.workerIndex);
22+
23+
const apiClient = new OnyxApiClient(page.request);
24+
chatId = await apiClient.createChatSession(CHAT_NAME);
25+
26+
sidebar = new AppSidebarPage(page);
27+
await sidebar.goto();
28+
});
29+
30+
test.afterEach(async ({ page }) => {
31+
const apiClient = new OnyxApiClient(page.request);
32+
await apiClient.deleteChatSession(chatId);
33+
});
34+
35+
test("renames a chat session from the row's options popover", async () => {
36+
const row = sidebar.chatRow(CHAT_NAME);
37+
await row.startRename();
38+
39+
// The editor must appear, hold focus, and start from the current name.
40+
await expect(row.renameInput).toBeVisible();
41+
await expect(row.renameInput).toBeFocused();
42+
await expect(row.renameInput).toHaveValue(CHAT_NAME);
43+
44+
await row.submitRename(NEW_NAME);
45+
46+
await expect(sidebar.chatRow(NEW_NAME).root).toBeVisible();
47+
await expect(row.renameInput).toBeHidden();
48+
49+
// The new name survives a reload.
50+
await sidebar.goto();
51+
await expect(sidebar.chatRow(NEW_NAME).root).toBeVisible();
52+
});
53+
});

0 commit comments

Comments
 (0)