The open list of CategoryCombobox was positioned `absolute` inside its input's container, so a scrolling ancestor clipped it: the body of the split window, the `overflow-x-auto` wrapper of the Transactions table. It is now rendered in a portal on document.body, `position: fixed`, at z-[300] (above every window, the highest at z-[210]). The pure rule lives in placeListbox.ts: under the input, as wide as it, 4 px below; above it when the room below is short of the list's 240 px and the room above has them; below when neither side has them. The list is placed in a layout effect before the first paint, then follows the input on every scroll (capture phase, any scrolling container) and resize while it is open, skipping scrolls of the list itself; the listeners are removed with the list (followAnchor returns the cleanup). The outside press counts the list as inside, since it no longer sits in the container's DOM. The closed markup is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
// placeListbox — where the open list of a category combobox goes (#429).
|
|
// The portal itself needs a DOM (manual check); its placement rule and the
|
|
// listeners that keep it on its input do not, and are pinned here.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
LISTBOX_GAP,
|
|
LISTBOX_MAX_HEIGHT,
|
|
followAnchor,
|
|
placeListbox,
|
|
samePlacement,
|
|
type ListenerTarget,
|
|
type ListboxPlacement,
|
|
} from "./placeListbox";
|
|
|
|
/** An input 30 px high and 200 px wide, its top at `top`. */
|
|
const anchorAt = (top: number) => ({ top, bottom: top + 30, left: 50, width: 200 });
|
|
|
|
describe("placeListbox", () => {
|
|
it("keeps the list's size of max-h-60 and the gap of mt-1", () => {
|
|
expect(LISTBOX_MAX_HEIGHT).toBe(240);
|
|
expect(LISTBOX_GAP).toBe(4);
|
|
});
|
|
|
|
it("places the list under the input, as wide as it, at the input's left edge", () => {
|
|
expect(placeListbox({ anchor: anchorAt(100), viewportHeight: 800 })).toEqual({
|
|
left: 50,
|
|
width: 200,
|
|
openUpward: false,
|
|
top: 134,
|
|
});
|
|
});
|
|
|
|
it("stays below when the room below is exactly the list's maximum height", () => {
|
|
// 130 (input's bottom) + 4 + 240 = 374.
|
|
const placement = placeListbox({ anchor: anchorAt(100), viewportHeight: 374 });
|
|
expect(placement.openUpward).toBe(false);
|
|
expect(placement.top).toBe(134);
|
|
});
|
|
|
|
it("opens upward when the room below is short and the room above suffices", () => {
|
|
const placement = placeListbox({ anchor: anchorAt(700), viewportHeight: 800 });
|
|
expect(placement).toEqual({ left: 50, width: 200, openUpward: true, bottom: 104 });
|
|
// Its bottom edge sits the gap above the input, whatever its height.
|
|
expect(800 - placement.bottom!).toBe(700 - LISTBOX_GAP);
|
|
});
|
|
|
|
it("opens upward when the room above is exactly the list's maximum height", () => {
|
|
// Room above: 244 - 4 = 240; room below: 300 - 274 - 4 = 22.
|
|
expect(placeListbox({ anchor: anchorAt(244), viewportHeight: 300 }).openUpward).toBe(true);
|
|
});
|
|
|
|
it("stays below when room is short on both sides, even with more room above", () => {
|
|
// Room above: 200 - 4 = 196; room below: 400 - 230 - 4 = 166.
|
|
expect(placeListbox({ anchor: anchorAt(200), viewportHeight: 400 })).toEqual({
|
|
left: 50,
|
|
width: 200,
|
|
openUpward: false,
|
|
top: 234,
|
|
});
|
|
// One pixel short above: still below.
|
|
expect(placeListbox({ anchor: anchorAt(243), viewportHeight: 300 }).openUpward).toBe(false);
|
|
});
|
|
|
|
it("reads the maximum height and the gap it is given", () => {
|
|
// Room below: 800 - 730 - 10 = 60, under 100; room above: 700 - 10 = 690.
|
|
expect(
|
|
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 100, gap: 10 }),
|
|
).toEqual({ left: 50, width: 200, openUpward: true, bottom: 110 });
|
|
// Room below: 60, enough for a list of 50 px.
|
|
expect(
|
|
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 50, gap: 10 }),
|
|
).toEqual({ left: 50, width: 200, openUpward: false, top: 740 });
|
|
});
|
|
});
|
|
|
|
describe("samePlacement", () => {
|
|
const below: ListboxPlacement = { left: 50, width: 200, openUpward: false, top: 134 };
|
|
|
|
it("tells an unchanged position apart from a moved one", () => {
|
|
expect(samePlacement({ ...below }, below)).toBe(true);
|
|
expect(samePlacement(null, below)).toBe(false);
|
|
expect(samePlacement({ ...below, top: 135 }, below)).toBe(false);
|
|
expect(samePlacement({ ...below, left: 51 }, below)).toBe(false);
|
|
expect(samePlacement({ ...below, width: 201 }, below)).toBe(false);
|
|
expect(
|
|
samePlacement({ left: 50, width: 200, openUpward: true, bottom: 134 }, below),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A fake `window` with the DOM's rule: a listener is removed only by the same
|
|
* type, the same function and the same capture flag.
|
|
*/
|
|
function fakeTarget() {
|
|
const listeners: Array<{ type: string; fn: (event: Event) => void; capture: boolean }> = [];
|
|
const target: ListenerTarget = {
|
|
addEventListener: (type, fn, capture = false) => {
|
|
listeners.push({ type, fn, capture });
|
|
},
|
|
removeEventListener: (type, fn, capture = false) => {
|
|
const at = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture);
|
|
if (at >= 0) listeners.splice(at, 1);
|
|
},
|
|
};
|
|
const fire = (type: string, eventTarget: unknown = null) => {
|
|
for (const l of [...listeners]) if (l.type === type) l.fn({ target: eventTarget } as unknown as Event);
|
|
};
|
|
return { target, listeners, fire };
|
|
}
|
|
|
|
/** A node of the list: it contains itself and its options. */
|
|
const option = { id: "option" };
|
|
const listNode = { contains: (node: unknown) => node === listNode || node === option } as unknown as Node;
|
|
const outside = { id: "a scrolling container" };
|
|
|
|
describe("followAnchor", () => {
|
|
it("listens to scroll in the capture phase, since scroll does not bubble, and to resize", () => {
|
|
const { target, listeners } = fakeTarget();
|
|
followAnchor(target, vi.fn(), () => listNode);
|
|
expect(listeners.map(({ type, capture }) => ({ type, capture }))).toEqual([
|
|
{ type: "scroll", capture: true },
|
|
{ type: "resize", capture: false },
|
|
]);
|
|
});
|
|
|
|
it("moves the list on a scroll of the page or of a container, and on a resize", () => {
|
|
const { target, fire } = fakeTarget();
|
|
const onMove = vi.fn();
|
|
followAnchor(target, onMove, () => listNode);
|
|
fire("scroll", outside);
|
|
expect(onMove).toHaveBeenCalledTimes(1);
|
|
fire("resize");
|
|
expect(onMove).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not move the list on a scroll of the list itself", () => {
|
|
const { target, fire } = fakeTarget();
|
|
const onMove = vi.fn();
|
|
followAnchor(target, onMove, () => listNode);
|
|
fire("scroll", listNode);
|
|
fire("scroll", option);
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
// Before the list is in the page, every scroll counts.
|
|
const early = fakeTarget();
|
|
const onEarlyMove = vi.fn();
|
|
followAnchor(early.target, onEarlyMove, () => null);
|
|
early.fire("scroll", outside);
|
|
expect(onEarlyMove).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("removes exactly its own listeners, capture flag included", () => {
|
|
const { target, listeners, fire } = fakeTarget();
|
|
const onMove = vi.fn();
|
|
const stop = followAnchor(target, onMove, () => listNode);
|
|
stop();
|
|
expect(listeners).toEqual([]);
|
|
fire("scroll", outside);
|
|
fire("resize");
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves another open list's listeners in place", () => {
|
|
const { target, listeners, fire } = fakeTarget();
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
const stopFirst = followAnchor(target, first, () => listNode);
|
|
followAnchor(target, second, () => listNode);
|
|
stopFirst();
|
|
expect(listeners).toHaveLength(2);
|
|
fire("scroll", outside);
|
|
expect(first).not.toHaveBeenCalled();
|
|
expect(second).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|