feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471

Open
maximus wants to merge 6 commits from issue-429-category-combobox-portal into issue-372-systems-journal-first
3 changed files with 329 additions and 5 deletions
Showing only changes of commit 3631ef0b64 - Show all commits

View file

@ -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<T extends CategoryLike = CategoryLike>(
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -187,10 +196,36 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
}
}, [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<T extends CategoryLike = CategoryLike>(
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(
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
// z-[300]: above every window of the app, the highest at z-[210].
className="fixed z-[300] overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
style={{
top: placement.top,
bottom: placement.bottom,
left: placement.left,
width: placement.width,
maxHeight: LISTBOX_MAX_HEIGHT,
}}
>
{filteredExtras.map((opt, i) => (
<li
@ -327,7 +370,8 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
</li>
);
})}
</ul>
</ul>,
document.body,
)}
</div>
);

View file

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

View file

@ -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<Node, "contains"> | 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);
};
}