Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 13 additions & 5 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +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,
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";
Expand Down Expand Up @@ -310,6 +314,7 @@ function ChannelSection({
// The "+" dropdown (New task / New canvas). Keeps the hover actions pinned
// while open.
const [newMenuOpen, setNewMenuOpen] = 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.
Expand All @@ -324,7 +329,7 @@ function ChannelSection({
} = useChannelActions(channel);

return (
<Box className="group/chan relative">
<Box className="group/chan relative" {...hoverProps}>
{/* A single, non-expandable row: the "# name" navigates straight to the
channel home. Right-clicking opens the same actions as the "..." menu. */}
<ContextMenu>
Expand All @@ -346,6 +351,7 @@ function ChannelSection({
params: { channelId: channel.id },
});
}}
{...focusProps}
className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12"
>
<HashIcon
Expand All @@ -358,21 +364,23 @@ function ChannelSection({
: "text-muted-foreground group-hover/button:text-foreground",
)}
/>
<span
<OverflowTickerText
reveal={reveal}
className={cn(
"truncate text-[13px] group-hover/chan:pr-8",
// mr-11 clears the two icon-xs hover buttons pinned at right-1.
"text-[13px] group-hover/chan:mr-11",
// Bold is unread's alone; full contrast is shared with the
// channel you're in. Either way there's no hover brighten
// left to do, so those rows skip it.
isUnread ? "font-bold" : "font-medium",
isUnread || isActive
? "text-foreground"
: "text-muted-foreground group-hover/button:text-foreground",
menuOpen && "pr-8",
menuOpen && "mr-11",
)}
>
{channel.name}
</span>
</OverflowTickerText>
</Button>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
<Flex
direction="column"
gap="2"
className="mx-auto w-full max-w-3xl px-8 pb-6"
className="mx-auto w-full max-w-3xl px-8 pt-3 pb-6"
>
<LoopBuilderComposer
context={{ folderId: channelId, name: contextName }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function LoopsListViewPresentation({
<Flex
direction="column"
gap="2"
className="mx-auto w-full max-w-5xl px-8 pb-6"
className="mx-auto w-full max-w-5xl px-8 pt-3 pb-6"
>
{builderSessions.map((session) => (
<BuilderSessionRow
Expand Down
65 changes: 15 additions & 50 deletions packages/ui/src/features/sidebar/components/SidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
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 {
OverflowTickerText,
useOverflowTickerReveal,
} from "@posthog/ui/primitives/OverflowTickerText";

export const INDENT_SIZE = 8;

Expand Down Expand Up @@ -35,46 +31,6 @@ interface SidebarItemProps {
disabled?: boolean;
}

function SidebarItemLabel({
label,
grow,
}: {
label: React.ReactNode;
grow: boolean;
}) {
const canTooltip = typeof label === "string" || typeof label === "number";

const measureRef = useCallback((el: HTMLSpanElement | null) => {
if (!el) return;
const update = () => {
el.style.pointerEvents = el.scrollWidth > el.clientWidth ? "" : "none";
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);

const span = (
<span ref={measureRef} className={cn("min-w-0 truncate", grow && "flex-1")}>
{label}
</span>
);

if (!canTooltip) return span;

return (
<TooltipProvider delay={600}>
<Tooltip>
<TooltipTrigger render={span} />
<TooltipContent side="top" className="max-w-[900px] break-words">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

export function SidebarItem({
depth,
icon,
Expand All @@ -92,6 +48,8 @@ export function SidebarItem({
endContent,
disabled,
}: SidebarItemProps) {
const { reveal, hoverProps, focusProps } = useOverflowTickerReveal();

return (
<Button
type="button"
Expand All @@ -112,6 +70,8 @@ export function SidebarItem({
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
{...hoverProps}
{...focusProps}
disabled={disabled}
>
{icon ? (
Expand All @@ -121,7 +81,12 @@ export function SidebarItem({
) : null}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-h-[18px] items-center gap-1">
<SidebarItemLabel label={label} grow={!badge} />
<OverflowTickerText
reveal={reveal}
className={cn(!badge && "flex-1")}
>
{label}
</OverflowTickerText>
{badge ? (
<span className="mr-auto ml-1 flex shrink-0 items-center">
{badge}
Expand Down
173 changes: 173 additions & 0 deletions packages/ui/src/primitives/OverflowTickerText.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("framer-motion")>()),
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(
<OverflowTickerText reveal={reveal}>long title</OverflowTickerText>,
);
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(
<OverflowTickerText reveal={false}>long title</OverflowTickerText>,
);
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(
<OverflowTickerText reveal>
<i data-testid="inner">long title</i>
</OverflowTickerText>,
);
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(
<OverflowTickerText reveal={false}>long title</OverflowTickerText>,
);
rerender(<OverflowTickerText reveal>long title</OverflowTickerText>);
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<HTMLElement>;
}

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);
});
});
Loading
Loading