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}
isOptionEqualToValue={(o, v) => o.id === v.id}
noOptionsText="Žádné položky"
renderOption={(props, option) => {
const { key, ...rest } = props;
return (
<Box component="li" key={key} {...rest}>
<Box>
<Typography variant="body2">{option.name}</Typography>
{(option.item_number ||
option.available_quantity !== undefined) && (
<Typography variant="caption" color="text.secondary">
{option.item_number}
{option.item_number && option.available_quantity !== undefined
? " · "
: ""}
{option.available_quantity !== undefined
? `${option.available_quantity} ${option.unit ?? ""}`
: ""}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Hledat položku..." placeholder="Hledat položku..."
value={search} slotProps={{
role="searchbox" input: {
aria-autocomplete="list" ...params.InputProps,
aria-controls={listboxId} endAdornment: (
aria-activedescendant={activeId} <>
onChange={(e) => { {isFetching ? (
setSearch(e.target.value); <CircularProgress color="inherit" size={18} />
setOpen(true); ) : null}
{params.InputProps.endAdornment}
</>
),
},
}} }}
onFocus={() => {
setOpen(true);
updatePosition();
}}
onKeyDown={handleKeyDown}
/> />
{open &&
items.length > 0 &&
dropdownStyle &&
createPortal(
<ul
id={listboxId}
role="listbox"
className="admin-item-picker-list"
style={dropdownStyle}
>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={value === item.id}
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item.id, item.name);
}}
>
<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>
); );
} }