feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471
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