fix(ui): portal assign item dropdown

This commit is contained in:
Nico 2026-03-28 22:51:02 -07:00
parent 104519668a
commit 084ffe7099
3 changed files with 343 additions and 38 deletions

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import "../../styles/components/AssignItemForModal.css"; import "../../styles/components/AssignItemForModal.css";
function getMemberLabel(member) { function getMemberLabel(member) {
@ -19,7 +20,9 @@ export default function AssignItemForModal({
}) { }) {
const [selectedUserId, setSelectedUserId] = useState(""); const [selectedUserId, setSelectedUserId] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState(null);
const triggerRef = useRef(null);
const menuRef = useRef(null);
const hasMembers = members.length > 0; const hasMembers = members.length > 0;
const selectedMember = useMemo( const selectedMember = useMemo(
@ -27,10 +30,39 @@ export default function AssignItemForModal({
[members, selectedUserId] [members, selectedUserId]
); );
const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const viewportPadding = 16;
const menuGap = 6;
const width = Math.min(rect.width, window.innerWidth - (2 * viewportPadding));
const left = Math.min(
Math.max(viewportPadding, rect.left),
window.innerWidth - width - viewportPadding
);
const availableBelow = window.innerHeight - rect.bottom - menuGap - viewportPadding;
const availableAbove = rect.top - menuGap - viewportPadding;
const shouldOpenAbove = availableBelow < 140 && availableAbove > availableBelow;
const maxHeight = Math.max(
120,
Math.min(240, Math.floor(shouldOpenAbove ? availableAbove : availableBelow))
);
setDropdownStyle({
left: `${Math.round(left)}px`,
width: `${Math.round(width)}px`,
maxHeight: `${maxHeight}px`,
top: shouldOpenAbove ? "auto" : `${Math.round(rect.bottom + menuGap)}px`,
bottom: shouldOpenAbove ? `${Math.round(window.innerHeight - rect.top + menuGap)}px` : "auto",
});
}, []);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
setSelectedUserId(members[0] ? String(members[0].id) : ""); setSelectedUserId(members[0] ? String(members[0].id) : "");
setIsDropdownOpen(false); setIsDropdownOpen(false);
setDropdownStyle(null);
}, [isOpen, members]); }, [isOpen, members]);
useEffect(() => { useEffect(() => {
@ -54,8 +86,10 @@ export default function AssignItemForModal({
if (!isOpen || !isDropdownOpen) return undefined; if (!isOpen || !isDropdownOpen) return undefined;
const handlePointerDown = (event) => { const handlePointerDown = (event) => {
if (!dropdownRef.current) return; const clickedTrigger = triggerRef.current?.contains(event.target);
if (!dropdownRef.current.contains(event.target)) { const clickedMenu = menuRef.current?.contains(event.target);
if (!clickedTrigger && !clickedMenu) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
}; };
@ -64,6 +98,24 @@ export default function AssignItemForModal({
return () => window.removeEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [isDropdownOpen, isOpen]); }, [isDropdownOpen, isOpen]);
useEffect(() => {
if (!isOpen || !isDropdownOpen) return undefined;
updateDropdownPosition();
const handleViewportChange = () => {
updateDropdownPosition();
};
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isDropdownOpen, isOpen, updateDropdownPosition]);
if (!isOpen) return null; if (!isOpen) return null;
const handleConfirm = () => { const handleConfirm = () => {
@ -71,34 +123,26 @@ export default function AssignItemForModal({
onConfirm(selectedMember.id); onConfirm(selectedMember.id);
}; };
return ( const handleToggleDropdown = () => {
<div className="modal-overlay" onClick={onCancel}> if (isDropdownOpen) {
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}> setIsDropdownOpen(false);
<h2 className="modal-title">Add Item For Someone Else</h2> return;
}
{hasMembers ? ( updateDropdownPosition();
<div className="assign-item-for-modal-field"> setIsDropdownOpen(true);
<label className="form-label"> };
Household member
</label> const dropdownMenu = isDropdownOpen && dropdownStyle
<div className="assign-item-for-dropdown" ref={dropdownRef}> ? createPortal(
<button <div
type="button" ref={menuRef}
className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`} className="assign-item-for-dropdown-menu"
aria-haspopup="listbox" role="listbox"
aria-expanded={isDropdownOpen} aria-label="Household member"
onClick={() => setIsDropdownOpen((prev) => !prev)} style={dropdownStyle}
onClick={(event) => event.stopPropagation()}
> >
<span className="assign-item-for-dropdown-label">
{selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"}
</span>
<span className="assign-item-for-dropdown-caret" aria-hidden="true">
{isDropdownOpen ? "▲" : "▼"}
</span>
</button>
{isDropdownOpen ? (
<div className="assign-item-for-dropdown-menu" role="listbox" aria-label="Household member">
{members.map((member) => { {members.map((member) => {
const memberId = String(member.id); const memberId = String(member.id);
const isSelected = memberId === String(selectedUserId); const isSelected = memberId === String(selectedUserId);
@ -120,8 +164,37 @@ export default function AssignItemForModal({
</button> </button>
); );
})} })}
</div> </div>,
) : null} document.body
)
: null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="modal-title">Add Item For Someone Else</h2>
{hasMembers ? (
<div className="assign-item-for-modal-field">
<label className="form-label">
Household member
</label>
<div className="assign-item-for-dropdown">
<button
ref={triggerRef}
type="button"
className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`}
aria-haspopup="listbox"
aria-expanded={isDropdownOpen}
onClick={handleToggleDropdown}
>
<span className="assign-item-for-dropdown-label">
{selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"}
</span>
<span className="assign-item-for-dropdown-caret" aria-hidden="true">
{isDropdownOpen ? "▲" : "▼"}
</span>
</button>
</div> </div>
</div> </div>
) : ( ) : (
@ -144,6 +217,7 @@ export default function AssignItemForModal({
</button> </button>
</div> </div>
</div> </div>
{dropdownMenu}
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@
width: min(420px, calc(100vw - (2 * var(--spacing-md)))); width: min(420px, calc(100vw - (2 * var(--spacing-md))));
max-width: 420px; max-width: 420px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible;
} }
.assign-item-for-modal-field { .assign-item-for-modal-field {
@ -54,13 +55,10 @@
} }
.assign-item-for-dropdown-menu { .assign-item-for-dropdown-menu {
position: absolute; position: fixed;
top: calc(100% + 6px); z-index: var(--z-tooltip);
left: 0;
right: 0;
z-index: 3;
max-height: 180px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);

View File

@ -0,0 +1,233 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "assignment-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const members = [
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
{ id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" },
{ id: 3, username: "jordan", name: "Jordan Client", display_name: "Jordan Client", role: "member" },
{ id: 4, username: "alex", name: "Alex Member", display_name: "Alex Member", role: "member" },
{ id: 5, username: "morgan", name: "Morgan Member", display_name: "Morgan Member", role: "member" },
{ id: 6, username: "sam", name: "Sam Member", display_name: "Sam Member", role: "member" },
{ id: 7, username: "jamie", name: "Jamie Member", display_name: "Jamie Member", role: "member" },
{ id: 8, username: "pat", name: "Pat Member", display_name: "Pat Member", role: "member" },
{ id: 9, username: "drew", name: "Drew Member", display_name: "Drew Member", role: "member" },
{ id: 10, username: "kai", name: "Kai Member", display_name: "Kai Member", role: "member" },
{ id: 11, username: "blair", name: "Blair Member", display_name: "Blair Member", role: "member" },
{ id: 12, username: "quinn", name: "Quinn Member", display_name: "Quinn Member", role: "member" },
{ id: 13, username: "rowan", name: "Rowan Member", display_name: "Rowan Member", role: "member" },
{ id: 14, username: "sage", name: "Sage Member", display_name: "Sage Member", role: "member" },
{ id: 15, username: "taylor", name: "Taylor Member", display_name: "Taylor Member", role: "member" },
{ id: 16, username: "river", name: "River Member", display_name: "River Member", role: "member" },
];
let listItems: Array<{
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
}> = [];
let addCallCount = 0;
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Assignment House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
const url = new URL(route.request().url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const item = listItems.find((candidate) => candidate.item_name === itemName);
await route.fulfill({
status: item ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(item || { message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list/add", async (route) => {
addCallCount += 1;
if (addCallCount === 1) {
listItems = [
{
id: 201,
item_id: 501,
item_name: "bananas",
quantity: 1,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Casey Client"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: null,
item_group: null,
zone: null,
},
];
} else {
listItems = [
{
id: 201,
item_id: 501,
item_name: "bananas",
quantity: 2,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Casey Client", "Jordan Client"],
last_added_on: "2026-03-28T12:05:00.000Z",
item_type: null,
item_group: null,
zone: null,
},
];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: addCallCount === 1 ? "Item added" : "Item updated",
item: {
id: 201,
item_name: "bananas",
quantity: addCallCount === 1 ? 1 : 2,
bought: false,
},
}),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: listItems }),
});
});
await page.goto("/");
await expect(page.getByRole("heading", { name: "Grocery List" })).toBeVisible();
await page.getByPlaceholder("Enter item name").fill("bananas");
await page.getByRole("button", { name: "Others" }).click();
const assignModal = page.locator(".assign-item-for-modal");
await expect(assignModal).toBeVisible();
await assignModal.getByRole("button", { name: "Select member" }).click();
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
await expect(portalMenu).toBeVisible();
await expect(page.locator(".assign-item-for-modal .assign-item-for-dropdown-menu")).toHaveCount(0);
const dropdownMetrics = await portalMenu.evaluate((element) => {
const menu = element as HTMLDivElement;
return {
position: window.getComputedStyle(menu).position,
scrollable: menu.scrollHeight > menu.clientHeight,
};
});
expect(dropdownMetrics.position).toBe("fixed");
expect(dropdownMetrics.scrollable).toBe(true);
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
await assignModal.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
await page.getByRole("button", { name: "Create + Add" }).click();
await page.getByRole("button", { name: "Skip All" }).click();
const bananasRow = page.locator(".glist-li").filter({ hasText: "bananas" });
await expect(bananasRow).toContainText("Casey Client");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
await page.getByPlaceholder("Enter item name").fill("bananas");
await page.getByRole("button", { name: "Others" }).click();
await assignModal.getByRole("button", { name: "Select member" }).click();
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
await assignModal.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
await page.getByRole("button", { name: "Create + Add" }).click();
await page.getByRole("button", { name: "Update Quantity" }).click();
await expect(bananasRow).toContainText("Casey Client");
await expect(bananasRow).toContainText("Jordan Client");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity");
});