diff --git a/src/i18n/en.json b/src/i18n/en.json
index 35546d87d..2a7b60c8a 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -3744,6 +3744,9 @@
"inventory_item_list": {
"inventory_items": "Inventory",
"alert_info": "You can create or archive items from the list. To edit an item, click pencil icon.",
+ "image_preview_alt": "Inventory item preview",
+ "image_preview_action_alt": "Open image preview for {itemName} in a new tab",
+ "image_preview_unavailable": "Image preview unavailable",
"code_column_label": "Code",
"name_column_label": "Name",
"archive_button": "Archive",
diff --git a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js
new file mode 100644
index 000000000..ad788a1c6
--- /dev/null
+++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js
@@ -0,0 +1,106 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import T from "i18n-react/dist/i18n-react";
+import { InventoryImagePreviewCell } from "../inventory-list-page";
+
+describe("InventoryListPage", () => {
+ describe("InventoryImagePreviewCell", () => {
+ const imageUrl = "https://example.com/image.png";
+ const previewAlt = T.translate("inventory_item_list.image_preview_alt");
+ const previewActionAlt = T.translate(
+ "inventory_item_list.image_preview_action_alt"
+ );
+ const previewUnavailable = T.translate(
+ "inventory_item_list.image_preview_unavailable"
+ );
+
+ beforeEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test("opens preview on hover/focus and closes on leave/blur", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const button = screen.getByRole("button");
+
+ await user.hover(button);
+ await waitFor(() => {
+ expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
+ });
+
+ await user.unhover(button);
+ await waitFor(() => {
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ });
+
+ button.focus();
+ await waitFor(() => {
+ expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
+ });
+
+ button.blur();
+ await waitFor(() => {
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ });
+ });
+
+ test("does not render when imageUrl is null or empty", () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+
+ rerender();
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ test("sets explicit aria-label for accessibility", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: previewActionAlt })
+ ).toBeInTheDocument();
+ });
+
+ test("shows fallback text if image fails to load", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const button = screen.getByRole("button");
+ await user.hover(button);
+
+ const image = screen.getByAltText(previewAlt);
+ fireEvent.error(image);
+
+ expect(screen.getByText(previewUnavailable)).toBeInTheDocument();
+ });
+
+ test("keeps click action while preview is visible", async () => {
+ const openSpy = jest.spyOn(window, "open").mockImplementation(() => null);
+ const user = userEvent.setup();
+
+ render();
+
+ const button = screen.getByRole("button");
+ await user.hover(button);
+
+ await waitFor(() => {
+ expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
+ });
+
+ await user.click(button);
+
+ expect(openSpy).toHaveBeenCalledWith(
+ imageUrl,
+ "_blank",
+ "noopener,noreferrer"
+ );
+ });
+ });
+});
diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js
index 4d351229e..d212570bf 100644
--- a/src/pages/sponsors-global/inventory/inventory-list-page.js
+++ b/src/pages/sponsors-global/inventory/inventory-list-page.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2024 OpenStack Foundation
+ * Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -19,7 +19,9 @@ import {
FormControlLabel,
FormGroup,
Grid2,
- TextField
+ Popover,
+ TextField,
+ Typography
} from "@mui/material";
import Box from "@mui/material/Box";
import AddIcon from "@mui/icons-material/Add";
@@ -44,6 +46,116 @@ import MuiTable from "../../../components/mui/table/mui-table";
import SponsorInventoryDialog from "../form-templates/sponsor-inventory-popup";
import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants";
+const PREVIEW_BOX_SIZE = 220;
+const PREVIEW_MARGIN_BOTTOM = 1;
+const PREVIEW_TRANSITION_DURATION = { enter: 120, exit: 90 };
+const PREVIEW_POINTER_EVENTS = "none";
+
+export const InventoryImagePreviewCell = React.memo(
+ ({ imageUrl, itemName }) => {
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [imageLoadError, setImageLoadError] = useState(false);
+
+ const previewActionLabel = itemName
+ ? T.translate("inventory_item_list.image_preview_action_alt", {
+ itemName
+ })
+ : T.translate("inventory_item_list.image_preview_action_alt");
+
+ const previewImgAlt = itemName
+ ? T.translate("inventory_item_list.image_preview_alt", { itemName })
+ : T.translate("inventory_item_list.image_preview_alt");
+
+ if (!imageUrl) return null;
+
+ const isOpen = Boolean(anchorEl);
+
+ const openPreview = (target) => {
+ setAnchorEl(target);
+ setImageLoadError(false);
+ };
+
+ const closePreview = () => setAnchorEl(null);
+
+ return (
+ <>
+ openPreview(event.currentTarget)}
+ onMouseLeave={closePreview}
+ onFocus={(event) => openPreview(event.currentTarget)}
+ onBlur={closePreview}
+ onClick={() => window.open(imageUrl, "_blank", "noopener,noreferrer")}
+ >
+
+
+
+
+
+ {!imageLoadError ? (
+ setImageLoadError(true)}
+ sx={{
+ width: "100%",
+ height: "100%",
+ display: "block",
+ objectFit: "contain"
+ }}
+ />
+ ) : (
+
+ {T.translate("inventory_item_list.image_preview_unavailable")}
+
+ )}
+
+
+ >
+ );
+ }
+);
+
+InventoryImagePreviewCell.displayName = "InventoryImagePreviewCell";
+
const InventoryListPage = ({
inventoryItems,
currentInventoryItem,
@@ -74,8 +186,15 @@ const InventoryListPage = ({
};
useEffect(() => {
- getInventoryItems(term, 1, perPage, order, orderDir, hideArchived);
- }, []);
+ getInventoryItems(
+ term,
+ DEFAULT_CURRENT_PAGE,
+ perPage,
+ order,
+ orderDir,
+ hideArchived
+ );
+ }, [getInventoryItems]);
const handlePageChange = (page) => {
getInventoryItems(term, page, perPage, order, orderDir, hideArchived);
@@ -167,21 +286,17 @@ const InventoryListPage = ({
header: "",
width: 40,
align: "center",
- render: (row) =>
- row.images.length > 0 ? (
-
-
- window.open(
- row.images[0].file_url,
- "_blank",
- "noopener,noreferrer"
- )
- }
- />
-
- ) : null
+ render: (row) => {
+ const hasImages = Array.isArray(row.images) && row.images.length > 0;
+ const imageUrl = row.images?.[0]?.file_url;
+ const itemName = row.name;
+
+ if (!hasImages || !imageUrl) return null;
+
+ return (
+
+ );
+ }
}
];