Files
paseo/packages/app/src/components/ui/anchor.test.ts
T
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00

123 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
const CONTENT = { width: 200, height: 100 };
function position(overrides: Partial<Parameters<typeof computePosition>[0]> = {}) {
return computePosition({
triggerRect: TRIGGER,
contentSize: CONTENT,
displayArea: DISPLAY,
placement: "bottom",
alignment: "start",
offset: 4,
...overrides,
});
}
describe("computePosition", () => {
it("sits below the trigger with room to spare", () => {
expect(position()).toEqual({ x: 100, y: 124, actualPlacement: "bottom" });
});
it("aligns to the trigger's start, center, and end edges", () => {
expect(position({ alignment: "start" }).x).toBe(100);
expect(position({ alignment: "center" }).x).toBe(20);
// 100 + 40 - 200 = -60, pulled back to the edge padding.
expect(position({ alignment: "end" }).x).toBe(8);
});
it("flips above the trigger when it does not fit below and there is more room above", () => {
const result = position({
triggerRect: { ...TRIGGER, y: 700 },
contentSize: { width: 200, height: 200 },
});
expect(result.actualPlacement).toBe("top");
expect(result.y).toBe(496);
});
it("stays put when flipping would be just as cramped", () => {
// Taller than the whole display area: neither side fits, so flipping buys nothing.
const result = position({
displayArea: { x: 0, y: 0, width: 1000, height: 300 },
contentSize: { width: 200, height: 400 },
});
expect(result.actualPlacement).toBe("bottom");
});
it("places side placements flush with the trigger top and ignores alignment", () => {
const left = position({ placement: "left", alignment: "end" });
expect(left).toEqual({ x: 8, y: 100, actualPlacement: "left" });
const right = position({ placement: "right", alignment: "end" });
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
});
it("lets hover cards flip horizontally when the opposite side has room", () => {
const result = position({
triggerRect: { ...TRIGGER, x: 850 },
placement: "right",
flipHorizontal: true,
});
expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" });
expect(
position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement,
).toBe("right");
});
it("keeps the surface inside a display area that does not start at the origin", () => {
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
const result = position({
displayArea: { x: 100, y: 0, width: 900, height: 800 },
alignment: "end",
});
expect(result.x).toBe(108);
});
});
describe("getTransformOrigin", () => {
it.each([
["bottom", "start", [0, 0, 0]],
["bottom", "center", ["50%", 0, 0]],
["bottom", "end", ["100%", 0, 0]],
["top", "start", [0, "100%", 0]],
["top", "center", ["50%", "100%", 0]],
["top", "end", ["100%", "100%", 0]],
["left", "start", [0, "50%", 0]],
["left", "center", ["50%", "50%", 0]],
["left", "end", ["100%", "50%", 0]],
["right", "start", [0, "50%", 0]],
["right", "center", ["50%", "50%", 0]],
["right", "end", ["100%", "50%", 0]],
] as const)("emits a native-ready origin for %s/%s", (placement, alignment, expected) => {
// Theme updates may reach Fabric without React Native's JS string preprocessing.
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
});
});
describe("fitSurfaceWidth", () => {
it("leaves a surface that fits the display alone, capped at the display", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
width: 240,
minWidth: 180,
maxWidth: 984,
});
});
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
width: 204,
minWidth: 180,
maxWidth: 204,
});
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
width: undefined,
minWidth: 134,
maxWidth: 134,
});
});
});