| author | |
| committer | |
| log | f4d979a6c6139ab10aa1013335bd94a313a5b56e |
| tree | 33d1084ddef1b1279218f5913c55fa8011215749 |
| parent | c789cd174ae50f50ecb0a34b96a81e00b0f5c823 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>
Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com>2 files changed, 182 insertions(+), 38 deletions(-)
apps/web/src/components/chat/ComposerBannerStack.test.tsx created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import { cloneElement, type ReactElement, type ReactNode } from "react"; | |
| 2 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 3 | import { afterEach, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { ComposerBannerStack } from "./ComposerBannerStack"; | |
| 6 | ||
| 7 | vi.mock("../ui/popover", () => ({ | |
| 8 | Popover: "popover", | |
| 9 | PopoverTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => | |
| 10 | cloneElement(render, {}, children), | |
| 11 | PopoverPopup: "popup", | |
| 12 | })); | |
| 13 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 14 | vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); | |
| 15 | ||
| 16 | let renderer: ReactTestRenderer; | |
| 17 | afterEach(async () => { | |
| 18 | if (renderer) await act(() => renderer.unmount()); | |
| 19 | vi.unstubAllGlobals(); | |
| 20 | }); | |
| 21 | ||
| 22 | it("only offers notice details when the description cannot fit", async () => { | |
| 23 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 24 | let resize = () => {}; | |
| 25 | let mutate = () => {}; | |
| 26 | vi.stubGlobal( | |
| 27 | "MutationObserver", | |
| 28 | class { | |
| 29 | constructor(callback: () => void) { | |
| 30 | mutate = callback; | |
| 31 | } | |
| 32 | observe() {} | |
| 33 | disconnect() {} | |
| 34 | }, | |
| 35 | ); | |
| 36 | vi.stubGlobal( | |
| 37 | "ResizeObserver", | |
| 38 | class { | |
| 39 | constructor(callback: () => void) { | |
| 40 | resize = callback; | |
| 41 | } | |
| 42 | observe() {} | |
| 43 | disconnect() {} | |
| 44 | }, | |
| 45 | ); | |
| 46 | let position = "static"; | |
| 47 | vi.stubGlobal("getComputedStyle", () => ({ position })); | |
| 48 | let availableWidth = 200; | |
| 49 | const nested = { clientWidth: 100, scrollWidth: 80 }; | |
| 50 | const text = { | |
| 51 | querySelectorAll: () => [nested], | |
| 52 | get clientWidth() { | |
| 53 | return ( | |
| 54 | availableWidth - | |
| 55 | (renderer?.root.findAllByProps({ "aria-label": "Show notice details" }).length ? 28 : 0) | |
| 56 | ); | |
| 57 | }, | |
| 58 | scrollWidth: 80, | |
| 59 | }; | |
| 60 | await act(() => { | |
| 61 | renderer = create( | |
| 62 | <ComposerBannerStack | |
| 63 | items={[ | |
| 64 | { | |
| 65 | id: "usage", | |
| 66 | variant: "info", | |
| 67 | icon: null, | |
| 68 | title: "Usage limits", | |
| 69 | description: "OpenCode", | |
| 70 | }, | |
| 71 | ]} | |
| 72 | />, | |
| 73 | { | |
| 74 | createNodeMock: (element) => | |
| 75 | element.type === "span" ? text : element.type === "button" ? { offsetWidth: 24 } : null, | |
| 76 | }, | |
| 77 | ); | |
| 78 | }); | |
| 79 | const details = () => renderer.root.findAllByProps({ "aria-label": "Show notice details" }); | |
| 80 | expect(details()).toHaveLength(0); | |
| 81 | text.scrollWidth = 300; | |
| 82 | await act(() => resize()); | |
| 83 | expect(details()).toHaveLength(1); | |
| 84 | // It fits without the icon: the icon must not keep its own overflow alive. | |
| 85 | availableWidth = 308; | |
| 86 | await act(() => resize()); | |
| 87 | expect(details()).toHaveLength(0); | |
| 88 | text.scrollWidth = 80; | |
| 89 | await act(() => resize()); | |
| 90 | expect(details()).toHaveLength(0); | |
| 91 | nested.scrollWidth = 500; | |
| 92 | await act(() => mutate()); | |
| 93 | expect(details()).toHaveLength(1); | |
| 94 | nested.scrollWidth = 80; | |
| 95 | await act(() => mutate()); | |
| 96 | expect(details()).toHaveLength(0); | |
| 97 | position = "absolute"; | |
| 98 | await act(() => resize()); | |
| 99 | expect(details()).toHaveLength(1); | |
| 100 | position = "static"; | |
| 101 | await act(() => resize()); | |
| 102 | expect(details()).toHaveLength(0); | |
| 103 | }); |
apps/web/src/components/chat/ComposerBannerStack.tsx+79-38| ... | ... | @@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 242 | 242 | ); |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | /** Keep full descriptions reachable only when their inline copy is clipped. */ | |
| 246 | function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) { | |
| 247 | const descriptionRef = useRef<HTMLSpanElement>(null); | |
| 248 | const detailsRef = useRef<HTMLButtonElement>(null); | |
| 249 | const [showDetails, setShowDetails] = useState(false); | |
| 250 | ||
| 251 | useLayoutEffect(() => { | |
| 252 | const description = descriptionRef.current; | |
| 253 | if (!description) return; | |
| 254 | const measure = () => { | |
| 255 | // Ignore the space taken by the details button itself so it cannot | |
| 256 | // sustain its own overflow after the description would otherwise fit. | |
| 257 | const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; | |
| 258 | const hidden = getComputedStyle(description).position === "absolute"; | |
| 259 | setShowDetails( | |
| 260 | hidden || | |
| 261 | [description, ...description.querySelectorAll("*")].some( | |
| 262 | (element) => element.scrollWidth > element.clientWidth + recoveredWidth, | |
| 263 | ), | |
| 264 | ); | |
| 265 | }; | |
| 266 | measure(); | |
| 267 | const observer = new ResizeObserver(measure); | |
| 268 | observer.observe(description); | |
| 269 | // A child can reveal new text without resizing its clipped box. | |
| 270 | const mutations = new MutationObserver(measure); | |
| 271 | mutations.observe(description, { childList: true, subtree: true, characterData: true }); | |
| 272 | return () => { | |
| 273 | observer.disconnect(); | |
| 274 | mutations.disconnect(); | |
| 275 | }; | |
| 276 | }, []); | |
| 277 | ||
| 278 | return ( | |
| 279 | <span className={compact ? "contents" : "flex min-w-8 flex-1 items-center gap-1"}> | |
| 280 | <span | |
| 281 | ref={descriptionRef} | |
| 282 | className={cn( | |
| 283 | "min-w-0 truncate text-muted-foreground", | |
| 284 | compact && "shrink-[9999] @max-[400px]:sr-only", | |
| 285 | )} | |
| 286 | > | |
| 287 | {children} | |
| 288 | </span> | |
| 289 | {showDetails ? ( | |
| 290 | <Popover> | |
| 291 | <PopoverTrigger | |
| 292 | openOnHover | |
| 293 | render={ | |
| 294 | <Button | |
| 295 | ref={detailsRef} | |
| 296 | size="icon-xs" | |
| 297 | variant="ghost-muted" | |
| 298 | aria-label="Show notice details" | |
| 299 | className="flex-none" | |
| 300 | /> | |
| 301 | } | |
| 302 | > | |
| 303 | <InfoIcon /> | |
| 304 | </PopoverTrigger> | |
| 305 | <PopoverPopup | |
| 306 | aria-label="Notice details" | |
| 307 | tooltipStyle | |
| 308 | side="top" | |
| 309 | className="max-w-80 whitespace-normal text-pretty wrap-anywhere" | |
| 310 | > | |
| 311 | <ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]"> | |
| 312 | {children} | |
| 313 | </ComposerBanner.Scroll> | |
| 314 | </PopoverPopup> | |
| 315 | </Popover> | |
| 316 | ) : null} | |
| 317 | </span> | |
| 318 | ); | |
| 319 | } | |
| 320 | ||
| 245 | 321 | function ComposerBannerStackAlert({ |
| 246 | 322 | item, |
| 247 | 323 | attached, |
| ... | ... | @@ -278,44 +354,9 @@ function ComposerBannerStackAlert({ |
| 278 | 354 | <ComposerBanner.Content className="whitespace-nowrap"> |
| 279 | 355 | <span className="min-w-0 truncate font-medium leading-7 sm:leading-6">{item.title}</span> |
| 280 | 356 | {item.description ? ( |
| 281 | <span className={item.compact ? "contents" : "flex min-w-8 flex-1 items-center gap-1"}> | |
| 282 | <span | |
| 283 | className={cn( | |
| 284 | "min-w-0 truncate text-muted-foreground", | |
| 285 | item.compact && "shrink-[9999] @max-[400px]:sr-only", | |
| 286 | )} | |
| 287 | > | |
| 288 | {item.description} | |
| 289 | </span> | |
| 290 | <Popover> | |
| 291 | <PopoverTrigger | |
| 292 | openOnHover | |
| 293 | render={ | |
| 294 | <Button | |
| 295 | size="icon-xs" | |
| 296 | variant="ghost" | |
| 297 | aria-label="Show notice details" | |
| 298 | className={cn( | |
| 299 | "flex-none text-muted-foreground hover:text-foreground", | |
| 300 | item.compact && "hidden @max-[400px]:inline-flex", | |
| 301 | )} | |
| 302 | /> | |
| 303 | } | |
| 304 | > | |
| 305 | <InfoIcon className="size-3.5" /> | |
| 306 | </PopoverTrigger> | |
| 307 | <PopoverPopup | |
| 308 | aria-label="Notice details" | |
| 309 | tooltipStyle | |
| 310 | side="top" | |
| 311 | className="max-w-80 whitespace-normal text-pretty wrap-anywhere" | |
| 312 | > | |
| 313 | <ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]"> | |
| 314 | {item.description} | |
| 315 | </ComposerBanner.Scroll> | |
| 316 | </PopoverPopup> | |
| 317 | </Popover> | |
| 318 | </span> | |
| 357 | <NoticeDescription compact={item.compact ?? false}> | |
| 358 | {item.description} | |
| 359 | </NoticeDescription> | |
| 319 | 360 | ) : null} |
| 320 | 361 | </ComposerBanner.Content> |
| 321 | 362 | {item.actions || item.onDismiss ? ( |