diff --git a/frontend/src/components/modals/AssignItemForModal.jsx b/frontend/src/components/modals/AssignItemForModal.jsx
index 5b16973..da60f6d 100644
--- a/frontend/src/components/modals/AssignItemForModal.jsx
+++ b/frontend/src/components/modals/AssignItemForModal.jsx
@@ -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";
function getMemberLabel(member) {
@@ -19,7 +20,9 @@ export default function AssignItemForModal({
}) {
const [selectedUserId, setSelectedUserId] = useState("");
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 selectedMember = useMemo(
@@ -27,10 +30,39 @@ export default function AssignItemForModal({
[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(() => {
if (!isOpen) return;
setSelectedUserId(members[0] ? String(members[0].id) : "");
setIsDropdownOpen(false);
+ setDropdownStyle(null);
}, [isOpen, members]);
useEffect(() => {
@@ -54,8 +86,10 @@ export default function AssignItemForModal({
if (!isOpen || !isDropdownOpen) return undefined;
const handlePointerDown = (event) => {
- if (!dropdownRef.current) return;
- if (!dropdownRef.current.contains(event.target)) {
+ const clickedTrigger = triggerRef.current?.contains(event.target);
+ const clickedMenu = menuRef.current?.contains(event.target);
+
+ if (!clickedTrigger && !clickedMenu) {
setIsDropdownOpen(false);
}
};
@@ -64,6 +98,24 @@ export default function AssignItemForModal({
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [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;
const handleConfirm = () => {
@@ -71,6 +123,52 @@ export default function AssignItemForModal({
onConfirm(selectedMember.id);
};
+ const handleToggleDropdown = () => {
+ if (isDropdownOpen) {
+ setIsDropdownOpen(false);
+ return;
+ }
+
+ updateDropdownPosition();
+ setIsDropdownOpen(true);
+ };
+
+ const dropdownMenu = isDropdownOpen && dropdownStyle
+ ? createPortal(
+
event.stopPropagation()}
+ >
+ {members.map((member) => {
+ const memberId = String(member.id);
+ const isSelected = memberId === String(selectedUserId);
+
+ return (
+
+ );
+ })}
+
,
+ document.body
+ )
+ : null;
+
return (
event.stopPropagation()}>
@@ -81,13 +179,14 @@ export default function AssignItemForModal({
-
+
-
- {isDropdownOpen ? (
-
- {members.map((member) => {
- const memberId = String(member.id);
- const isSelected = memberId === String(selectedUserId);
-
- return (
-
- );
- })}
-
- ) : null}
) : (
@@ -144,6 +217,7 @@ export default function AssignItemForModal({
+ {dropdownMenu}
);
}
diff --git a/frontend/src/styles/components/AssignItemForModal.css b/frontend/src/styles/components/AssignItemForModal.css
index a3ae81b..6ad319a 100644
--- a/frontend/src/styles/components/AssignItemForModal.css
+++ b/frontend/src/styles/components/AssignItemForModal.css
@@ -2,6 +2,7 @@
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
max-width: 420px;
overflow-x: hidden;
+ overflow-y: visible;
}
.assign-item-for-modal-field {
@@ -54,13 +55,10 @@
}
.assign-item-for-dropdown-menu {
- position: absolute;
- top: calc(100% + 6px);
- left: 0;
- right: 0;
- z-index: 3;
- max-height: 180px;
+ position: fixed;
+ z-index: var(--z-tooltip);
overflow-y: auto;
+ overscroll-behavior: contain;
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--border-radius-md);
diff --git a/frontend/tests/grocery-list-assignment.spec.ts b/frontend/tests/grocery-list-assignment.spec.ts
new file mode 100644
index 0000000..51c7557
--- /dev/null
+++ b/frontend/tests/grocery-list-assignment.spec.ts
@@ -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");
+});