feat(mui): rewrite warehouse ItemPicker on MUI Autocomplete (same props)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 00:21:17 +02:00
parent 5d919c3c90
commit f51d5fcba4

View File

@@ -1,7 +1,14 @@
import { useState, useRef, useEffect, useCallback, useId } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse"; import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
warehouseItemListOptions,
type WarehouseItem,
} from "../../lib/queries/warehouse";
interface ItemPickerProps { interface ItemPickerProps {
value: number | null; value: number | null;
@@ -9,220 +16,88 @@ interface ItemPickerProps {
itemName?: string; itemName?: string;
} }
/** Minimal option shape used to display a selected value that isn't in the
* current search results (edit/prefill via `itemName`). */
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
export default function ItemPicker({ export default function ItemPicker({
value, value,
onChange, onChange,
itemName, itemName,
}: ItemPickerProps) { }: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? ""); const [inputValue, setInputValue] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const containerRef = useRef<HTMLDivElement>(null); const { data, isFetching } = useQuery(
const activeIndexRef = useRef<number>(-1); warehouseItemListOptions({ search: inputValue, perPage: 20 }),
const listboxId = useId(); );
const [activeIndex, setActiveIndex] = useState<number>(-1); const options: ItemOption[] = data?.data ?? [];
const [dropdownStyle, setDropdownStyle] = useState<{ // Show the selected value even when it isn't in the current search results
position: "fixed"; // (e.g. prefilled via `itemName`) by synthesizing a fallback option.
top: number; const selectedOption: ItemOption | null =
left: number; value == null
width: number; ? null
zIndex: number; : (options.find((o) => o.id === value) ?? {
} | null>(null); id: value,
name: itemName ?? "",
const updatePosition = useCallback(() => { });
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 2,
left: rect.left,
width: rect.width,
zIndex: 100,
});
}, []);
// Reset active index when items change
useEffect(() => {
if (activeIndex >= items.length) {
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [items, activeIndex]);
useEffect(() => {
if (open) {
updatePosition();
const onScroll = () => updatePosition();
const onClose = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
} else {
setDropdownStyle(null);
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [open, updatePosition]);
// Close on click-outside via mousedown on document
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target;
if (containerRef.current && target instanceof Node) {
if (!containerRef.current.contains(target)) {
// Don't close if click landed on an option in the portal
const listEl = document.getElementById(listboxId);
if (listEl && listEl.contains(target)) return;
setOpen(false);
}
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open, listboxId]);
const handleSelect = (itemId: number, name: string) => {
onChange(itemId);
setOpen(false);
setSearch(name);
};
const setActive = (index: number) => {
activeIndexRef.current = index;
setActiveIndex(index);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(0);
return;
}
const next =
activeIndexRef.current < items.length - 1
? activeIndexRef.current + 1
: 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(items.length - 1);
return;
}
const prev =
activeIndexRef.current > 0
? activeIndexRef.current - 1
: items.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
if (
open &&
activeIndexRef.current >= 0 &&
activeIndexRef.current < items.length
) {
e.preventDefault();
const item = items[activeIndexRef.current];
handleSelect(item.id, item.name);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
} else if (e.key === "Home") {
if (open && items.length > 0) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && items.length > 0) {
e.preventDefault();
setActive(items.length - 1);
}
} else if (e.key === "Tab") {
setOpen(false);
}
};
const activeId =
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
return ( return (
<div <Autocomplete<ItemOption>
ref={containerRef} value={selectedOption}
style={{ position: "relative" }} inputValue={inputValue}
role="combobox" onInputChange={(_, newInput) => setInputValue(newInput)}
aria-haspopup="listbox" onChange={(_, opt) => {
aria-expanded={open} if (opt) onChange(opt.id);
aria-owns={listboxId} }}
> options={options}
<input loading={isFetching}
type="text" filterOptions={(x) => x}
className="admin-form-input" getOptionLabel={(o) => o.name}
placeholder="Hledat položku..." isOptionEqualToValue={(o, v) => o.id === v.id}
value={search} noOptionsText="Žádné položky"
role="searchbox" renderOption={(props, option) => {
aria-autocomplete="list" const { key, ...rest } = props;
aria-controls={listboxId} return (
aria-activedescendant={activeId} <Box component="li" key={key} {...rest}>
onChange={(e) => { <Box>
setSearch(e.target.value); <Typography variant="body2">{option.name}</Typography>
setOpen(true); {(option.item_number ||
}} option.available_quantity !== undefined) && (
onFocus={() => { <Typography variant="caption" color="text.secondary">
setOpen(true); {option.item_number}
updatePosition(); {option.item_number && option.available_quantity !== undefined
}} ? " · "
onKeyDown={handleKeyDown} : ""}
/> {option.available_quantity !== undefined
{open && ? `${option.available_quantity} ${option.unit ?? ""}`
items.length > 0 && : ""}
dropdownStyle && </Typography>
createPortal( )}
<ul </Box>
id={listboxId} </Box>
role="listbox" );
className="admin-item-picker-list" }}
style={dropdownStyle} renderInput={(params) => (
> <TextField
{items.map((item, index) => ( {...params}
<li size="small"
key={item.id} placeholder="Hledat položku..."
id={`${listboxId}-option-${index}`} slotProps={{
role="option" input: {
aria-selected={value === item.id} ...params.InputProps,
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`} endAdornment: (
onMouseDown={(e) => { <>
e.preventDefault(); {isFetching ? (
handleSelect(item.id, item.name); <CircularProgress color="inherit" size={18} />
}} ) : null}
> {params.InputProps.endAdornment}
<span className="admin-item-picker-name">{item.name}</span> </>
{item.item_number && ( ),
<span className="admin-item-picker-number"> },
{item.item_number} }}
</span> />
)} )}
{item.available_quantity !== undefined && ( />
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>,
document.body,
)}
</div>
); );
} }