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
39 changes: 20 additions & 19 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { InlineButton } from "../ui/button";
import { DraftProjectName } from "./DraftProjectName";
import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";

const ADD_PROJECT_VALUE = "add-project";
Expand Down Expand Up @@ -254,13 +255,13 @@ export function DraftHeroHeadline({
<ComboboxTrigger
render={<InlineButton tone="picker" />}
data-draft-project-trigger=""
// Leaves room for the trailing "?" so a truncated name keeps it on its line.
className="pointer-events-auto max-w-[calc(100%-1em)] align-baseline"
className="pointer-events-auto min-w-0 max-w-full align-baseline"
/>
}
>
<DraftProjectName
value={isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")}
singleLine
/>
</TooltipTrigger>
{activeProjectDisplayName && !isScratchDraft ? (
Expand Down Expand Up @@ -308,9 +309,9 @@ export function DraftHeroHeadline({
<button
type="button"
onClick={openAddProject}
className="pointer-events-auto inline cursor-pointer border-muted-foreground/35 border-b border-dotted text-muted-foreground/60 transition-colors hover:border-muted-foreground/60 hover:text-muted-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
className="pointer-events-auto inline min-w-0 max-w-full cursor-pointer border-muted-foreground/35 border-b border-dotted text-muted-foreground/60 transition-colors hover:border-muted-foreground/60 hover:text-muted-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{activeProjectTitle ?? "Add a project"}
<DraftProjectName value={activeProjectTitle ?? "Add a project"} singleLine />
</button>
);

Expand Down Expand Up @@ -341,21 +342,21 @@ export function DraftHeroHeadline({

return (
<div className="mx-auto flex w-full max-w-5xl flex-col items-center">
{/* An explicit heading label hides the inline project picker in NVDA browse mode. */}
<h1 className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl">
{isScratchDraft ? (
<>What should we work on?</>
) : hasResolvedProject ? (
<>
What should we build in <span className="whitespace-nowrap">{projectSelector}?</span>
</>
) : canChooseProject ? (
<>{projectSelector} to start</>
) : (
<>Add a project to start</>
)}
</h1>
{}
{hasResolvedProject && !isScratchDraft ? (
<DraftHeroProjectHeading projectName={activeProjectDisplayName ?? "Choose a project"}>
{projectSelector}
</DraftHeroProjectHeading>
) : (
<h1 className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl">
{isScratchDraft ? (
<>What should we work on?</>
) : canChooseProject ? (
<>{projectSelector} to start</>
) : (
<>Add a project to start</>
)}
</h1>
)}
{scratchWorkspaceRoot === null ? null : (
<p className="mt-2 flex h-6 w-full items-center justify-center text-sm">
{isScratchDraft ? projectSelector : orStartWithoutProject}
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/components/chat/DraftHeroProjectHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

import { observeResize } from "~/lib/observeResize";

export function DraftHeroProjectHeading({
children,
projectName,
}: {
children: ReactNode;
projectName: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const inlineRef = useRef<HTMLSpanElement>(null);
const [fitsInline, setFitsInline] = useState(false);

useLayoutEffect(() => {
const container = containerRef.current;
const inline = inlineRef.current;
if (!container || !inline) return;

// Measure the full inline heading independently of its current layout, so
// moving the name below it cannot make the fit check oscillate.
const update = () =>
setFitsInline(
inline.getBoundingClientRect().width <= container.getBoundingClientRect().width,
);
update();
return observeResize([container, inline], update);
}, []);

return (
<div ref={containerRef} className="group/draft-hero relative w-full" data-inline={fitsInline}>
<span
aria-hidden="true"
className="pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden"
>
<span
ref={inlineRef}
className="flex w-max items-baseline gap-2 whitespace-nowrap font-normal text-3xl tracking-tight"
>
<span>What should we build in</span>
<span>
<span className="font-medium">{projectName}</span>?
</span>
</span>
</span>
<h1 className="flex w-full flex-col items-center justify-center gap-2 text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl lg:group-data-[inline=true]/draft-hero:flex-row">
<span className="max-w-full lg:group-data-[inline=true]/draft-hero:shrink-0">
What should we build
<span className="lg:group-data-[inline=true]/draft-hero:hidden">?</span>
<span className="hidden lg:group-data-[inline=true]/draft-hero:inline"> in</span>
</span>
<span className="flex min-w-0 max-w-full items-baseline justify-center text-base tracking-normal lg:group-data-[inline=true]/draft-hero:text-3xl lg:group-data-[inline=true]/draft-hero:tracking-tight">
<span className="min-w-0">{children}</span>
<span className="hidden shrink-0 lg:group-data-[inline=true]/draft-hero:inline">?</span>
</span>
</h1>
</div>
);
}
186 changes: 134 additions & 52 deletions apps/web/src/components/chat/DraftProjectName.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import "~/index.css";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";

import { InlineButton } from "../ui/button";
import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading";
import { DraftProjectName } from "./DraftProjectName";

let root: Root;
Expand All @@ -14,30 +16,29 @@ beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
host = document.body.appendChild(document.createElement("div"));
host.style.width = "700px";
host.style.zoom = "1.31";
host.style.maxWidth = "100%";
root = createRoot(host);
});

afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

async function render(value: string, placement: "headline" | "row") {
async function render(value: string, placement: "headline" | "row" = "headline") {
await act(async () => {
root.render(
placement === "headline" ? (
<h1 className="w-full text-center text-3xl tracking-tight">
What should we build in{" "}
<span className="whitespace-nowrap">
<InlineButton tone="picker" className="max-w-[calc(100%-1em)] align-baseline">
<DraftProjectName value={value} />
</InlineButton>
?
</span>
</h1>
<div data-testid="composer" className="relative mt-64 h-32 w-full">
<div className="absolute inset-x-0 bottom-full pb-4">
<DraftHeroProjectHeading projectName={value}>
<InlineButton tone="picker" className="min-w-0 max-w-full align-baseline">
<DraftProjectName value={value} singleLine />
</InlineButton>
</DraftHeroProjectHeading>
</div>
</div>
) : (
<div className="flex w-full items-center gap-2 text-sm font-medium">
<span className="size-4 shrink-0" />
Expand All @@ -50,54 +51,135 @@ async function render(value: string, placement: "headline" | "row") {
});
}

function label() {
return host.querySelector<HTMLSpanElement>("span[aria-label]")!;
const label = () => host.querySelector<HTMLSpanElement>("span[aria-label]")!;
const title = () => host.querySelector<HTMLSpanElement>("h1 > span")!;

function expectWithinHost(element: Element) {
const bounds = element.getBoundingClientRect();
const hostBounds = host.getBoundingClientRect();
expect(bounds.left).toBeGreaterThanOrEqual(hostBounds.left);
expect(bounds.right).toBeLessThanOrEqual(hostBounds.right);
}

function expectAboveComposer() {
const heading = host.querySelector("h1")!;
const composer = host.querySelector('[data-testid="composer"]')!;
expectWithinHost(heading);
expectWithinHost(label());
expect(heading.getBoundingClientRect().bottom).toBeLessThan(composer.getBoundingClientRect().top);
}

it.each(["headline", "row"] as const)(
"keeps fitting names in the %s even when canvas metrics disagree with layout",
async (placement) => {
host.style.zoom = "1";
const measureText = CanvasRenderingContext2D.prototype.measureText;
vi.spyOn(CanvasRenderingContext2D.prototype, "measureText").mockImplementation(function (
this: CanvasRenderingContext2D,
text,
) {
const metrics = measureText.call(this, text);
Object.defineProperty(metrics, "width", { value: metrics.width + 8 });
return metrics;
});

for (const value of ["t3code", "mozart_v2", "No project", "Add project"]) {
await render(value, placement);
it.each([320, 390, 822])(
"places the full short project name below the title in smaller text at %ipx",
async (width) => {
await page.viewport(width, 800);
host.style.width = `${width - 32}px`;
for (const value of ["t3code", "obsidian-arr", "No project"]) {
await render(value);
expect(title().innerText).toBe("What should we build?");
expect(label().textContent).toBe(value);
expect(label().scrollWidth).toBeLessThanOrEqual(label().clientWidth);
expect(label().scrollHeight).toBeLessThanOrEqual(label().clientHeight);
expect(label().getBoundingClientRect().top).toBeGreaterThanOrEqual(
title().getBoundingClientRect().bottom,
);
expect(Number.parseFloat(getComputedStyle(label()).fontSize)).toBeLessThan(
Number.parseFloat(getComputedStyle(title()).fontSize),
);
expectAboveComposer();
}
},
);

it.each(["headline", "row"] as const)(
"fits long names in the %s and restores them when the available space grows",
async (placement) => {
const value = "ExampleCompany-CustomerPortal-App";
await render(value, placement);
expect(label().textContent).toBe(value);
it("uses the inline heading only when the viewport and chat lane both have room", async () => {
await page.viewport(1440, 900);
await render("obsidian-arr");
expect(title().innerText).toBe("What should we build in");
expect(label().getBoundingClientRect().top).toBeLessThan(title().getBoundingClientRect().bottom);
expectAboveComposer();

await act(async () => {
host.style.width = "180px";
await expect.poll(() => label().textContent).toContain("…");
});
const shortened = label().textContent!;
expect(shortened.startsWith("Ex")).toBe(true);
expect(shortened.endsWith("App")).toBe(true);
const text = document.createRange();
text.selectNodeContents(label());
expect(text.getBoundingClientRect().width).toBeLessThanOrEqual(
label().getBoundingClientRect().width,
);
host.style.width = "350px";
await expect.poll(() => title().innerText).toBe("What should we build?");
expect(label().getBoundingClientRect().top).toBeGreaterThanOrEqual(
title().getBoundingClientRect().bottom,
);
expectAboveComposer();
});

await act(async () => {
host.style.width = "700px";
await expect.poll(() => label().textContent).toBe(value);
});
it.each([390, 1440])(
"uses a single smaller row and truncates only its remaining overflow at %ipx",
async (width) => {
await page.viewport(width, 900);
host.style.width = `${Math.min(width - 32, 700)}px`;
const value = "ExampleCompany-CustomerPortal-Production-".repeat(5);
await render(value);
expect(label().textContent).toBe(value);
expect(label().getAttribute("aria-label")).toBe(value);
expect(title().innerText).toBe("What should we build?");
expect(label().getBoundingClientRect().top).toBeGreaterThanOrEqual(
title().getBoundingClientRect().bottom,
);
expect(Number.parseFloat(getComputedStyle(label()).fontSize)).toBeLessThan(
Number.parseFloat(getComputedStyle(title()).fontSize),
);
expect(label().scrollWidth).toBeGreaterThan(label().clientWidth);
expect(label().clientHeight).toBe(Number.parseFloat(getComputedStyle(label()).lineHeight));
expect(getComputedStyle(label()).textOverflow).toBe("ellipsis");
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth);
expectAboveComposer();
},
);

it("keeps short picker rows intact and bounds long rows at fractional zoom", async () => {
await page.viewport(1280, 800);
host.style.width = "240px";
host.style.zoom = "1.31";
for (const value of ["t3code", "obsidian-arr", "Add project", "LongProjectName-".repeat(10)]) {
await render(value, "row");
expect(label().textContent).toBe(value);
expectWithinHost(label());
expect(label().clientHeight).toBeLessThanOrEqual(
Number.parseFloat(getComputedStyle(label()).lineHeight) * 2,
);
}
});

it("moves a name below the heading when it stops fitting and returns it inline when space recovers", async () => {
await page.viewport(1440, 900);
host.style.width = "900px";
const value = "CustomerPortal-Production";
await render(value);
expect(title().innerText).toBe("What should we build in");

await act(async () => {
host.style.width = "650px";
await expect.poll(() => title().innerText).toBe("What should we build?");
});
expect(label().textContent).toBe(value);
expect(label().scrollWidth).toBeLessThanOrEqual(label().clientWidth);
expect(label().scrollHeight).toBeLessThanOrEqual(label().clientHeight);
expectAboveComposer();

await act(async () => {
host.style.width = "900px";
await expect.poll(() => title().innerText).toBe("What should we build in");
});
expect(label().textContent).toBe(value);
expectAboveComposer();
});

it("rechecks inline fit when the selected project changes", async () => {
await page.viewport(1440, 900);
await render("ExampleCompany-CustomerPortal-Production-Environment");
expect(title().innerText).toBe("What should we build?");

await render("obsidian-arr");
await expect.poll(() => title().innerText).toBe("What should we build in");
expect(label().textContent).toBe("obsidian-arr");

await render("ExampleCompany-CustomerPortal-Production-Environment");
await expect.poll(() => title().innerText).toBe("What should we build?");
expect(label().scrollWidth).toBeLessThanOrEqual(label().clientWidth);
expect(label().scrollHeight).toBeLessThanOrEqual(label().clientHeight);
expectAboveComposer();
});
Loading
Loading