From e139ff79588dde5d22b6af0a65a835c13bf54132 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 18:18:00 -0700 Subject: [PATCH 01/12] fade and scroll overflowing sidebar titles --- .../sidebar/components/SidebarItem.tsx | 97 ++++++++++++------- 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index 6a7506cb48..12d472aa8a 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -1,16 +1,12 @@ -import { - Button, - cn, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@posthog/quill"; +import { Button, cn } from "@posthog/quill"; import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types"; -import { useCallback } from "react"; +import { useEffect, useRef, useState } from "react"; export const INDENT_SIZE = 8; +const TICKER_SPEED_PX_PER_SECOND = 50; +const TICKER_FADE_PX = 24; + export function getSidebarItemPaddingLeft(depth: number): string { return `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`; } @@ -38,40 +34,67 @@ interface SidebarItemProps { function SidebarItemLabel({ label, grow, + isRowHovered, }: { label: React.ReactNode; grow: boolean; + isRowHovered: boolean; }) { - const canTooltip = typeof label === "string" || typeof label === "number"; + const containerRef = useRef(null); + const contentRef = useRef(null); + const [overflowPx, setOverflowPx] = useState(0); - const measureRef = useCallback((el: HTMLSpanElement | null) => { - if (!el) return; - const update = () => { - el.style.pointerEvents = el.scrollWidth > el.clientWidth ? "" : "none"; + useEffect(() => { + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + const measure = () => { + setOverflowPx(Math.max(0, container.scrollWidth - container.clientWidth)); }; - update(); - const observer = new ResizeObserver(update); - observer.observe(el); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + observer.observe(content); return () => observer.disconnect(); }, []); - const span = ( - - {label} - - ); - - if (!canTooltip) return span; + const isTicking = isRowHovered && overflowPx > 0; + // Overshoot by the fade width so the title's end stops clear of the right fade. + const scrollDistance = overflowPx + TICKER_FADE_PX; return ( - - - - - {label} - - - + + + {label} + + ); } @@ -92,6 +115,8 @@ export function SidebarItem({ endContent, disabled, }: SidebarItemProps) { + const [isHovered, setIsHovered] = useState(false); + return ( } /> diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index 30d4e35c73..1b13677d4c 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -1,13 +1,10 @@ import { Button, cn } from "@posthog/quill"; import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types"; -import { useReducedMotion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; +import { OverflowTickerText } from "@posthog/ui/primitives/OverflowTickerText"; +import { useState } from "react"; export const INDENT_SIZE = 8; -const TICKER_SPEED_PX_PER_SECOND = 50; -const TICKER_FADE_PX = 24; - export function getSidebarItemPaddingLeft(depth: number): string { return `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`; } @@ -32,83 +29,6 @@ interface SidebarItemProps { disabled?: boolean; } -function SidebarItemLabel({ - label, - grow, - revealOverflow, -}: { - label: React.ReactNode; - grow: boolean; - revealOverflow: boolean; -}) { - const containerRef = useRef(null); - const contentRef = useRef(null); - const [overflowPx, setOverflowPx] = useState(0); - const [reachedEnd, setReachedEnd] = useState(false); - - useEffect(() => { - const container = containerRef.current; - const content = contentRef.current; - if (!container || !content) return; - const measure = () => { - setOverflowPx(Math.max(0, container.scrollWidth - container.clientWidth)); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(container); - observer.observe(content); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!revealOverflow) setReachedEnd(false); - }, [revealOverflow]); - - const prefersReducedMotion = useReducedMotion(); - const isTicking = revealOverflow && overflowPx > 0; - const showsEnd = reachedEnd || (isTicking && prefersReducedMotion); - - return ( - - { - if (e.propertyName === "transform") setReachedEnd(true); - }} - style={ - isTicking - ? { - transform: `translateX(-${overflowPx}px)`, - transitionProperty: prefersReducedMotion ? "none" : "transform", - transitionTimingFunction: "linear", - transitionDuration: `${overflowPx / TICKER_SPEED_PX_PER_SECOND}s`, - } - : { transform: "translateX(0)", transitionProperty: "none" } - } - > - {label} - - - ); -} - export function SidebarItem({ depth, icon, @@ -164,11 +84,12 @@ export function SidebarItem({ ) : null} - + + {label} + {badge ? ( {badge} diff --git a/packages/ui/src/primitives/OverflowTickerText.tsx b/packages/ui/src/primitives/OverflowTickerText.tsx new file mode 100644 index 0000000000..04037a0205 --- /dev/null +++ b/packages/ui/src/primitives/OverflowTickerText.tsx @@ -0,0 +1,80 @@ +import { cn } from "@posthog/quill"; +import { useReducedMotion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; + +const TICKER_SPEED_PX_PER_SECOND = 50; +const TICKER_FADE_PX = 24; + +export function OverflowTickerText({ + reveal, + className, + children, +}: { + reveal: boolean; + className?: string; + children: React.ReactNode; +}) { + const containerRef = useRef(null); + const contentRef = useRef(null); + const [overflowPx, setOverflowPx] = useState(0); + const [reachedEnd, setReachedEnd] = useState(false); + + useEffect(() => { + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + const measure = () => { + setOverflowPx(Math.max(0, container.scrollWidth - container.clientWidth)); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + observer.observe(content); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!reveal) setReachedEnd(false); + }, [reveal]); + + const prefersReducedMotion = useReducedMotion(); + const isTicking = reveal && overflowPx > 0; + const showsEnd = reachedEnd || (isTicking && prefersReducedMotion); + + return ( + + { + if (e.propertyName === "transform") setReachedEnd(true); + }} + style={ + isTicking + ? { + transform: `translateX(-${overflowPx}px)`, + transitionProperty: prefersReducedMotion ? "none" : "transform", + transitionTimingFunction: "linear", + transitionDuration: `${overflowPx / TICKER_SPEED_PX_PER_SECOND}s`, + } + : { transform: "translateX(0)", transitionProperty: "none" } + } + > + {children} + + + ); +} From 73e0c20c3638d7880e3b1efdbcff2c2d56a52760 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 18:39:15 -0700 Subject: [PATCH 06/12] reset ticker end state during render --- packages/ui/src/primitives/OverflowTickerText.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/primitives/OverflowTickerText.tsx b/packages/ui/src/primitives/OverflowTickerText.tsx index 04037a0205..2efb6c75f1 100644 --- a/packages/ui/src/primitives/OverflowTickerText.tsx +++ b/packages/ui/src/primitives/OverflowTickerText.tsx @@ -18,6 +18,11 @@ export function OverflowTickerText({ const contentRef = useRef(null); const [overflowPx, setOverflowPx] = useState(0); const [reachedEnd, setReachedEnd] = useState(false); + const [prevReveal, setPrevReveal] = useState(reveal); + if (reveal !== prevReveal) { + setPrevReveal(reveal); + if (!reveal) setReachedEnd(false); + } useEffect(() => { const container = containerRef.current; @@ -33,10 +38,6 @@ export function OverflowTickerText({ return () => observer.disconnect(); }, []); - useEffect(() => { - if (!reveal) setReachedEnd(false); - }, [reveal]); - const prefersReducedMotion = useReducedMotion(); const isTicking = reveal && overflowPx > 0; const showsEnd = reachedEnd || (isTicking && prefersReducedMotion); From fcdfb4bb182845b0b63e82a57d60a6485ae21f16 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 18:48:00 -0700 Subject: [PATCH 07/12] clear hover buttons in channel name ticker --- packages/ui/src/features/canvas/components/ChannelsList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index d85036ae23..dd7fc4c733 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -372,7 +372,7 @@ function ChannelSection({ {channel.name} From 44ffbe8637eee85ac95cca8f5c1dffb8568276a6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 18:48:50 -0700 Subject: [PATCH 08/12] pad composer chips from scrolling content --- .../ui/src/features/canvas/components/WebsiteChannelLoops.tsx | 2 +- packages/ui/src/features/loops/components/LoopsListView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 1fab5d69d1..deebc84e73 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -157,7 +157,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { {builderSessions.map((session) => ( Date: Sun, 26 Jul 2026 01:59:58 +0000 Subject: [PATCH 09/12] chore(visual): update storybook baselines 1 updated Run: e6c8ae88-caaf-40b7-8ea1-7f0ebd5fc916 Co-authored-by: charlesvien <5378415+charlesvien@users.noreply.github.com> --- apps/code/snapshots.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index e2fc980da7..751e81b16b 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -567,7 +567,7 @@ snapshots: loops-loopslistview--long-mixed-list--dark: hash: v1.k4693efd2.654250bc74fff46c496747eb13031e0e7a7ef264be5bb7be41451d82a497a515.vvrxXbbABkVY3hcHS3oThQ339JFgwIzB8E4n1usVWf0 loops-loopslistview--long-mixed-list--light: - hash: v1.k4693efd2.a8e53087efdaf58e351809775aaf410f53a781a97e10e973ee53fd0db2f94e73.7GcprWAYAgATLtNckNBd-44EyctqKNcb4FXiHA08QJs + hash: v1.k4693efd2.8dee426bab2a63843aefe9cf0765799ae9e3c5ece4adc522dc63faf3b362716b.K90qmg7hD8qD522iR-Y5lbr6ETEzRuYgQdYl0Z520do loops-loopslistview--with-builder-sessions--dark: hash: v1.k4693efd2.84cfc28bedc22a6728ba4ecf69274ab03537faeed5333775991716bf0b5f439e.fHsD0XKPnud-oDM4PLJ7edCbMnVcW6uVMdMHBYp3K08 loops-loopslistview--with-builder-sessions--light: From 2494a82616ab363d1089b6ed73f6fab60a54424c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 21:48:13 -0700 Subject: [PATCH 10/12] harden overflow ticker mask and end state --- .../ui/src/primitives/OverflowTickerText.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/primitives/OverflowTickerText.tsx b/packages/ui/src/primitives/OverflowTickerText.tsx index 2efb6c75f1..c1f9ad2e98 100644 --- a/packages/ui/src/primitives/OverflowTickerText.tsx +++ b/packages/ui/src/primitives/OverflowTickerText.tsx @@ -5,6 +5,32 @@ import { useEffect, useRef, useState } from "react"; const TICKER_SPEED_PX_PER_SECOND = 50; const TICKER_FADE_PX = 24; +function tickerMask(overflowPx: number, isTicking: boolean, showsEnd: boolean) { + if (overflowPx === 0) return undefined; + const fadeIn = `transparent, black ${TICKER_FADE_PX}px`; + const fadeOut = `black calc(100% - ${TICKER_FADE_PX}px), transparent`; + if (!isTicking) return `linear-gradient(to right, ${fadeOut})`; + if (showsEnd) return `linear-gradient(to right, ${fadeIn})`; + return `linear-gradient(to right, ${fadeIn}, ${fadeOut})`; +} + +export function useOverflowTickerReveal() { + const [isHovered, setIsHovered] = useState(false); + const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + return { + reveal: isHovered || isKeyboardFocused, + hoverProps: { + onPointerEnter: () => setIsHovered(true), + onPointerLeave: () => setIsHovered(false), + }, + focusProps: { + onFocus: (e: React.FocusEvent) => + setIsKeyboardFocused(e.currentTarget.matches(":focus-visible")), + onBlur: () => setIsKeyboardFocused(false), + }, + }; +} + export function OverflowTickerText({ reveal, className, @@ -23,6 +49,11 @@ export function OverflowTickerText({ setPrevReveal(reveal); if (!reveal) setReachedEnd(false); } + const [prevOverflowPx, setPrevOverflowPx] = useState(overflowPx); + if (overflowPx !== prevOverflowPx) { + setPrevOverflowPx(overflowPx); + setReachedEnd(false); + } useEffect(() => { const container = containerRef.current; @@ -40,28 +71,22 @@ export function OverflowTickerText({ const prefersReducedMotion = useReducedMotion(); const isTicking = reveal && overflowPx > 0; - const showsEnd = reachedEnd || (isTicking && prefersReducedMotion); + const showsEnd = reachedEnd || (isTicking && prefersReducedMotion === true); + const maskImage = tickerMask(overflowPx, isTicking, showsEnd); return ( { - if (e.propertyName === "transform") setReachedEnd(true); + if (e.target === e.currentTarget && e.propertyName === "transform") { + setReachedEnd(true); + } }} style={ isTicking From f66af200437981804ec1c34f9b5302ea78132c7e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 21:48:58 -0700 Subject: [PATCH 11/12] share ticker reveal wiring in a hook --- .../canvas/components/ChannelsList.tsx | 22 ++++++++----------- .../sidebar/components/SidebarItem.tsx | 19 +++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index dd7fc4c733..235760b9f8 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -63,7 +63,10 @@ import { import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; -import { OverflowTickerText } from "@posthog/ui/primitives/OverflowTickerText"; +import { + OverflowTickerText, + useOverflowTickerReveal, +} from "@posthog/ui/primitives/OverflowTickerText"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; @@ -311,8 +314,7 @@ function ChannelSection({ // The "+" dropdown (New task / New canvas). Keeps the hover actions pinned // while open. const [newMenuOpen, setNewMenuOpen] = useState(false); - const [isHovered, setIsHovered] = useState(false); - const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + const { reveal, hoverProps, focusProps } = useOverflowTickerReveal(); const createAndOpenCanvas = useCreateAndOpenDashboard(channel.id); // Shared by the "..." dropdown and the right-click context menu so both offer // the same star / edit / rename / delete actions. @@ -327,11 +329,7 @@ function ChannelSection({ } = useChannelActions(channel); return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > + {/* A single, non-expandable row: the "# name" navigates straight to the channel home. Right-clicking opens the same actions as the "..." menu. */} @@ -353,10 +351,7 @@ function ChannelSection({ params: { channelId: channel.id }, }); }} - onFocus={(e) => - setIsKeyboardFocused(e.currentTarget.matches(":focus-visible")) - } - onBlur={() => setIsKeyboardFocused(false)} + {...focusProps} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - onFocus={(e) => - setIsKeyboardFocused(e.currentTarget.matches(":focus-visible")) - } - onBlur={() => setIsKeyboardFocused(false)} + {...hoverProps} + {...focusProps} disabled={disabled} > {icon ? ( @@ -85,7 +82,7 @@ export function SidebarItem({ {label} From 15fea89a1fac51377ed0447be0303449f2a60b5a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 25 Jul 2026 21:49:20 -0700 Subject: [PATCH 12/12] add overflow ticker tests --- .../primitives/OverflowTickerText.test.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/ui/src/primitives/OverflowTickerText.test.tsx diff --git a/packages/ui/src/primitives/OverflowTickerText.test.tsx b/packages/ui/src/primitives/OverflowTickerText.test.tsx new file mode 100644 index 0000000000..8740913fd9 --- /dev/null +++ b/packages/ui/src/primitives/OverflowTickerText.test.tsx @@ -0,0 +1,173 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + OverflowTickerText, + useOverflowTickerReveal, +} from "./OverflowTickerText"; + +let reducedMotion = false; +vi.mock("framer-motion", async (importOriginal) => ({ + ...(await importOriginal()), + useReducedMotion: () => reducedMotion, +})); + +afterEach(() => { + reducedMotion = false; + vi.restoreAllMocks(); +}); + +// jsdom does no layout; report widths only for the ticker's overflow-hidden +// container so the mount-time measure sees the requested overflow. +function mockOverflow(overflowPx: number) { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation( + function (this: HTMLElement) { + return this.classList.contains("overflow-hidden") ? 200 : 0; + }, + ); + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockImplementation( + function (this: HTMLElement) { + return this.classList.contains("overflow-hidden") ? 200 + overflowPx : 0; + }, + ); +} + +function renderTicker(reveal: boolean, overflowPx: number) { + mockOverflow(overflowPx); + const view = render( + long title, + ); + const tickerContainer = view.container.firstElementChild as HTMLElement; + const content = tickerContainer.firstElementChild as HTMLElement; + return { ...view, tickerContainer, content }; +} + +function endTransition(el: HTMLElement, propertyName: string) { + const event = new Event("transitionend", { bubbles: true }); + Object.assign(event, { propertyName }); + act(() => { + el.dispatchEvent(event); + }); +} + +const FADE_IN = "transparent, black 24px"; +const FADE_OUT = "black calc(100% - 24px), transparent"; + +describe("OverflowTickerText", () => { + it.each([ + { name: "fitting text", overflowPx: 0, reveal: true, fades: [] }, + { name: "narrow text", overflowPx: -50, reveal: true, fades: [] }, + { + name: "resting overflow", + overflowPx: 40, + reveal: false, + fades: [FADE_OUT], + }, + { + name: "ticking overflow", + overflowPx: 40, + reveal: true, + fades: [FADE_IN, FADE_OUT], + }, + ])("masks $name", ({ overflowPx, reveal, fades }) => { + const { tickerContainer } = renderTicker(reveal, overflowPx); + const mask = tickerContainer.style.maskImage ?? ""; + expect(mask.includes(FADE_IN)).toBe(fades.includes(FADE_IN)); + expect(mask.includes(FADE_OUT)).toBe(fades.includes(FADE_OUT)); + }); + + it("scrolls exactly the overflow at constant speed", () => { + const { content } = renderTicker(true, 40); + expect(content.style.transform).toBe("translateX(-40px)"); + expect(content.style.transitionProperty).toBe("transform"); + expect(content.style.transitionTimingFunction).toBe("linear"); + expect(content.style.transitionDuration).toBe("0.8s"); + }); + + it("snaps back without a transition when reveal ends", () => { + const { rerender, content } = renderTicker(true, 40); + rerender( + long title, + ); + expect(content.style.transform).toBe("translateX(0)"); + expect(content.style.transitionProperty).toBe("none"); + }); + + it("drops the end fade once the scroll finishes", () => { + const { tickerContainer, content } = renderTicker(true, 40); + endTransition(content, "transform"); + const mask = tickerContainer.style.maskImage ?? ""; + expect(mask).toContain(FADE_IN); + expect(mask).not.toContain(FADE_OUT); + }); + + it("ignores transition ends for other properties", () => { + const { tickerContainer, content } = renderTicker(true, 40); + endTransition(content, "opacity"); + expect(tickerContainer.style.maskImage ?? "").toContain(FADE_OUT); + }); + + it("ignores transition ends bubbled from children", () => { + mockOverflow(40); + const view = render( + + long title + , + ); + const tickerContainer = view.container.firstElementChild as HTMLElement; + endTransition(view.getByTestId("inner"), "transform"); + expect(tickerContainer.style.maskImage ?? "").toContain(FADE_OUT); + }); + + it("restores both fades on the next reveal", () => { + const { rerender, tickerContainer, content } = renderTicker(true, 40); + endTransition(content, "transform"); + rerender( + long title, + ); + rerender(long title); + const mask = tickerContainer.style.maskImage ?? ""; + expect(mask).toContain(FADE_IN); + expect(mask).toContain(FADE_OUT); + }); + + it("jumps straight to the end under reduced motion", () => { + reducedMotion = true; + const { tickerContainer, content } = renderTicker(true, 40); + expect(content.style.transform).toBe("translateX(-40px)"); + expect(content.style.transitionProperty).toBe("none"); + const mask = tickerContainer.style.maskImage ?? ""; + expect(mask).toContain(FADE_IN); + expect(mask).not.toContain(FADE_OUT); + }); +}); + +describe("useOverflowTickerReveal", () => { + function focusEvent(matchesFocusVisible: boolean) { + return { + currentTarget: { matches: () => matchesFocusVisible }, + } as unknown as React.FocusEvent; + } + + it("reveals while hovered", () => { + const { result } = renderHook(() => useOverflowTickerReveal()); + expect(result.current.reveal).toBe(false); + act(() => result.current.hoverProps.onPointerEnter()); + expect(result.current.reveal).toBe(true); + act(() => result.current.hoverProps.onPointerLeave()); + expect(result.current.reveal).toBe(false); + }); + + it("reveals on keyboard focus until blur", () => { + const { result } = renderHook(() => useOverflowTickerReveal()); + act(() => result.current.focusProps.onFocus(focusEvent(true))); + expect(result.current.reveal).toBe(true); + act(() => result.current.focusProps.onBlur()); + expect(result.current.reveal).toBe(false); + }); + + it("does not reveal on pointer-driven focus", () => { + const { result } = renderHook(() => useOverflowTickerReveal()); + act(() => result.current.focusProps.onFocus(focusEvent(false))); + expect(result.current.reveal).toBe(false); + }); +});