diff --git a/src/components/shared/CategoryCombobox.tsx b/src/components/shared/CategoryCombobox.tsx index 41cc9ee..4a2c869 100644 --- a/src/components/shared/CategoryCombobox.tsx +++ b/src/components/shared/CategoryCombobox.tsx @@ -1,5 +1,13 @@ -import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useCallback, useId, useMemo } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { + LISTBOX_MAX_HEIGHT, + followAnchor, + placeListbox, + samePlacement, + type ListboxPlacement, +} from "./placeListbox"; /** * Minimal shape the combobox needs. `Category` (transactions, reports) and * `CategoryTreeNode` (categories page) both satisfy it, so callers pass their @@ -135,6 +143,7 @@ export default function CategoryCombobox( const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlightIndex, setHighlightIndex] = useState(0); + const [placement, setPlacement] = useState(null); const inputRef = useRef(null); const listRef = useRef(null); const containerRef = useRef(null); @@ -187,10 +196,36 @@ export default function CategoryCombobox( } }, [highlightIndex, open]); + // The open list lives in a portal on document.body (#429): no scrolling + // container clips it. It is placed on its input before the first paint, + // then follows the input on every scroll and resize, even out of the + // visible part of its container; the listeners go with the list. + const updatePlacement = useCallback(() => { + const input = inputRef.current; + if (!input) return; + const next = placeListbox({ + anchor: input.getBoundingClientRect(), + viewportHeight: window.innerHeight, + }); + setPlacement((prev) => (samePlacement(prev, next) ? prev : next)); + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePlacement(); + return followAnchor(window, updatePlacement, () => listRef.current); + }, [open, updatePlacement]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + // The list is outside the container in the DOM: a press on an option + // counts as inside, or the list would close before the click selects. + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) && + !listRef.current?.contains(e.target as Node) + ) { setOpen(false); setQuery(""); } @@ -277,12 +312,20 @@ export default function CategoryCombobox( onKeyDown={handleKeyDown} className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`} /> - {open && totalItems > 0 && ( + {open && totalItems > 0 && placement && createPortal(
    {filteredExtras.map((opt, i) => (
  • (
  • ); })} -
+ , + document.body, )} ); diff --git a/src/components/shared/placeListbox.test.ts b/src/components/shared/placeListbox.test.ts new file mode 100644 index 0000000..ccc9950 --- /dev/null +++ b/src/components/shared/placeListbox.test.ts @@ -0,0 +1,175 @@ +// 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); + }); +}); diff --git a/src/components/shared/placeListbox.ts b/src/components/shared/placeListbox.ts new file mode 100644 index 0000000..a3f4ba8 --- /dev/null +++ b/src/components/shared/placeListbox.ts @@ -0,0 +1,105 @@ +// placeListbox — where the open list of a combobox goes (#429). +// +// `CategoryCombobox` renders its open list in a portal on `document.body`, +// with `position: fixed`, so no scrolling container clips it: the body of the +// split window, the wrapper of the Transactions table, a modal. What does not +// need a DOM lives here, so vitest's node environment can pin it: the +// placement rule, and the listeners that keep the list on its input. + +/** Maximum height of the list, in pixels (Tailwind's `max-h-60`). */ +export const LISTBOX_MAX_HEIGHT = 240; + +/** Gap between the input and the list, in pixels (the former `mt-1`). */ +export const LISTBOX_GAP = 4; + +/** What the placement reads of the input: a `DOMRect` fits. */ +export interface ListboxAnchor { + top: number; + bottom: number; + left: number; + width: number; +} + +/** + * Fixed position of the list, in viewport pixels, as wide as the input. + * Below the input, `top` is the list's top edge. Above it, `bottom` is the + * distance from the viewport's bottom edge to the list's bottom edge, so a + * list shorter than its maximum height still ends right above the input. + */ +export interface ListboxPlacement { + left: number; + width: number; + openUpward: boolean; + top?: number; + bottom?: number; +} + +export interface PlaceListboxInput { + anchor: ListboxAnchor; + viewportHeight: number; + maxHeight?: number; + gap?: number; +} + +/** + * Under the input, unless the list does not fit below and does fit above: + * then above. With room on neither side, it stays below, its usual place. + */ +export function placeListbox({ + anchor, + viewportHeight, + maxHeight = LISTBOX_MAX_HEIGHT, + gap = LISTBOX_GAP, +}: PlaceListboxInput): ListboxPlacement { + const spaceBelow = viewportHeight - anchor.bottom - gap; + const spaceAbove = anchor.top - gap; + const base = { left: anchor.left, width: anchor.width }; + if (spaceBelow < maxHeight && spaceAbove >= maxHeight) { + return { ...base, openUpward: true, bottom: viewportHeight - anchor.top + gap }; + } + return { ...base, openUpward: false, top: anchor.bottom + gap }; +} + +/** Same position: a scroll that does not move the input renders nothing again. */ +export function samePlacement(a: ListboxPlacement | null, b: ListboxPlacement): boolean { + return ( + a !== null && + a.left === b.left && + a.width === b.width && + a.openUpward === b.openUpward && + a.top === b.top && + a.bottom === b.bottom + ); +} + +/** The part of `window` the listeners use, so a test can pass a fake. */ +export interface ListenerTarget { + addEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void; + removeEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void; +} + +/** + * Keeps the list on its input while it is open: `onMove` runs on every resize + * and on every scroll, of the page or of any scrolling container (a modal's + * body, the page's main area), hence the capture phase, since `scroll` does + * not bubble. A scroll of the list itself is skipped: its options moving do + * not move the input. Returns the cleanup, which removes exactly these + * listeners, capture flag included. + */ +export function followAnchor( + target: ListenerTarget, + onMove: () => void, + list: () => Pick | null, +): () => void { + const onScroll = (event: Event) => { + if (list()?.contains(event.target as Node | null)) return; + onMove(); + }; + const onResize = () => onMove(); + target.addEventListener("scroll", onScroll, true); + target.addEventListener("resize", onResize); + return () => { + target.removeEventListener("scroll", onScroll, true); + target.removeEventListener("resize", onResize); + }; +}