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:
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user