FPSMS-frontend
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 

291 lignes
7.4 KiB

  1. "use client";
  2. import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
  3. import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton";
  4. import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary";
  5. import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
  6. import Box from "@mui/material/Box";
  7. import List from "@mui/material/List";
  8. import ListItemButton from "@mui/material/ListItemButton";
  9. import Skeleton from "@mui/material/Skeleton";
  10. import Typography from "@mui/material/Typography";
  11. import { alpha } from "@mui/material/styles";
  12. import type { Theme } from "@mui/material/styles";
  13. import { useCallback, useRef } from "react";
  14. import { useTranslation } from "react-i18next";
  15. const LIST_CONTAINER_SX = {
  16. position: "absolute",
  17. inset: 0,
  18. display: "flex",
  19. flexDirection: "column",
  20. overflow: "hidden",
  21. bgcolor: "background.paper",
  22. } as const;
  23. const LIST_HEADER_SX = {
  24. flexShrink: 0,
  25. px: 2,
  26. pt: 0.5,
  27. pb: 0.5,
  28. borderBottom: 1,
  29. borderColor: "divider",
  30. bgcolor: "background.paper",
  31. } as const;
  32. const EMPTY_STATE_SX = {
  33. flex: 1,
  34. minHeight: 0,
  35. display: "flex",
  36. flexDirection: "column",
  37. alignItems: "center",
  38. justifyContent: "center",
  39. px: 3,
  40. py: 4,
  41. } as const;
  42. const RESULTS_BODY_SX = {
  43. flex: 1,
  44. minHeight: 0,
  45. display: "flex",
  46. flexDirection: "column",
  47. bgcolor: "background.paper",
  48. } as const;
  49. const SCROLL_NEAR_BOTTOM_PX = 120;
  50. /**
  51. * Scroll host: the list; load the next page when the user scrolls near the bottom.
  52. * Use `overflow-y: scroll` (not `auto`) so a scrollbar track stays visible when content is short;
  53. * the list still does not scroll until content overflows.
  54. */
  55. const SCROLL_HOST_SX = {
  56. flex: 1,
  57. minHeight: 0,
  58. height: "100%",
  59. overflowY: "scroll",
  60. overflowX: "hidden",
  61. scrollbarGutter: "stable",
  62. py: 0,
  63. } as const;
  64. interface ResultListItemProps {
  65. row: PoWorkbenchListRow;
  66. selected: boolean;
  67. onSelect: (id: string) => void;
  68. theme: Theme;
  69. }
  70. function ResultListItem({
  71. row,
  72. selected,
  73. onSelect,
  74. theme,
  75. }: ResultListItemProps) {
  76. const rowSx = {
  77. m: 0,
  78. mx: 0,
  79. borderRadius: 0,
  80. pt: 1.25,
  81. pb: 1,
  82. px: 2,
  83. borderBottom: 1,
  84. borderColor: "divider",
  85. borderLeftStyle: "solid",
  86. borderLeftWidth: 10,
  87. borderLeftColor: selected ? "primary.main" : "transparent",
  88. borderTopLeftRadius: 0,
  89. borderBottomLeftRadius: 0,
  90. borderTopRightRadius: 0,
  91. borderBottomRightRadius: 0,
  92. bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent",
  93. "&:hover": {
  94. borderTopRightRadius: 10,
  95. borderBottomRightRadius: 10,
  96. bgcolor: selected
  97. ? alpha(theme.palette.primary.main, 0.12)
  98. : "action.hover",
  99. },
  100. "&.Mui-selected": {
  101. borderTopRightRadius: 10,
  102. borderBottomRightRadius: 10,
  103. bgcolor: alpha(theme.palette.primary.main, 0.08),
  104. "&:hover": {
  105. borderTopRightRadius: 10,
  106. borderBottomRightRadius: 10,
  107. bgcolor: alpha(theme.palette.primary.main, 0.12),
  108. },
  109. },
  110. } as const;
  111. return (
  112. <ListItemButton
  113. selected={selected}
  114. onClick={() => onSelect(row.id)}
  115. alignItems="flex-start"
  116. sx={rowSx}
  117. >
  118. <WorkbenchResultSummary row={row} />
  119. </ListItemButton>
  120. );
  121. }
  122. export interface PoWorkbenchSearchResultsListProps {
  123. results: readonly PoWorkbenchListRow[];
  124. totalMatches: number;
  125. isLoading: boolean;
  126. isLoadingMore: boolean;
  127. loadError: string | null;
  128. hasMore: boolean;
  129. onLoadMore: () => void;
  130. selectedId: string | null;
  131. onSelect: (id: string) => void;
  132. theme: Theme;
  133. }
  134. export default function PoWorkbenchSearchResultsList({
  135. results,
  136. totalMatches,
  137. isLoading,
  138. isLoadingMore,
  139. loadError,
  140. hasMore,
  141. onLoadMore,
  142. selectedId,
  143. onSelect,
  144. theme,
  145. }: PoWorkbenchSearchResultsListProps) {
  146. const { t } = useTranslation("poWorkbench");
  147. const scrollRootRef = useRef<HTMLDivElement | null>(null);
  148. /**
  149. * Load the next page when the user scrolls near the bottom.
  150. * No auto-chaining on first paint (avoids firing many /po/list calls before any scroll).
  151. */
  152. const handleScroll = useCallback(
  153. (e: React.UIEvent<HTMLDivElement>) => {
  154. if (!hasMore || isLoading || isLoadingMore) {
  155. return;
  156. }
  157. const el = e.currentTarget;
  158. if (el.scrollTop <= 0) {
  159. return;
  160. }
  161. if (
  162. el.scrollTop + el.clientHeight >=
  163. el.scrollHeight - SCROLL_NEAR_BOTTOM_PX
  164. ) {
  165. onLoadMore();
  166. }
  167. },
  168. [hasMore, isLoading, isLoadingMore, onLoadMore],
  169. );
  170. const showLoadingHeaderOnly = isLoading && results.length === 0;
  171. return (
  172. <Box sx={LIST_CONTAINER_SX}>
  173. <Box sx={LIST_HEADER_SX}>
  174. {showLoadingHeaderOnly ? (
  175. <Skeleton
  176. variant="text"
  177. width={200}
  178. height={20}
  179. sx={{ my: 0.25 }}
  180. aria-label={t("results.loading")}
  181. />
  182. ) : (
  183. <>
  184. <Typography
  185. variant="caption"
  186. color="text.secondary"
  187. component="p"
  188. sx={{ m: 0 }}
  189. >
  190. {t("results.totalMatches", { total: totalMatches })}
  191. </Typography>
  192. </>
  193. )}
  194. </Box>
  195. {loadError && results.length > 0 ? (
  196. <Box sx={{ flexShrink: 0, px: 2, py: 0.5, bgcolor: "error.light" }}>
  197. <Typography variant="caption" color="error.contrastText">
  198. {loadError}
  199. </Typography>
  200. </Box>
  201. ) : null}
  202. {isLoading && results.length === 0 ? (
  203. <Box sx={RESULTS_BODY_SX}>
  204. <Box
  205. ref={scrollRootRef}
  206. onScroll={handleScroll}
  207. sx={SCROLL_HOST_SX}
  208. aria-busy="true"
  209. aria-label={t("results.loading")}
  210. >
  211. <PoWorkbenchSearchResultsListSkeleton />
  212. </Box>
  213. </Box>
  214. ) : null}
  215. {!isLoading && results.length === 0 ? (
  216. <Box sx={EMPTY_STATE_SX}>
  217. <SearchOffOutlinedIcon
  218. sx={{
  219. fontSize: 88,
  220. color: (p) => p.palette.grey[400],
  221. }}
  222. aria-hidden
  223. />
  224. <Typography
  225. variant="body2"
  226. component="p"
  227. sx={{
  228. mt: 2,
  229. maxWidth: 320,
  230. textAlign: "center",
  231. color: "text.secondary",
  232. fontWeight: 700,
  233. lineHeight: 1.6,
  234. }}
  235. >
  236. {loadError ?? t("results.emptyState")}
  237. </Typography>
  238. </Box>
  239. ) : null}
  240. {results.length > 0 ? (
  241. <Box sx={RESULTS_BODY_SX}>
  242. <Box ref={scrollRootRef} onScroll={handleScroll} sx={SCROLL_HOST_SX}>
  243. <List disablePadding>
  244. {results.map((row) => (
  245. <ResultListItem
  246. key={row.id}
  247. row={row}
  248. selected={row.id === selectedId}
  249. onSelect={onSelect}
  250. theme={theme}
  251. />
  252. ))}
  253. </List>
  254. {isLoadingMore ? (
  255. <Box sx={{ display: "flex", justifyContent: "center", py: 1.5 }}>
  256. <Typography
  257. variant="caption"
  258. color="text.secondary"
  259. component="p"
  260. sx={{ m: 0 }}
  261. >
  262. {t("results.loading")}
  263. </Typography>
  264. </Box>
  265. ) : null}
  266. </Box>
  267. </Box>
  268. ) : null}
  269. </Box>
  270. );
  271. }