From f4d979a6c6139ab10aa1013335bd94a313a5b56e Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:35:02 -0300 Subject: [PATCH] fix(web): only show notice details when text is clipped (#12760) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> --- .../chat/ComposerBannerStack.test.tsx | 103 +++++++++++++++ .../components/chat/ComposerBannerStack.tsx | 117 ++++++++++++------ 2 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerBannerStack.test.tsx diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d2e9a649df10c56883e0db69fbe9a7664740e45d --- /dev/null +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -0,0 +1,103 @@ +import { cloneElement, type ReactElement, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { ComposerBannerStack } from "./ComposerBannerStack"; + +vi.mock("../ui/popover", () => ({ + Popover: "popover", + PopoverTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => + cloneElement(render, {}, children), + PopoverPopup: "popup", +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); + +let renderer: ReactTestRenderer; +afterEach(async () => { + if (renderer) await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +it("only offers notice details when the description cannot fit", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let resize = () => {}; + let mutate = () => {}; + vi.stubGlobal( + "MutationObserver", + class { + constructor(callback: () => void) { + mutate = callback; + } + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + let position = "static"; + vi.stubGlobal("getComputedStyle", () => ({ position })); + let availableWidth = 200; + const nested = { clientWidth: 100, scrollWidth: 80 }; + const text = { + querySelectorAll: () => [nested], + get clientWidth() { + return ( + availableWidth - + (renderer?.root.findAllByProps({ "aria-label": "Show notice details" }).length ? 28 : 0) + ); + }, + scrollWidth: 80, + }; + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "span" ? text : element.type === "button" ? { offsetWidth: 24 } : null, + }, + ); + }); + const details = () => renderer.root.findAllByProps({ "aria-label": "Show notice details" }); + expect(details()).toHaveLength(0); + text.scrollWidth = 300; + await act(() => resize()); + expect(details()).toHaveLength(1); + // It fits without the icon: the icon must not keep its own overflow alive. + availableWidth = 308; + await act(() => resize()); + expect(details()).toHaveLength(0); + text.scrollWidth = 80; + await act(() => resize()); + expect(details()).toHaveLength(0); + nested.scrollWidth = 500; + await act(() => mutate()); + expect(details()).toHaveLength(1); + nested.scrollWidth = 80; + await act(() => mutate()); + expect(details()).toHaveLength(0); + position = "absolute"; + await act(() => resize()); + expect(details()).toHaveLength(1); + position = "static"; + await act(() => resize()); + expect(details()).toHaveLength(0); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index b47790b960390cefd3b199de14b4e3cfea04da29..ec4b442593211ce2f22eb685df84bb479cf1190e 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro ); } +/** Keep full descriptions reachable only when their inline copy is clipped. */ +function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) { + const descriptionRef = useRef(null); + const detailsRef = useRef(null); + const [showDetails, setShowDetails] = useState(false); + + useLayoutEffect(() => { + const description = descriptionRef.current; + if (!description) return; + const measure = () => { + // Ignore the space taken by the details button itself so it cannot + // sustain its own overflow after the description would otherwise fit. + const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; + const hidden = getComputedStyle(description).position === "absolute"; + setShowDetails( + hidden || + [description, ...description.querySelectorAll("*")].some( + (element) => element.scrollWidth > element.clientWidth + recoveredWidth, + ), + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(description); + // A child can reveal new text without resizing its clipped box. + const mutations = new MutationObserver(measure); + mutations.observe(description, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + mutations.disconnect(); + }; + }, []); + + return ( + + + {children} + + {showDetails ? ( + + + } + > + + + + + {children} + + + + ) : null} + + ); +} + function ComposerBannerStackAlert({ item, attached, @@ -278,44 +354,9 @@ function ComposerBannerStackAlert({ {item.title} {item.description ? ( - - - {item.description} - - - - } - > - - - - - {item.description} - - - - + + {item.description} + ) : null} {item.actions || item.onDismiss ? ( -- 2.54.0