feat: render the category combobox's open list in a portal (#429)
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>
This commit is contained in:
parent
5decb985f1
commit
3631ef0b64
3 changed files with 329 additions and 5 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
175
src/components/shared/placeListbox.test.ts
Normal file
175
src/components/shared/placeListbox.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
105
src/components/shared/placeListbox.ts
Normal file
105
src/components/shared/placeListbox.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue