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 { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LISTBOX_MAX_HEIGHT,
|
||||||
|
followAnchor,
|
||||||
|
placeListbox,
|
||||||
|
samePlacement,
|
||||||
|
type ListboxPlacement,
|
||||||
|
} from "./placeListbox";
|
||||||
/**
|
/**
|
||||||
* Minimal shape the combobox needs. `Category` (transactions, reports) and
|
* Minimal shape the combobox needs. `Category` (transactions, reports) and
|
||||||
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
|
* `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 [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -187,10 +196,36 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
}
|
}
|
||||||
}, [highlightIndex, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
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);
|
setOpen(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
}
|
}
|
||||||
|
|
@ -277,12 +312,20 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
onKeyDown={handleKeyDown}
|
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)]`}
|
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
|
<ul
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
role="listbox"
|
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) => (
|
{filteredExtras.map((opt, i) => (
|
||||||
<li
|
<li
|
||||||
|
|
@ -327,7 +370,8 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</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