Files
learnharness/tests/menu-shell-pure.test.ts
amosblomqvist 8675b19bef Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new
- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped
  (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip;
  computeMenuPlacement exported pure, placement-only (sites keep their own
  open/close/Esc contracts); re-places on capture-phase scroll/resize
- migrated: settings slot menus (agent-section), keys Add-provider,
  composer model+thinking (open up, right-aligned), SetupDialog pickers
  (portaled out of the overflow-hidden dialog frame — no clipping by
  construction); graph popovers + stats-bar summary stay bespoke (owner)
- /chat/new: the setup CTA above the draft blob, mirroring home's
- tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
2026-09-22 16:56:44 +03:00

82 lines
3.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { computeMenuPlacement, type MenuSide } from "../components/menu-shell.tsx";
/**
* The shared menu shell's placement decision (fix wave chunk B): the
* pure math behind every dropdown's anchoring — side preference, the
* honest-fit flip, the 60vh cap, alignment. The DOM effects (portal,
* measure-then-show, re-placement on scroll/resize) are live-verified;
* this pins the geometry.
*/
const rect = (top: number, bottom: number) => ({ top, bottom, left: 100, right: 300 });
const viewport = (height: number) => ({ width: 1280, height });
describe("computeMenuPlacement — side", () => {
it("keeps the preferred side when it shows the whole menu", () => {
for (const preferred of ["up", "down"] as MenuSide[]) {
const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, preferred, "right");
assert.equal(p.side, preferred);
}
});
it("flips when the preferred side cannot hold the content and the other is roomier", () => {
// Anchor 30px above the viewport bottom: down has ~2px of room,
// up has ~380. A 300px menu must flip UP despite preferring down.
const p = computeMenuPlacement(rect(770, 800), viewport(800), 300, "down", "right");
assert.equal(p.side, "up");
// ...and mirrors: an anchor at the very top preferring up flips down.
const q = computeMenuPlacement(rect(0, 30), viewport(800), 300, "up", "right");
assert.equal(q.side, "down");
});
it("stays put when neither side fits better (the roomier side already preferred)", () => {
// Short viewport: down has 450, up has 200 — a 600px menu stays
// down (capped gracefully) because up is NOT roomier.
const p = computeMenuPlacement(rect(150, 190), viewport(650), 600, "down", "right");
assert.equal(p.side, "down");
});
it("nothing measured yet (contentHeight ≤ 0) keeps the preferred side", () => {
const p = computeMenuPlacement(rect(770, 800), viewport(800), 0, "down", "right");
assert.equal(p.side, "down");
});
});
describe("computeMenuPlacement — cap + position", () => {
it("caps at 60vh when the side has more room than that", () => {
// Anchor high: 648px below it — more than 60vh(480) → the cap is 60vh.
const p = computeMenuPlacement(rect(100, 140), viewport(800), 300, "down", "right");
assert.equal(p.maxHeight, 0.6 * 800);
assert.equal(p.style.top, 144); // anchor bottom + GAP(4)
assert.equal(p.style.right, 1280 - 300); // right-aligned to the anchor
});
it("caps at the side's available room when 60vh exceeds it", () => {
// Anchor at 500/800: down room = 800 − 8 − 544 = 248 < 480. The
// short content (100) fits down — no flip — so the cap binds at 248.
const p = computeMenuPlacement(rect(500, 540), viewport(800), 100, "down", "right");
assert.equal(p.side, "down");
assert.equal(p.maxHeight, 248);
});
it("never crushes below the usability floor, even against a tiny side", () => {
// Anchor 30px above the bottom, both sides thin: capped to the
// floor, not a 2px sliver — the cap overflows rather than crushes.
const p = computeMenuPlacement(rect(30, 60), viewport(90), 300, "down", "right");
assert.equal(p.maxHeight, 96);
});
it("up-side menus anchor their BOTTOM above the anchor's top", () => {
const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, "up", "right");
assert.equal(p.style.bottom, 800 - 400 + 4); // viewport − anchor.top − GAP
assert.ok(!("top" in p.style));
});
it("left alignment keys off the anchor's left edge", () => {
const p = computeMenuPlacement(rect(400, 440), viewport(800), 300, "down", "left");
assert.equal(p.style.left, 100);
assert.ok(!("right" in p.style));
});
});