fix: Preserve template validation and navigation layout when merging main

This commit is contained in:
Oleg Isonen committed 2026-09-09 16:05:04 +01:00
commit 9bc31ca679
7 files changed
+361 -23

No files matched your search

@@ -0,0 +1,111 @@
import { page } from "@vitest/browser/context";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { TooltipProvider } from "@webstudio-is/design-system";
import type { Asset } from "@webstudio-is/sdk";
import { createAssetFolderFixture } from "@webstudio-is/sdk/testing";
import { $assets, $assetFolders } from "~/shared/sync/data-stores";
import { $authPermit } from "~/shared/nano-states";
import { createAssetManagerTestRenderer } from "~/builder/shared/asset-manager/test-utils";
import { SelectAsset } from "./select-asset";
const renderer = createAssetManagerTestRenderer();
const asset: Asset = {
id: "image",
projectId: "project",
name: "image.svg",
filename: "A long image name that should not widen the source picker",
format: "svg",
type: "image",
size: 100,
meta: { width: 100, height: 100 },
createdAt: "2026-01-01T00:00:00.000Z",
};
beforeEach(() => {
$authPermit.set("build");
$assets.set(new Map([[asset.id, asset]]));
$assetFolders.set(new Map());
});
afterEach(() => {
renderer.cleanup();
$assets.set(new Map());
$assetFolders.set(new Map());
});
const openPicker = async (onChange = vi.fn()) => {
renderer.render(
<TooltipProvider>
<div data-floating-panel-container style={{ width: 260, height: 600 }}>
<SelectAsset accept="image/*" onChange={onChange} />
</div>
</TooltipProvider>
);
await act(async () => {
await page.getByRole("button", { name: "Choose source" }).click();
});
await expect
.element(page.getByRole("dialog", { name: "Images" }))
.toBeVisible();
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
const thumbnail = dialog.querySelector<HTMLButtonElement>(
"[data-asset-manager-thumbnail-button]"
)!;
// Let opening layout and the initial resize observer delivery finish.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
});
return { dialog, thumbnail, onChange };
};
test.each([false, true])(
"keeps the source picker width when selecting an image (nested: %s)",
async (nested) => {
if (nested) {
const parent = createAssetFolderFixture({
id: "parent",
name: "A long parent folder name",
});
const folder = createAssetFolderFixture({
id: "folder",
parentId: parent.id,
name: "A long nested folder name",
});
$assetFolders.set(
new Map([
[parent.id, parent],
[folder.id, folder],
])
);
$assets.set(new Map([[asset.id, { ...asset, folderId: folder.id }]]));
}
const { dialog } = await openPicker();
if (nested) {
await act(async () => {
await page.getByRole("textbox", { name: "Search" }).fill("image");
});
}
const thumbnail = dialog.querySelector<HTMLButtonElement>(
"[data-asset-manager-thumbnail-button]"
)!;
const width = dialog.getBoundingClientRect().width;
expect(width).toBe(240);
act(() => thumbnail.focus());
await new Promise(requestAnimationFrame);
expect(dialog.getBoundingClientRect().width).toBe(width);
}
);
test("chooses an image with a quick thumbnail click", async () => {
const { thumbnail, onChange } = await openPicker();
const preview = thumbnail.firstElementChild!;
// Allow a frame between press and release, as with a normal mouse click.
await act(async () => {
await page.elementLocator(preview).click({ delay: 80 });
});
expect(onChange).toHaveBeenCalledExactlyOnceWith(asset.id);
await expect
.element(page.getByRole("dialog", { name: "Images" }))
.not.toBeInTheDocument();
});
@@ -5,7 +5,7 @@ import {
useRef,
useLayoutEffect,
} from "react";
import { css, theme } from "../stitches.config";
import { theme } from "../stitches.config";
import {
Dialog,
DialogTitleActions,
@@ -130,11 +130,6 @@ type FloatingPanelProps = {
closeOnInteractOutside?: boolean;
};
const contentStyle = css({
width: theme.sizes.sidebarWidth,
overflow: "auto",
});
const defaultOffset: OffsetOptions = { mainAxis: 0, crossAxis: 0 };
export const FloatingPanel = ({
@@ -313,7 +308,7 @@ export const FloatingPanel = ({
{children}
</DialogTrigger>
<DialogContent
className={contentStyle()}
css={{ width: theme.sizes.sidebarWidth, overflow: "auto" }}
width={width}
height={height}
{...position}
@@ -23,6 +23,32 @@ const pressArrow = (element: HTMLElement, key: string) => {
});
};
test("keeps navigation labels left aligned beside their icons", () => {
const container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() =>
root?.render(
<InsetList css={{ width: 220 }}>
<InsetListItem asChild>
<a href="#projects">
<svg width="16" height="16" />
<span>Projects</span>
</a>
</InsetListItem>
<InsetListItem>Settings</InsetListItem>
</InsetList>
)
);
const link = container.querySelector("a")!;
const icon = link.querySelector("svg")!.getBoundingClientRect();
const label = link.querySelector("span")!.getBoundingClientRect();
expect(label.left - icon.right).toBeLessThanOrEqual(8);
expect(getComputedStyle(container.querySelector("button")!).textAlign).toBe(
"left"
);
});
test.each(["{Enter}", " "])("activates list items with %s", async (key) => {
const container = document.createElement("div");
document.body.append(container);
@@ -1,4 +1,5 @@
import { useState } from "react";
import { GearIcon } from "@webstudio-is/icons";
import { Grid } from "./grid";
import { InsetList, InsetListItem } from "./inset-list";
import { List, ListItem } from "./primitives/list";
@@ -54,6 +55,19 @@ export const SupportingText = () => (
</StorySection>
);
export const NavigationWithIcons = () => (
<StorySection title="Navigation with icons">
<InsetList css={{ width: 220 }}>
{items.map((item) => (
<InsetListItem key={item}>
<GearIcon />
<Text variant="labels">{item}</Text>
</InsetListItem>
))}
</InsetList>
</StorySection>
);
export const Columns = () => (
<StorySection title="Columns">
<List asChild aria-label="Projects">
@@ -15,11 +15,11 @@ export const InsetList = styled("div", {
padding: theme.spacing[3],
});
const insetListItemStyle = css({
const insetListItemStyle = css(buttonStyle, {
height: "auto",
minHeight: theme.spacing[13],
padding: theme.spacing[3],
justifyContent: "stretch",
justifyContent: "start",
textAlign: "left",
overflow: "hidden",
gap: theme.spacing[5],
@@ -52,9 +52,10 @@ export const InsetListItem = forwardRef(
{...props}
ref={ref}
data-state={dataState ?? "auto"}
className={buttonStyle({
className={insetListItemStyle({
color: "ghost",
className: insetListItemStyle({ className, css: cssProp }),
className,
css: cssProp,
})}
/>
);
@@ -62,6 +62,33 @@ test.each(["body", "templates", "list-item"])(
}
);
test.each(["list", "templates", "box", "paragraph"])(
"does not inherit a description-list parent into a template from %s",
(start) => {
const data = renderData(
<ws.element ws:tag="dl" ws:id="list">
<ws.block ws:id="block">
<BlockTemplate ws:id="templates">
<ws.element ws:tag="div" ws:id="box">
<ws.element ws:tag="p" ws:id="paragraph">
Content
</ws.element>
</ws.element>
</BlockTemplate>
</ws.block>
</ws.element>
);
const selector = ["paragraph", "box", "templates", "block", "list"];
expect(
isTreeSatisfyingContentModel({
...data,
metas: defaultMetas,
instanceSelector: selector.slice(selector.indexOf(start)),
})
).toBe(true);
}
);
test.each(["template", "body"])(
"still rejects invalid list nesting in %s content",
(location) => {
@@ -209,6 +236,149 @@ test("none category accepted by parent by tag", () => {
).toBeTruthy();
});
describe("description lists", () => {
test("does not accept an internal context marker as an HTML tag", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<ws.element ws:tag="dl-child" />
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["listId"],
})
).toBe(false);
});
test.each([
["listId"],
["groupId", "listId"],
["termId", "groupId", "listId"],
["detailId", "groupId", "listId"],
])("accepts div groups when validating from %j", (...instanceSelector) => {
const errors: string[] = [];
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<ws.element ws:tag="div" ws:id="groupId">
<ws.element ws:tag="dt" ws:id="termId">
Company
</ws.element>
<ws.element ws:tag="dd" ws:id="detailId">
XXX
</ws.element>
</ws.element>
<ws.element ws:tag="div">
<ws.element ws:tag="dt">Tax ID (NIP EU)</ws.element>
<ws.element ws:tag="dd">XXX</ws.element>
</ws.element>
<ws.element ws:tag="div">
<ws.element ws:tag="dt">Address</ws.element>
<ws.element ws:tag="dd">...</ws.element>
</ws.element>
</ws.element>
),
metas: defaultMetas,
instanceSelector,
onError: (message) => errors.push(message),
})
).toBe(true);
expect(errors).toEqual([]);
});
test("accepts div groups through components without HTML tags", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<Fragment>
<ws.element ws:tag="div">
<Slot>
<ws.element ws:tag="dt">Company</ws.element>
<ws.element ws:tag="dd">XXX</ws.element>
</Slot>
</ws.element>
</Fragment>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["listId"],
})
).toBe(true);
});
test("rejects flow content directly inside a description-list group", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<ws.element ws:tag="div">
<ws.element ws:tag="p">Company</ws.element>
</ws.element>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["listId"],
})
).toBe(false);
});
test.each(["dt", "dd"])("keeps %s restricted to description lists", (tag) => {
for (const [ancestor, parent] of [
["div", "div"],
["dl", "div"],
["dl", "a"],
["dl", "section"],
]) {
const data = renderData(
<ws.element ws:tag={ancestor} ws:id="rootId">
<ws.element ws:tag={parent} ws:id="groupId">
<ws.element ws:tag="div" ws:id="wrapperId">
<ws.element ws:tag={tag} ws:id="childId">
XXX
</ws.element>
</ws.element>
</ws.element>
</ws.element>
);
expect(
isTreeSatisfyingContentModel({
...data,
metas: defaultMetas,
instanceSelector: ["childId", "wrapperId", "groupId", "rootId"],
})
).toBe(false);
}
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<ws.element ws:tag="div">
<ws.element ws:tag={tag}>XXX</ws.element>
</ws.element>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["listId"],
})
).toBe(true);
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="dl" ws:id="listId">
<ws.element ws:tag="dt">Company</ws.element>
<ws.element ws:tag="dd">XXX</ws.element>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["listId"],
})
).toBe(true);
});
});
test("none category prevents unacceptable parent", () => {
expect(
isTreeSatisfyingContentModel({
@@ -119,7 +119,8 @@ const isTagSatisfyingContentModel = ({
*/
const getElementChildren = (
tag: undefined | string,
allowedCategories: undefined | string[]
allowedCategories: undefined | string[],
parentTag: undefined | string
) => {
// A transparent component without a known parent imposes no constraint.
if (tag === undefined && allowedCategories === undefined) {
@@ -138,6 +139,10 @@ const getElementChildren = (
category === "transparent" ? inheritedCategories : category
);
}
// A div directly under dl contains name-value groups instead of flow content.
if (tag === "div" && parentTag === "dl") {
elementChildren = ["dt", "dd", "script-supporting elements"];
}
// introduce custom non-interactive category to restrict nesting interactive elements
// like button > button or a > input
if (
@@ -187,6 +192,7 @@ const computeAllowedCategories = ({
}) => {
let instance: undefined | Instance;
let allowedCategories: undefined | string[];
let parentTag: undefined | string;
// skip selected instance for which these constraints are computed
for (const instanceId of instanceSelector.slice(1).reverse()) {
instance = instances.get(instanceId);
@@ -197,12 +203,15 @@ const computeAllowedCategories = ({
// Template roots are reusable definitions, not children of the page HTML.
if (instance.component === blockTemplateComponent) {
allowedCategories = undefined;
parentTag = undefined;
continue;
}
const tag = getTag({ instance, metas, props, htmlTagsByInstanceId });
allowedCategories = getElementChildren(tag, allowedCategories);
allowedCategories = getElementChildren(tag, allowedCategories, parentTag);
// Tagless components do not change the rendered HTML parent.
parentTag = tag ?? parentTag;
}
return allowedCategories;
return { allowedCategories, parentTag };
};
const findHtmlConstraintInstance = ({
@@ -223,6 +232,7 @@ const findHtmlConstraintInstance = ({
component: Instance["component"];
}) => {
let allowedCategories: undefined | string[];
let parentTag: undefined | string;
let wasSatisfying = true;
let constraintInstance: undefined | Instance;
@@ -233,6 +243,7 @@ const findHtmlConstraintInstance = ({
}
if (ancestor.component === blockTemplateComponent) {
allowedCategories = undefined;
parentTag = undefined;
constraintInstance = undefined;
wasSatisfying = true;
continue;
@@ -243,7 +254,12 @@ const findHtmlConstraintInstance = ({
props,
htmlTagsByInstanceId,
});
allowedCategories = getElementChildren(ancestorTag, allowedCategories);
allowedCategories = getElementChildren(
ancestorTag,
allowedCategories,
parentTag
);
parentTag = ancestorTag ?? parentTag;
const isSatisfying = isTagSatisfyingContentModel({
tag,
component,
@@ -429,6 +445,7 @@ export const isTreeSatisfyingContentModel = ({
htmlTagsByInstanceId = getHtmlTagsFromProps(props),
onError,
_allowedCategories: allowedCategories,
_parentTag: parentTag,
_allowedAncestorCategories: allowedAncestorCategories,
_allowedParentCategories: allowedParentCategories,
}: {
@@ -439,17 +456,20 @@ export const isTreeSatisfyingContentModel = ({
htmlTagsByInstanceId?: HtmlTagsByInstanceId;
onError?: (message: string, instanceSelector: InstanceSelector) => void;
_allowedCategories?: string[];
_parentTag?: string;
_allowedAncestorCategories?: string[];
_allowedParentCategories?: string[];
}): boolean => {
// compute constraints only when not passed from parent
allowedCategories ??= computeAllowedCategories({
instanceSelector,
instances,
props,
metas,
htmlTagsByInstanceId,
});
if (allowedCategories === undefined) {
({ allowedCategories, parentTag } = computeAllowedCategories({
instanceSelector,
instances,
props,
metas,
htmlTagsByInstanceId,
}));
}
allowedParentCategories ??= getAllowedParentCategories({
instanceSelector,
instances,
@@ -547,7 +567,7 @@ export const isTreeSatisfyingContentModel = ({
isSatisfying = false;
}
const contentModel = getComponentContentModel(metas.get(instance.component));
allowedCategories = getElementChildren(tag, allowedCategories);
allowedCategories = getElementChildren(tag, allowedCategories, parentTag);
allowedParentCategories = contentModel.children;
if (contentModel.descendants) {
allowedAncestorCategories ??= [];
@@ -569,6 +589,7 @@ export const isTreeSatisfyingContentModel = ({
instance.component === blockTemplateComponent
? undefined
: allowedCategories,
_parentTag: tag ?? parentTag,
_allowedParentCategories: allowedParentCategories,
_allowedAncestorCategories: allowedAncestorCategories,
});