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
28 changes: 28 additions & 0 deletions .changeset/shell-contains-overlays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/builder": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/module-specifiers": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch
"create-nextly-app": patch
"nextly": patch
---

Dropdowns and pickers opened inside the page editor no longer stay on screen when the editor hides itself. If the editor did not have enough width it would show a notice explaining that, while an open dropdown floated on top of the notice and could still be clicked.
39 changes: 38 additions & 1 deletion apps/playground/src/app/builder-shell/harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { type LeftPanel } from "@nextlyhq/builder";
// The shell comes from its own entry: that is the one carrying `"use client"`,
// which is why the root barrel can stay callable from a Server Component.
import { BuilderShell } from "@nextlyhq/builder/shell";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@nextlyhq/ui";
// The design system's sheet FIRST, then the editor's, which supplements it.
// `@nextlyhq/builder/styles.css` deliberately ships neither the `--nx-*` tokens
// nor the base reset, because the host already loads a sheet that owns both and
Expand Down Expand Up @@ -56,7 +63,37 @@ export function BuilderShellHarness({
}}
topBar={<span data-testid="top-bar-slot">Top bar slot</span>}
breadcrumb={<span data-testid="breadcrumb-slot">Breadcrumb slot</span>}
inspector={<div data-testid="inspector-slot">Inspector slot</div>}
inspector={
<div data-testid="inspector-slot">
Inspector slot
{/*
* The ONE interactive thing in this harness, and it earns the
* exception the module docblock makes for inert slots.
*
* Where a portalled overlay LANDS is not decidable without one: it
* leaves the DOM position it was opened from, so the question is
* which container receives it and whether that container is inside
* the subtree the shell hides. A marker cannot answer that, and
* jsdom cannot either, since it computes no styles and would report
* a clipped dropdown and a visible one identically.
*
* Deliberately at the BOTTOM of the inspector: the inspector
* scrolls, so an overlay opened here is where clipping shows. One
* opened mid-panel looks correct whether or not the container
* escaped the scroll box.
*/}
<div style={{ height: "150vh" }} aria-hidden />
<Select>
<SelectTrigger data-testid="overlay-trigger">
<SelectValue placeholder="Pick one" />
</SelectTrigger>
<SelectContent data-testid="overlay-content">
<SelectItem value="a">First</SelectItem>
<SelectItem value="b">Second</SelectItem>
</SelectContent>
</Select>
</div>
}
renderPanel={(panel: LeftPanel) => (
<div data-testid="panel-slot" data-panel={panel}>
{panel} slot
Expand Down
119 changes: 119 additions & 0 deletions e2e/tests/shell/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,125 @@ test.describe("a narrow CONTAINER inside a wide window", () => {
});
});

test.describe("overlays opened inside the shell", () => {
test.use({ viewport: { width: 1440, height: 900 } });

const HOST = '[data-slot="builder-overlay-host"]';

test("land in the shell's own host rather than the document body", async ({
page,
}) => {
// The containment property. An overlay leaves the DOM position it was
// opened from, so nothing about the region that owns it can reach the
// content — which is why an open dropdown used to survive the shell being
// hidden. Asserted as ANCESTRY rather than by looking for the element,
// since finding it says nothing about where it went.
const shell = createShellDriver(page);
await shell.goto();

await page.getByTestId("overlay-trigger").click();
const content = page.getByTestId("overlay-content");
await expect(content).toBeVisible();

expect(
await content.evaluate((node, host) => node.closest(host) !== null, HOST)
).toBe(true);
});

test("render with a real box when opened at a scrolled panel edge", async ({
page,
}) => {
// The trigger sits at the BOTTOM of a scrolling inspector, which is where a
// container placed inside that scroll box would cut the dropdown off.
//
// WHAT THIS DOES NOT COVER, measured rather than assumed. Every overlay
// positions itself `fixed`, and `overflow` only clips a fixed descendant
// when an ancestor is a containing block for it — a `transform`, `filter`,
// `contain` or `will-change`. There is none anywhere in the shell today, so
// a container placed inside the inspector would NOT clip either, and this
// test would pass on that placement as readily as on the correct one. It
// cannot currently distinguish them.
//
// What actually guards the placement is the unit assertion that no ancestor
// of the host carries one of those properties
// (`builder-shell.test.tsx`), which is the condition that would make
// clipping possible. This test is the smoke check beside it: the overlay
// opens, is painted, and has a box — which is worth having, and is not the
// clipping guard.
const shell = createShellDriver(page);
await shell.goto();

const trigger = page.getByTestId("overlay-trigger");
await trigger.scrollIntoViewIfNeeded();
await trigger.click();

const content = page.getByTestId("overlay-content");
await expect(content).toBeVisible();

const box = await content.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
expect(box.height).toBeGreaterThan(20);
expect(box.width).toBeGreaterThan(20);
});

test("resolve the design-system tokens from inside that host", async ({
page,
}) => {
// The admin re-applies `nextly-admin` on its own portal root because that
// class selects the token values. This host inherits instead, being nested
// inside the shell, and inheritance is a different path from re-declaring:
// an overlay whose background resolves to nothing renders as unstyled text
// over the canvas, which is legible enough to pass a screenshot glance.
const shell = createShellDriver(page);
await shell.goto();

await page.getByTestId("overlay-trigger").click();
const content = page.getByTestId("overlay-content");
await expect(content).toBeVisible();

// Read through the BROWSER's computed value, never the custom property:
// asking for `--nx-builder-surface` returns the declared token stream
// whether or not anything defines it, which is how an unstyled overlay
// passes a check that looks like this one.
//
// Not routed through the driver's `isUnpainted`, deliberately. That
// predicate takes a whole `ShellRenderMeasurement` and reads the alpha from
// a separator its own measurement produces; supplying that here would mean
// re-deriving the alpha beside it, which is the duplication the driver
// exists to prevent. The narrower question this test needs — did the
// background resolve to anything at all — is answerable without it.
const background = await content.evaluate(
node => getComputedStyle(node).backgroundColor
);
expect(background.trim()).not.toBe("");
expect(background.trim()).not.toBe("transparent");
expect(background).not.toMatch(/(rgba?\([^)]*[,/]\s*0(\.0+)?\s*\))/);
});

test("disappear with the shell when its container is too narrow", async ({
page,
}) => {
// The defect this whole change exists for, end to end. Opened while the
// shell is usable, then the CONTAINER is narrowed underneath it: the
// dropdown must go with the editor rather than floating over the notice
// that says the editor is unavailable.
const shell = createShellDriver(page);
await shell.goto();

await page.getByTestId("overlay-trigger").click();
await expect(page.getByTestId("overlay-content")).toBeVisible();

await page.getByTestId("shell-container").evaluate((node, width) => {
node.style.width = `${width}px`;
}, MIN_SHELL_WIDTH - 100);

await expect(page.getByText(/needs more width/i)).toBeVisible();
await expect(page.getByTestId("overlay-content")).toBeHidden();
void shell;
});
});

