|
- "use client";
-
- import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
- import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton";
- import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary";
- import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
- import Box from "@mui/material/Box";
- import List from "@mui/material/List";
- import ListItemButton from "@mui/material/ListItemButton";
- import Skeleton from "@mui/material/Skeleton";
- import Typography from "@mui/material/Typography";
- import { alpha } from "@mui/material/styles";
- import type { Theme } from "@mui/material/styles";
- import { useCallback, useRef } from "react";
- import { useTranslation } from "react-i18next";
-
- const LIST_CONTAINER_SX = {
- position: "absolute",
- inset: 0,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- bgcolor: "background.paper",
- } as const;
-
- const LIST_HEADER_SX = {
- flexShrink: 0,
- px: 2,
- pt: 0.5,
- pb: 0.5,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: "background.paper",
- } as const;
-
- const EMPTY_STATE_SX = {
- flex: 1,
- minHeight: 0,
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- px: 3,
- py: 4,
- } as const;
-
- const RESULTS_BODY_SX = {
- flex: 1,
- minHeight: 0,
- display: "flex",
- flexDirection: "column",
- bgcolor: "background.paper",
- } as const;
-
- const SCROLL_NEAR_BOTTOM_PX = 120;
-
- /**
- * Scroll host: the list; load the next page when the user scrolls near the bottom.
- * Use `overflow-y: scroll` (not `auto`) so a scrollbar track stays visible when content is short;
- * the list still does not scroll until content overflows.
- */
- const SCROLL_HOST_SX = {
- flex: 1,
- minHeight: 0,
- height: "100%",
- overflowY: "scroll",
- overflowX: "hidden",
- scrollbarGutter: "stable",
- py: 0,
- } as const;
-
- interface ResultListItemProps {
- row: PoWorkbenchListRow;
- selected: boolean;
- onSelect: (id: string) => void;
- theme: Theme;
- }
-
- function ResultListItem({
- row,
- selected,
- onSelect,
- theme,
- }: ResultListItemProps) {
- const rowSx = {
- m: 0,
- mx: 0,
- borderRadius: 0,
- pt: 1.25,
- pb: 1,
- px: 2,
- borderBottom: 1,
- borderColor: "divider",
- borderLeftStyle: "solid",
- borderLeftWidth: 10,
- borderLeftColor: selected ? "primary.main" : "transparent",
- borderTopLeftRadius: 0,
- borderBottomLeftRadius: 0,
- borderTopRightRadius: 0,
- borderBottomRightRadius: 0,
- bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent",
- "&:hover": {
- borderTopRightRadius: 10,
- borderBottomRightRadius: 10,
- bgcolor: selected
- ? alpha(theme.palette.primary.main, 0.12)
- : "action.hover",
- },
- "&.Mui-selected": {
- borderTopRightRadius: 10,
- borderBottomRightRadius: 10,
- bgcolor: alpha(theme.palette.primary.main, 0.08),
- "&:hover": {
- borderTopRightRadius: 10,
- borderBottomRightRadius: 10,
- bgcolor: alpha(theme.palette.primary.main, 0.12),
- },
- },
- } as const;
-
- return (
- <ListItemButton
- selected={selected}
- onClick={() => onSelect(row.id)}
- alignItems="flex-start"
- sx={rowSx}
- >
- <WorkbenchResultSummary row={row} />
- </ListItemButton>
- );
- }
-
- export interface PoWorkbenchSearchResultsListProps {
- results: readonly PoWorkbenchListRow[];
- totalMatches: number;
- isLoading: boolean;
- isLoadingMore: boolean;
- loadError: string | null;
- hasMore: boolean;
- onLoadMore: () => void;
- selectedId: string | null;
- onSelect: (id: string) => void;
- theme: Theme;
- }
-
- export default function PoWorkbenchSearchResultsList({
- results,
- totalMatches,
- isLoading,
- isLoadingMore,
- loadError,
- hasMore,
- onLoadMore,
- selectedId,
- onSelect,
- theme,
- }: PoWorkbenchSearchResultsListProps) {
- const { t } = useTranslation("poWorkbench");
- const scrollRootRef = useRef<HTMLDivElement | null>(null);
-
- /**
- * Load the next page when the user scrolls near the bottom.
- * No auto-chaining on first paint (avoids firing many /po/list calls before any scroll).
- */
- const handleScroll = useCallback(
- (e: React.UIEvent<HTMLDivElement>) => {
- if (!hasMore || isLoading || isLoadingMore) {
- return;
- }
- const el = e.currentTarget;
- if (el.scrollTop <= 0) {
- return;
- }
- if (
- el.scrollTop + el.clientHeight >=
- el.scrollHeight - SCROLL_NEAR_BOTTOM_PX
- ) {
- onLoadMore();
- }
- },
- [hasMore, isLoading, isLoadingMore, onLoadMore],
- );
-
- const showLoadingHeaderOnly = isLoading && results.length === 0;
-
- return (
- <Box sx={LIST_CONTAINER_SX}>
- <Box sx={LIST_HEADER_SX}>
- {showLoadingHeaderOnly ? (
- <Skeleton
- variant="text"
- width={200}
- height={20}
- sx={{ my: 0.25 }}
- aria-label={t("results.loading")}
- />
- ) : (
- <>
- <Typography
- variant="caption"
- color="text.secondary"
- component="p"
- sx={{ m: 0 }}
- >
- {t("results.totalMatches", { total: totalMatches })}
- </Typography>
- </>
- )}
- </Box>
-
- {loadError && results.length > 0 ? (
- <Box sx={{ flexShrink: 0, px: 2, py: 0.5, bgcolor: "error.light" }}>
- <Typography variant="caption" color="error.contrastText">
- {loadError}
- </Typography>
- </Box>
- ) : null}
-
- {isLoading && results.length === 0 ? (
- <Box sx={RESULTS_BODY_SX}>
- <Box
- ref={scrollRootRef}
- onScroll={handleScroll}
- sx={SCROLL_HOST_SX}
- aria-busy="true"
- aria-label={t("results.loading")}
- >
- <PoWorkbenchSearchResultsListSkeleton />
- </Box>
- </Box>
- ) : null}
-
- {!isLoading && results.length === 0 ? (
- <Box sx={EMPTY_STATE_SX}>
- <SearchOffOutlinedIcon
- sx={{
- fontSize: 88,
- color: (p) => p.palette.grey[400],
- }}
- aria-hidden
- />
- <Typography
- variant="body2"
- component="p"
- sx={{
- mt: 2,
- maxWidth: 320,
- textAlign: "center",
- color: "text.secondary",
- fontWeight: 700,
- lineHeight: 1.6,
- }}
- >
- {loadError ?? t("results.emptyState")}
- </Typography>
- </Box>
- ) : null}
-
- {results.length > 0 ? (
- <Box sx={RESULTS_BODY_SX}>
- <Box ref={scrollRootRef} onScroll={handleScroll} sx={SCROLL_HOST_SX}>
- <List disablePadding>
- {results.map((row) => (
- <ResultListItem
- key={row.id}
- row={row}
- selected={row.id === selectedId}
- onSelect={onSelect}
- theme={theme}
- />
- ))}
- </List>
- {isLoadingMore ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 1.5 }}>
- <Typography
- variant="caption"
- color="text.secondary"
- component="p"
- sx={{ m: 0 }}
- >
- {t("results.loading")}
- </Typography>
- </Box>
- ) : null}
- </Box>
- </Box>
- ) : null}
- </Box>
- );
- }
|