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 { createPortal } from "react-dom";
import { useState } from "react";
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 {
value: number | null;
@@ -9,220 +16,88 @@ interface ItemPickerProps {
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({
value,
onChange,
itemName,
}: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const [inputValue, setInputValue] = useState(itemName ?? "");
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef<number>(-1);
const listboxId = useId();
const [activeIndex, setActiveIndex] = useState<number>(-1);
const { data, isFetching } = useQuery(
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
);
const options: ItemOption[] = data?.data ?? [];
const [dropdownStyle, setDropdownStyle] = useState<{
position: "fixed";
top: number;
left: number;
width: number;
zIndex: number;
} | null>(null);
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;
// Show the selected value even when it isn't in the current search results
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
const selectedOption: ItemOption | null =
value == null
? null
: (options.find((o) => o.id === value) ?? {
id: value,
name: itemName ?? "",
});
return (
<div
ref={containerRef}
style={{ position: "relative" }}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-owns={listboxId}
>
<input
type="text"
className="admin-form-input"
placeholder="Hledat položku..."
value={search}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeId}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
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>
<Autocomplete<ItemOption>
value={selectedOption}
inputValue={inputValue}
onInputChange={(_, newInput) => setInputValue(newInput)}
onChange={(_, opt) => {
if (opt) onChange(opt.id);
}}
options={options}
loading={isFetching}
filterOptions={(x) => x}
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..."
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{isFetching ? (
<CircularProgress color="inherit" size={18} />
) : null}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
);
}