mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 15:56:17 +00:00
* ♻️ Derive per-side stroke widths from the side values The per-stroke `:stroke-per-side` boolean only gated whether the renderer looked at the four side widths, and the CSS generator used it to decide whether to emit one `border-width` or four. Comparing the sides is enough, so drop the attribute from the shape schema and from the stroke attribute list. The WASM property and upload bridges and `stroke-per-side-widths` now derive the per-side widths from the values alone. AI-assisted-by: deepseek-v4.1-flash * ✨ Declare per-side stroke width token attributes Replace the single `:stroke-width` token attribute with `:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom` and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map the new attributes to the strokes shape attribute and to the dimensions token type. AI-assisted-by: deepseek-v4.1-flash * 🐛 Unapply only the token of the changed stroke side A stroke change that reports a single per-side sub-attribute now resolves to that side's token only. A plain `:stroke-width` change still resolves to every side, and a change with no sub-attribute resolves to all width keys plus the color. Add a regression test that tokens on untouched sides survive a change to another side. AI-assisted-by: deepseek-v4.1-flash * ✨ Add a predicate for per-side stroke shapes Boards and rectangles support independent stroke widths per side. Expose `per-side-stroke-shape?` so callers can gate the per-side UI, and cover the supported and unsupported shape types with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add stroke side width materialization helper `materialize-stroke-side-widths` concretizes the four per-side width keys from a stroke: edited sides take the new value, the others keep their current width (0 when there is no stroke), and `:stroke-width` mirrors the top side for legacy consumers. This pulls the logic already duplicated in the token apply path into a shared helper, ready for the direct-edit path. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to every side `update-stroke-width` now writes the four per-side width keys, both when the shape already has a stroke and when it gets a new default one, so the applied-token bookkeeping matches the stroke attributes. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to a single side Add `update-stroke-width-side`, which changes only the sides named in `attributes` on the first stroke of each shape. The remaining sides keep their current width (0 when the shape had no stroke) and all side keys are materialized through the shared helper, so consumers never fall back to `:stroke-width`. Route the per-side token keys to the new function and update the apply, remap and component tests. AI-assisted-by: deepseek-v4.1-flash * ✨ Complete a partially applied per-side token on toggle When explicit attributes come from an input or a plugin call, toggle the token off only if it already covers every target attribute on every selected shape. A partial per-side application is completed instead of removed. The token pill keeps the previous any-attribute behavior. Add tests for both the completion and the full removal. AI-assisted-by: deepseek-v4.1-flash * ✨ Offer per-side stroke width actions in the token menu Boards and rectangles get a stroke width submenu with an all-sides action and one action per side; other shapes keep the single global action. The global action targets every per-side attribute so the design tab keeps showing the token on each side. Add the pill labels for the new attributes, the menu test, and the `workspace.tokens.stroke-width` string. AI-assisted-by: deepseek-v4.1-flash * ✨ Persist the per-side stroke preference Add `:stroke-per-side` to the user profile props schema and expose it through a derived `stroke-per-side` ref. The design tab will read the preference from here instead of a per-stroke attribute. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width helpers to the stroke menu Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which checks the feature flag and that a single board or rectangle, or a uniform multi-selection of them, is selected. Use it instead of the inline shape-type check and drop the old per-side toggle handler. Cover both helpers with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width token inputs to the design tab Turn the four side width fields into token inputs with detach actions and a `:multiple` mixed value when the sides differ. The per-side toggle now reads the persisted profile preference instead of a per-stroke attribute, so previous per-side edits survive. AI-assisted-by: deepseek-v4.1-flash * 🐛 Materialize stroke sides on direct width edit The stroke menu per-side handler only wrote the edited side key and, for the top side, the global `:stroke-width`. A stroke holding just `:stroke-width` made every consumer fall back to the global value, so editing one side changed all the others. Add a `change-stroke-side-width` event that materializes the four side keys through the shared helper and reports only the edited key as changed, so tokens on untouched sides are not unapplied. Route the menu handler through it. AI-assisted-by: deepseek-v4.1-flash * 🐛 Make stroke width fields non nullable Drop `:nillable` from the global and per-side stroke width inputs and use `:multiple` for the mixed state, so an empty field no longer represents a null width. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix the numeric-input props schema key The schema declared `:applied-token`, but the component body and every caller use `:applied-token-name`, so the prop was never validated. Rename the schema entry to match. AI-assisted-by: deepseek-v4.1-flash * ✨ Add token-disabled support to the numeric input The design-system numeric input accepts `:token-disabled` and `:token-tooltip`; the token button is disabled and shows the reason. Scope the disabled input style to `input:disabled` so a disabled token button no longer dims the whole field. Pass both props through the token wrapper. AI-assisted-by: deepseek-v4.1-flash * 🐛 Disable token controls below the first fill or stroke Design tokens only apply to the first fill or stroke of a shape. Add `tokens-allowed-position?` and mark the fill and stroke lists with `tokens-first-only`, so later entries disable their token controls and explain why. The colorpicker opens on the direct color tab and disables the token tab for those rows. Cover the helper with a test and add the new translation. AI-assisted-by: deepseek-v4.1-flash * ♻️ Refactor colorpicker style switcher to DS radio buttons Replace the legacy `components/radio-buttons` markup in the colorpicker with the design system `radio-buttons*`, using its declarative options API. Switching between direct color and token mode now passes string values, as the DS component expects. The previous keyword values broke the round trip back to color mode: the DOM stringifies keywords with a leading colon, so the value never matched `:direct-color`. Using plain strings keeps the conversion clean. AI-assisted-by: deepseek-v4.1-flash * ✨ Add playwright tests * ✨ Scope per-side stroke controls to each stroke Give every stroke row its own expanded state instead of sharing one profile-wide preference. The state lives in `:workspace-local`, keyed by `[ids index]`, so it survives selecting another shape and coming back but resets on reload. Remove the `:stroke-per-side` profile prop and its ref. The ref now derives from `:workspace-local`. Update the Playwright spec to expand the controls per stroke through the toggle, and assert that strokes toggle independently, that the state resets on reload, and that it survives switching shapes. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep stroke tokens when editing or removing later strokes The token unapply logic decided which tokens to clear from the shape using only the changed sub-attributes, without knowing which stroke was edited. Since stroke tokens only live on the first stroke, editing or removing a later stroke cleared the first stroke's tokens. Add a `:changed-item-index` option to `generate-update-shapes` and skip unapplying fill/stroke tokens when the changed item is not the first. The stroke color, attrs, side-width and remove events now report the index they touch. AI-assisted-by: deepseek-v4.1-flash * 🐛 Ignore token shortcuts when tokens are disabled for input The numeric input opened the token dropdown on `{` regardless of `token-disabled?`, so inputs that cannot hold tokens (for example, strokes after the first one) still opened it, and typing `{token}` plus `}` could apply a token there. Extract the key handling into `token-shortcut`, which returns nil when tokens are disabled, and use it for both `{` and `}`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Gate per-side stroke tokens on the WASM renderer The token context menu offered per-side stroke width actions whenever the feature flag was on and the shape was a board or rectangle, without checking the renderer. The classic renderer only draws the single `:stroke-width`, so applying a per-side token there wrote inert data, the token pill reported it, and the stroke changed appearance when the WASM renderer was later enabled. Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from both the design tab and the token context menu. Thread the renderer flag into the context menu through `:render-wasm`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep first-stroke tokens when reordering later strokes Fill and stroke tokens only ever live on the first item of the collection. When a stroke update arrives without a changed item index (for example reordering the second and third strokes), the unapply logic assumed the first item had been edited and removed every stroke token from the shape. Compare the first item before and after the update instead: when no item index is given, unapply only if the first item actually changed. Reordering later strokes now leaves the first stroke and its tokens untouched, while moving the first stroke away still detaches them. Explicit item edits keep their previous behavior. AI-assisted-by: deepseek-v4-flash
603 lines
20 KiB
JavaScript
603 lines
20 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
|
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
|
import { WorkspacePage } from "../../pages/WorkspacePage";
|
|
import { setupStrokePerSideFile, unfoldTokenType } from "./helpers";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await WasmWorkspacePage.init(page);
|
|
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
|
});
|
|
|
|
// Accessible names of the design tab controls, from the translations.
|
|
const TOP = "Top (block start)";
|
|
const RIGHT = "Right (inline end)";
|
|
const BOTTOM = "Bottom (block end)";
|
|
const LEFT = "Left (inline start)";
|
|
const GLOBAL = "Stroke width";
|
|
const PER_SIDE_TOGGLE_LABEL = "Stroke per side";
|
|
const ONLY_FIRST =
|
|
"Design tokens can only be applied to the first fill or stroke in the list.";
|
|
const MIXED = "Mixed";
|
|
|
|
function strokeSection(page) {
|
|
return page.getByTestId("right-sidebar").getByRole("region", {
|
|
name: "Stroke section",
|
|
});
|
|
}
|
|
|
|
function strokeRow(page, index = 0) {
|
|
return strokeSection(page).getByLabel(`stroke-row-${index}`);
|
|
}
|
|
|
|
function perSideOptions(page) {
|
|
return page.getByTestId("stroke.per-side-options");
|
|
}
|
|
|
|
async function enablePerSide(page) {
|
|
await page.getByTestId("stroke.per-side-toggle").click();
|
|
await expect(perSideOptions(page)).toBeVisible();
|
|
}
|
|
|
|
async function prepareShape(workspace, page) {
|
|
await workspace.layers.getByTestId("layer-row").nth(0).click();
|
|
await page.getByTestId("add-stroke").click();
|
|
await expect(strokeSection(page)).toBeVisible();
|
|
}
|
|
|
|
async function openTokensTab(page) {
|
|
await page.getByRole("tab", { name: "Tokens" }).click();
|
|
}
|
|
|
|
async function unfoldStrokeWidth(page, workspace) {
|
|
await openTokensTab(page);
|
|
await unfoldTokenType(workspace.tokensSidebar, "stroke width");
|
|
}
|
|
|
|
test.describe("Tokens: stroke per side", () => {
|
|
test("per-side toggle is hidden when the feature flag is off", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = new WasmWorkspacePage(page);
|
|
await workspace.mockConfigFlags(["enable-feature-token-input"]);
|
|
await workspace.setupEmptyFile();
|
|
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
|
await workspace.goToWorkspace();
|
|
await workspace.waitForFirstRender();
|
|
|
|
await prepareShape(workspace, page);
|
|
|
|
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
|
|
await expect(perSideOptions(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("per-side toggle is enabled for a board with the feature on", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
|
|
const toggle = page.getByTestId("stroke.per-side-toggle");
|
|
await expect(toggle).toBeVisible();
|
|
await expect(toggle).toBeEnabled();
|
|
await expect(toggle).toHaveAccessibleName(PER_SIDE_TOGGLE_LABEL);
|
|
await expect(perSideOptions(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("clicking the toggle expands and collapses the side inputs", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
|
|
const toggle = page.getByTestId("stroke.per-side-toggle");
|
|
await toggle.click();
|
|
|
|
await expect(perSideOptions(page)).toBeVisible();
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
|
).toBeVisible();
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
|
).toBeVisible();
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
|
|
).toBeVisible();
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: LEFT }),
|
|
).toBeVisible();
|
|
|
|
await toggle.click();
|
|
await expect(perSideOptions(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("the per-side toggle is independent for each stroke", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
// The add button stays in the section header, so a second stroke is
|
|
// one more click.
|
|
await page.getByTestId("add-stroke").click();
|
|
|
|
const firstRow = strokeRow(page, 0);
|
|
const secondRow = strokeRow(page, 1);
|
|
await expect(firstRow).toBeVisible();
|
|
await expect(secondRow).toBeVisible();
|
|
|
|
await firstRow.getByTestId("stroke.per-side-toggle").click();
|
|
|
|
await expect(firstRow.getByTestId("stroke.per-side-options")).toBeVisible();
|
|
await expect(secondRow.getByTestId("stroke.per-side-options")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("the per-side state is not persisted across a reload", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
|
|
await page.getByTestId("stroke.per-side-toggle").click();
|
|
await expect(perSideOptions(page)).toBeVisible();
|
|
|
|
await page.reload();
|
|
await workspace.waitForFirstRender();
|
|
await prepareShape(workspace, page);
|
|
|
|
await expect(perSideOptions(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("the per-side state survives switching shapes", async ({ page }) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
// Select another shape; it has no stroke, so nothing is expanded.
|
|
await workspace.clickLeafLayer("Board");
|
|
await expect(perSideOptions(page)).toHaveCount(0);
|
|
|
|
// Come back to the rectangle: its per-side state is still expanded.
|
|
await workspace.clickLeafLayer("Rectangle", {}, 0);
|
|
await expect(perSideOptions(page)).toBeVisible();
|
|
});
|
|
|
|
test("applying a stroke width token from the panel covers every side", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click();
|
|
|
|
const row = strokeRow(page);
|
|
await expect(
|
|
row
|
|
.getByLabel(GLOBAL, { exact: true })
|
|
.getByRole("button", { name: "width-big" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(4);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("applying the global action from the token menu covers every side", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
await workspace.tokenContextMenuForToken.getByText("All").first().click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(4);
|
|
});
|
|
|
|
test("a dimensions token targets every side through the menu", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await openTokensTab(page);
|
|
await unfoldTokenType(workspace.tokensSidebar, "dimensions");
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "dim.md" })
|
|
.click({ button: "right" });
|
|
|
|
const menu = workspace.tokenContextMenuForToken;
|
|
const strokeWidthEntry = menu
|
|
.getByRole("listitem")
|
|
.filter({ hasText: "Stroke Width" });
|
|
await strokeWidthEntry.hover();
|
|
await strokeWidthEntry
|
|
.getByRole("listitem")
|
|
.filter({ hasText: "All" })
|
|
.click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "dim.md" }),
|
|
).toHaveCount(4);
|
|
});
|
|
|
|
test("applying a token to one side leaves the other sides untouched", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
|
|
const menu = workspace.tokenContextMenuForToken;
|
|
await expect(menu.getByText("Top")).toBeVisible();
|
|
await menu.getByText("Top").click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: LEFT }),
|
|
).toHaveCount(1);
|
|
});
|
|
|
|
test("editing one side does not change the other sides", async ({ page }) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
const options = perSideOptions(page);
|
|
const rightInput = options.getByRole("textbox", { name: RIGHT });
|
|
const bottomInput = options.getByRole("textbox", { name: BOTTOM });
|
|
const leftInput = options.getByRole("textbox", { name: LEFT });
|
|
const rightBefore = await rightInput.inputValue();
|
|
const bottomBefore = await bottomInput.inputValue();
|
|
const leftBefore = await leftInput.inputValue();
|
|
|
|
const topInput = options.getByRole("textbox", { name: TOP });
|
|
await topInput.fill("8");
|
|
await topInput.press("Enter");
|
|
|
|
await expect(topInput).toHaveValue("8");
|
|
await expect(rightInput).toHaveValue(rightBefore);
|
|
await expect(bottomInput).toHaveValue(bottomBefore);
|
|
await expect(leftInput).toHaveValue(leftBefore);
|
|
|
|
// The uniform field cannot show a single width anymore.
|
|
await expect(
|
|
strokeRow(page).getByRole("textbox", { name: GLOBAL }),
|
|
).toHaveAttribute("placeholder", MIXED);
|
|
});
|
|
|
|
test("detaching a side token leaves the other sides applied", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(4);
|
|
|
|
await perSideOptions(page)
|
|
.getByRole("button", { name: "Detach token" })
|
|
.first()
|
|
.click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(3);
|
|
});
|
|
|
|
test("applying a token to one side creates the stroke when missing", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await workspace.layers.getByTestId("layer-row").nth(0).click();
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
await workspace.tokenContextMenuForToken.getByText("Top").click();
|
|
|
|
await expect(strokeSection(page)).toBeVisible();
|
|
await enablePerSide(page);
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
|
).toHaveValue("0");
|
|
});
|
|
|
|
test("a token on one side overrides a global token on that side only", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click();
|
|
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-small" })
|
|
.click({ button: "right" });
|
|
await workspace.tokenContextMenuForToken.getByText("Right").click();
|
|
|
|
const options = perSideOptions(page);
|
|
await expect(
|
|
options.getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(3);
|
|
await expect(
|
|
options.getByRole("button", { name: "width-small" }),
|
|
).toHaveCount(1);
|
|
});
|
|
|
|
test("selecting the token again on one side completes it on every side", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
await workspace.tokenContextMenuForToken.getByText("Top").click();
|
|
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(1);
|
|
|
|
// The uniform field is mixed; picking the same token there covers
|
|
// every side instead of removing the partial application. The uniform
|
|
// field is the first token control in the row.
|
|
const row = strokeRow(page);
|
|
await row.getByRole("button", { name: "Open token list" }).first().click();
|
|
await row.getByRole("option", { name: "width-big" }).click();
|
|
|
|
const options = perSideOptions(page);
|
|
await expect(
|
|
options.getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(4);
|
|
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(0);
|
|
});
|
|
|
|
test("clicking the token again removes it from every side", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
const chip = workspace.tokensSidebar.getByRole("button", {
|
|
name: "width-big",
|
|
});
|
|
await chip.click();
|
|
await expect(
|
|
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(4);
|
|
|
|
await chip.click();
|
|
const options = perSideOptions(page);
|
|
await expect(
|
|
options.getByRole("button", { name: "width-big" }),
|
|
).toHaveCount(0);
|
|
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(1);
|
|
await expect(options.getByRole("textbox", { name: RIGHT })).toHaveCount(1);
|
|
});
|
|
|
|
test("the token menu only shows per-side entries for eligible shapes", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
|
|
const menu = workspace.tokenContextMenuForToken;
|
|
await expect(menu.getByText("All")).toBeVisible();
|
|
await expect(menu.getByText("Top")).toBeVisible();
|
|
await expect(menu.getByText("Right")).toBeVisible();
|
|
await expect(menu.getByText("Bottom")).toBeVisible();
|
|
await expect(menu.getByText("Left")).toBeVisible();
|
|
});
|
|
|
|
test("the token menu hides per-side entries when the flag is off", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = new WasmWorkspacePage(page);
|
|
await workspace.mockConfigFlags(["enable-feature-token-input"]);
|
|
await workspace.setupEmptyFile();
|
|
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
|
await workspace.goToWorkspace();
|
|
await workspace.waitForFirstRender();
|
|
|
|
await prepareShape(workspace, page);
|
|
await unfoldStrokeWidth(page, workspace);
|
|
await workspace.tokensSidebar
|
|
.getByRole("button", { name: "width-big" })
|
|
.click({ button: "right" });
|
|
|
|
const menu = workspace.tokenContextMenuForToken;
|
|
await expect(menu.getByText("Stroke Width").first()).toBeVisible();
|
|
await expect(menu.getByText("Top")).toHaveCount(0);
|
|
await expect(menu.getByText("Right")).toHaveCount(0);
|
|
});
|
|
|
|
test("other shape types do not offer per-side controls", async ({ page }) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
|
|
await workspace.selectToolFromFlyout(workspace, {
|
|
triggerToolName: "Rectangle (R)",
|
|
targetToolName: "Ellipse (E)",
|
|
});
|
|
await workspace.clickWithDragViewportAt(520, 100, 100, 100);
|
|
await page.getByTestId("add-stroke").click();
|
|
|
|
await expect(strokeSection(page)).toBeVisible();
|
|
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
|
|
});
|
|
|
|
test("token controls are disabled on the second stroke", async ({ page }) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
|
|
// The add button stays in the section header, so a second stroke is
|
|
// one more click.
|
|
await page.getByTestId("add-stroke").click();
|
|
|
|
const firstRow = strokeRow(page, 0);
|
|
const secondRow = strokeRow(page, 1);
|
|
await expect(firstRow).toBeVisible();
|
|
await expect(secondRow).toBeVisible();
|
|
|
|
await expect(
|
|
firstRow.getByRole("button", { name: "Open token list" }),
|
|
).toBeEnabled();
|
|
const disabledTokenButton = secondRow.getByRole("button", {
|
|
name: ONLY_FIRST,
|
|
});
|
|
await expect(disabledTokenButton).toBeVisible();
|
|
await expect(disabledTokenButton).toBeDisabled();
|
|
});
|
|
|
|
test("the colorpicker disables tokens on the second stroke", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await page.getByTestId("add-stroke").click();
|
|
|
|
await strokeRow(page, 1)
|
|
.getByRole("button", { name: "#000000" })
|
|
.first()
|
|
.click();
|
|
|
|
const colorpicker = page.getByTestId("colorpicker");
|
|
await expect(colorpicker).toBeVisible();
|
|
await expect(
|
|
colorpicker.getByRole("button", { name: ONLY_FIRST }),
|
|
).toBeDisabled();
|
|
});
|
|
|
|
test("per-side is offered for a multi-selection of rectangles", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await workspace.clickToggableLayer("Board");
|
|
await workspace.clickLeafLayer("Rectangle", {}, 0);
|
|
await workspace.clickLeafLayer("Rectangle", { modifiers: ["Shift"] }, 1);
|
|
await page.getByTestId("add-stroke").click();
|
|
|
|
await expect(page.getByTestId("stroke.per-side-toggle")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Tokens: stroke per side (inspect)", () => {
|
|
test("Inspect styles expose the four per-side border widths", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await setupStrokePerSideFile(page);
|
|
await prepareShape(workspace, page);
|
|
await enablePerSide(page);
|
|
|
|
const options = perSideOptions(page);
|
|
for (const [name, value] of [
|
|
[TOP, "2"],
|
|
[RIGHT, "8"],
|
|
[BOTTOM, "4"],
|
|
[LEFT, "6"],
|
|
]) {
|
|
const input = options.getByRole("textbox", { name });
|
|
await input.fill(value);
|
|
await input.press("Enter");
|
|
}
|
|
|
|
await page.getByRole("tab", { name: "Inspect" }).click();
|
|
const panel = page
|
|
.getByTestId("right-sidebar")
|
|
.getByRole("article")
|
|
.filter({ hasText: "Stroke" });
|
|
|
|
const expectRow = async (term, value) => {
|
|
const row = panel.getByTestId("property-row").filter({ hasText: term });
|
|
await expect(row).toContainText(value);
|
|
};
|
|
|
|
await expectRow("Border block start width", "2px");
|
|
await expectRow("Border inline end width", "8px");
|
|
await expectRow("Border block end width", "4px");
|
|
await expectRow("Border inline start width", "6px");
|
|
});
|
|
});
|
|
|
|
test.describe("Tokens: stroke per side (non wasm)", () => {
|
|
test("per-side toggle is disabled without the new renderer", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = new WorkspacePage(page);
|
|
await workspace.mockConfigFlags([
|
|
"enable-stroke-per-side",
|
|
"enable-feature-token-input",
|
|
]);
|
|
await workspace.setupEmptyFile();
|
|
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
|
await workspace.goToWorkspace();
|
|
|
|
await prepareShape(workspace, page);
|
|
|
|
const toggle = page.getByTestId("stroke.per-side-toggle");
|
|
await expect(toggle).toBeVisible();
|
|
await expect(toggle).toBeDisabled();
|
|
await expect(toggle).toHaveAccessibleName(
|
|
/only available in the new render/,
|
|
);
|
|
});
|
|
});
|