// 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); }); });