test.describe("the readiness diagnostic itself", () => {
test.use({ viewport: { width: 1440, height: 900 } });

Expand Down
103 changes: 103 additions & 0 deletions packages/builder/src/builder-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,109 @@ describe("preferences", () => {
});
});

describe("where overlays inside the shell portal to", () => {
/** The host the shell publishes for portalled overlay content. */
function overlayHost(): HTMLElement {
const host = document.querySelector<HTMLElement>(
'[data-slot="builder-overlay-host"]'
);
if (host === null) throw new Error("the shell rendered no overlay host");
return host;
}

it("puts the host inside the subtree that goes inert", () => {
// The whole point. `hidden` and `inert` are attributes on an element and
// reach descendants only, so an overlay portalled to `document.body` could
// never be covered by them — it stayed visible and clickable on top of the
// notice saying the editor was unavailable. Containment is what makes that
// impossible rather than something each control has to remember.
stubContainerFits(false);
render(
<BuilderShell onExit={vi.fn()} store={memoryStore()}>
<p>canvas</p>
</BuilderShell>
);

const inert = overlayHost().closest("[inert]");
expect(inert).not.toBeNull();
// And the same element is the one taken out of layout, so portalled
// content inherits `display: none` rather than merely losing pointers.
expect(inert?.hasAttribute("hidden")).toBe(true);
});

it("keeps the host out of every scrollable region", () => {
// The failure a naive placement produces, and it is worse than the one
// being fixed because it is silent: the panel, the canvas and the
// inspector are each `overflow-auto`, so an overlay opened near a panel
// edge would be CLIPPED rather than merely misplaced. Asserted
// structurally because jsdom computes no styles — what it can decide is
// that the host is not a descendant of any of them.
renderShell({ renderPanel: panel => <p>{panel} panel</p> });
fireEvent.click(screen.getByRole("button", { name: "Insert" }));

const host = overlayHost();
for (const region of [
screen.getByRole("region", { name: "Canvas" }),
screen.getByRole("complementary", { name: "Inspector" }),
screen.getByRole("navigation", { name: "Editor panels" }),
]) {
expect(region.contains(host)).toBe(false);
}
});

it("keeps the host out of any ancestor that would trap fixed positioning", () => {
// A guard rather than a comment, because the invariant is fragile by
// construction and its violation is silent.
//
// Every overlay positions itself `fixed`. A `transform`, `filter`,
// `backdrop-filter`, `perspective`, `contain` or `will-change` on an
// ancestor makes that ancestor the containing block instead of the
// viewport, so a centred dialog would centre on the canvas and a dropdown
// would land somewhere unrelated. The same properties create a stacking
// context, which additionally makes every z-index inside the host local
// and unable to compete with admin-level overlays.
//
// A page builder acquires canvas zoom eventually, and the day a `scale-`
// lands on an ancestor of this host is the day overlays quietly
// mispositioned. This fails then rather than in a bug report.
const TRAPS =
/(^|[\s:])(scale|rotate|translate|skew|transform|filter|blur|backdrop|perspective|isolate|contain|will-change|opacity)-/;

renderShell({ renderPanel: panel => <p>{panel} panel</p> });

const offenders: string[] = [];
for (
let node: HTMLElement | null = overlayHost().parentElement;
node !== null;
node = node.parentElement
) {
const classes = node.className;
if (typeof classes === "string" && TRAPS.test(classes)) {
offenders.push(`${node.tagName.toLowerCase()}: ${classes}`);
}
}

expect(offenders).toEqual([]);
});

it("walked real ancestors, so the guard above is not vacuous", () => {
// The positive control. A host with no parents, or a selector that found
// nothing, would satisfy the empty-offenders check perfectly.
renderShell();

let depth = 0;
for (
let node: HTMLElement | null = overlayHost().parentElement;
node !== null;
node = node.parentElement
) {
depth += 1;
}

expect(depth).toBeGreaterThan(2);
});
});

describe("the server render and the first client render agree", () => {
it("emits the defaults even when the store has preferences", () => {
// The hydration property, and it needs a real server render to state.
Expand Down
Loading
Loading