Mejorada la búsqueda rápida
This commit is contained in:
parent
e76bdd9970
commit
ee97a5800b
@ -20,6 +20,8 @@ type SimpleSearchInputProps = {
|
||||
|
||||
const SEARCH_HISTORY_KEY = "search_history";
|
||||
|
||||
const normalizeSearchValue = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||
|
||||
export const SimpleSearchInput = ({
|
||||
value,
|
||||
onSearchChange,
|
||||
@ -33,84 +35,102 @@ export const SimpleSearchInput = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Evita que el siguiente debounce pise una búsqueda inmediata
|
||||
const skipNextDebouncedEmitRef = useRef(false);
|
||||
|
||||
const debouncedValue = useDebounce(searchValue, 300);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
setSearchValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
if (stored) setHistory(JSON.parse(stored));
|
||||
if (!stored) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as string[];
|
||||
setHistory(Array.isArray(parsed) ? parsed : []);
|
||||
} catch {
|
||||
setHistory([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Emit changes after debounce
|
||||
useEffect(() => {
|
||||
onSearchChange(debouncedValue);
|
||||
if (skipNextDebouncedEmitRef.current) {
|
||||
skipNextDebouncedEmitRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onSearchChange(normalizeSearchValue(debouncedValue));
|
||||
}, [debouncedValue, onSearchChange]);
|
||||
|
||||
// Save history to localStorage
|
||||
const saveHistory = (term: string) => {
|
||||
if (!term.trim()) return;
|
||||
const cleaned = term.trim();
|
||||
const newHistory = [cleaned, ...history.filter((h) => h !== cleaned)].slice(0, maxHistory);
|
||||
setHistory(newHistory);
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
||||
const cleaned = normalizeSearchValue(term);
|
||||
if (!cleaned) return;
|
||||
|
||||
setHistory((prev) => {
|
||||
const nextHistory = [cleaned, ...prev.filter((item) => item !== cleaned)].slice(
|
||||
0,
|
||||
maxHistory
|
||||
);
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(nextHistory));
|
||||
return nextHistory;
|
||||
});
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
setHistory([]);
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
};
|
||||
|
||||
// Input handlers
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const cleaned = e.target.value.trimStart().replace(/\s+/g, " ");
|
||||
setSearchValue(cleaned);
|
||||
setSearchValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleImmediateSearch = (rawValue: string) => {
|
||||
const nextValue = normalizeSearchValue(rawValue);
|
||||
|
||||
skipNextDebouncedEmitRef.current = true;
|
||||
setSearchValue(nextValue);
|
||||
setLastSearch(nextValue);
|
||||
onSearchChange(nextValue);
|
||||
saveHistory(nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchValue("");
|
||||
onSearchChange("");
|
||||
handleImmediateSearch("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Enter → búsqueda inmediata
|
||||
const currentValue = e.currentTarget.value;
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onSearchChange(searchValue);
|
||||
setLastSearch(searchValue);
|
||||
saveHistory(searchValue);
|
||||
setOpen(false);
|
||||
handleImmediateSearch(currentValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Enter → repetir última búsqueda
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (lastSearch) {
|
||||
onSearchChange(lastSearch);
|
||||
setSearchValue(lastSearch);
|
||||
}
|
||||
if (!lastSearch) return;
|
||||
|
||||
handleImmediateSearch(lastSearch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter → limpiar
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleClear();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setSearchValue(term);
|
||||
onSearchChange(term);
|
||||
setLastSearch(term);
|
||||
setOpen(false);
|
||||
handleImmediateSearch(term);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 items-center gap-4">
|
||||
<InputGroup className="bg-background " data-disabled={loading}>
|
||||
<InputGroup className="bg-background">
|
||||
<InputGroupInput
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
inputMode="search"
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => history.length > 0 && setOpen(true)}
|
||||
@ -120,26 +140,33 @@ export const SimpleSearchInput = ({
|
||||
spellCheck={false}
|
||||
value={searchValue}
|
||||
/>
|
||||
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden />
|
||||
<SearchIcon aria-hidden className="text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
{loading && (
|
||||
<Spinner aria-label={t("components.simple_search_input.loading", "Loading")} />
|
||||
<Spinner
|
||||
aria-label={t("components.simple_search_input.loading", "Loading")}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{!(searchValue || loading) && (
|
||||
<Button variant="outline">
|
||||
<Button onClick={() => handleImmediateSearch(searchValue)} type="button" variant="outline">
|
||||
{t("components.simple_search_input.search_button", "Search")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{searchValue && !loading && (
|
||||
<Button
|
||||
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
|
||||
className="cursor-pointer"
|
||||
onClick={handleClear}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<XIcon aria-hidden className="size-4" />
|
||||
|
||||
@ -6,7 +6,7 @@ import { useListCustomersQuery } from "../../shared";
|
||||
|
||||
export const useListCustomersController = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const debouncedQ = useDebounce(search, 300);
|
||||
@ -24,7 +24,27 @@ export const useListCustomersController = () => {
|
||||
const query = useListCustomersQuery({ criteria });
|
||||
|
||||
const setSearchValue = (value: string) => {
|
||||
setSearch(value.trim().replace(/\s+/g, " "));
|
||||
const nextValue = value.trim().replace(/\s+/g, " ");
|
||||
|
||||
setSearch((prev) => {
|
||||
if (prev === nextValue) return prev;
|
||||
|
||||
// Sólo si la búsqueda realmente cambia,
|
||||
// reseteamos la página a 0 para evitar inconsistencias
|
||||
setPageIndex(0);
|
||||
return nextValue;
|
||||
});
|
||||
};
|
||||
|
||||
const setPageSizeValue = (value: number) => {
|
||||
setPageSize((prev) => {
|
||||
if (prev === value) return prev;
|
||||
|
||||
// Sólo si el tamaño de página realmente cambia,
|
||||
// reseteamos la página a 0 para evitar inconsistencias
|
||||
setPageIndex(0);
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
@ -40,7 +60,7 @@ export const useListCustomersController = () => {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
setPageIndex,
|
||||
setPageSize,
|
||||
setPageSize: setPageSizeValue,
|
||||
|
||||
search,
|
||||
setSearchValue,
|
||||
|
||||
@ -24,53 +24,36 @@ import type { DataTableMeta } from "./data-table.tsx";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
onPageChange?: (pageIndex: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
className,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
|
||||
const meta = table.options.meta as DataTableMeta<TData>;
|
||||
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
|
||||
|
||||
// Normalización segura
|
||||
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
||||
const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 10;
|
||||
|
||||
const pageCount = table.getPageCount() || Math.max(1, Math.ceil((totalRows || 0) / pageSize));
|
||||
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
const hasSelected = table.getFilteredSelectedRowModel().rows.length > 0;
|
||||
|
||||
// Rango visible (1-based en UI)
|
||||
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
|
||||
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
|
||||
|
||||
// Handlers de navegación controlada
|
||||
const notify = (next: Partial<{ pageIndex: number; pageSize: number }>) =>
|
||||
table.options.onPaginationChange?.({ pageIndex, pageSize, ...next });
|
||||
const gotoPage = (index: number) => {
|
||||
const nextIndex = Math.max(0, Math.min(index, pageCount - 1));
|
||||
table.setPageIndex(nextIndex);
|
||||
};
|
||||
|
||||
const gotoPage = (index: number) =>
|
||||
onPageChange ? onPageChange(index) : notify({ pageIndex: index });
|
||||
const handlePageSizeChange = (size: string) =>
|
||||
onPageSizeChange ? onPageSizeChange(Number(size)) : notify({ pageSize: Number(size) });
|
||||
|
||||
const gotoPreviousPage = () => gotoPage(pageIndex - 1);
|
||||
const gotoNextPage = () => gotoPage(pageIndex + 1);
|
||||
const gotoFirstPage = () => gotoPage(0);
|
||||
const gotoLastPage = () => gotoPage(pageCount - 1);
|
||||
const handlePageSizeChange = (size: string) => {
|
||||
table.setPageSize(Number(size));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between text-muted-foreground", className)}>
|
||||
{/* Información izquierda */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 ">
|
||||
<div className="flex flex-1 flex-col items-center gap-4 sm:flex-row">
|
||||
<span aria-live="polite">
|
||||
{t("components.datatable.pagination.showing_range", { start, end, total: totalRows })}
|
||||
</span>
|
||||
@ -85,12 +68,11 @@ export function DataTablePagination<TData>({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controles derecha */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("components.datatable.pagination.rows_per_page")}</span>
|
||||
<Select onValueChange={handlePageSizeChange} value={String(pageSize)}>
|
||||
<SelectTrigger className="w-20 h-8 bg-white border-gray-200">
|
||||
<SelectTrigger className="h-8 w-20 border-gray-200 bg-white">
|
||||
<SelectValue placeholder={String(pageSize)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -102,16 +84,15 @@ export function DataTablePagination<TData>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
aria-label={t("components.datatable.pagination.goto_first_page")}
|
||||
className="px-2.5 cursor-pointer"
|
||||
className="cursor-pointer px-2.5"
|
||||
isActive={pageIndex > 0}
|
||||
onClick={() => {
|
||||
if (pageIndex > 0) gotoFirstPage();
|
||||
}}
|
||||
onClick={() => pageIndex > 0 && gotoPage(0)}
|
||||
size="sm"
|
||||
>
|
||||
<ChevronsLeftIcon className="size-4" />
|
||||
@ -121,29 +102,28 @@ export function DataTablePagination<TData>({
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
aria-label={t("components.datatable.pagination.goto_previous_page")}
|
||||
className="px-2.5 cursor-pointer"
|
||||
className="cursor-pointer px-2.5"
|
||||
isActive={pageIndex > 0}
|
||||
onClick={() => {
|
||||
if (pageIndex > 0) gotoPreviousPage();
|
||||
}}
|
||||
onClick={() => pageIndex > 0 && gotoPage(pageIndex - 1)}
|
||||
size="sm"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
|
||||
<span aria-live="polite" className="text-sm text-muted-foreground px-2">
|
||||
{t("components.datatable.pagination.page_of", { page: pageIndex + 1, of: pageCount })}
|
||||
<span aria-live="polite" className="px-2 text-sm text-muted-foreground">
|
||||
{t("components.datatable.pagination.page_of", {
|
||||
page: pageIndex + 1,
|
||||
of: pageCount,
|
||||
})}
|
||||
</span>
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
aria-label={t("components.datatable.pagination.goto_next_page")}
|
||||
className="px-2.5 cursor-pointer"
|
||||
className="cursor-pointer px-2.5"
|
||||
isActive={pageIndex < pageCount - 1}
|
||||
onClick={() => {
|
||||
if (pageIndex < pageCount - 1) gotoNextPage();
|
||||
}}
|
||||
onClick={() => pageIndex < pageCount - 1 && gotoPage(pageIndex + 1)}
|
||||
size="sm"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
@ -153,11 +133,9 @@ export function DataTablePagination<TData>({
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
aria-label={t("components.datatable.pagination.goto_last_page")}
|
||||
className="px-2.5 cursor-pointer"
|
||||
className="cursor-pointer px-2.5"
|
||||
isActive={pageIndex < pageCount - 1}
|
||||
onClick={() => {
|
||||
if (pageIndex < pageCount - 1) gotoLastPage();
|
||||
}}
|
||||
onClick={() => pageIndex < pageCount - 1 && gotoPage(pageCount - 1)}
|
||||
size="sm"
|
||||
>
|
||||
<ChevronsRightIcon className="size-4" />
|
||||
|
||||
@ -137,10 +137,10 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
getRowId:
|
||||
getRowId ??
|
||||
((originalRow: TData, i: number) =>
|
||||
typeof (originalRow as any).id !== "undefined"
|
||||
? String((originalRow as any).id)
|
||||
: String(i)),
|
||||
((originalRow: TData, i: number) => {
|
||||
const row = originalRow as { id?: string | number };
|
||||
return row.id !== undefined ? String(row.id) : String(i);
|
||||
}),
|
||||
|
||||
state: {
|
||||
columnSizing: colSizes,
|
||||
@ -152,16 +152,21 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
|
||||
manualPagination,
|
||||
pageCount: manualPagination
|
||||
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
|
||||
: undefined,
|
||||
autoResetPageIndex: false,
|
||||
|
||||
pageCount: manualPagination ? Math.max(1, Math.ceil((totalItems ?? 0) / pageSize)) : undefined,
|
||||
|
||||
// Propagar cambios al padre
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater({ pageIndex, pageSize }) : updater;
|
||||
|
||||
if (typeof next.pageIndex === "number") onPageChange?.(next.pageIndex);
|
||||
if (typeof next.pageSize === "number") onPageSizeChange?.(next.pageSize);
|
||||
if (typeof next.pageIndex === "number" && next.pageIndex !== pageIndex) {
|
||||
onPageChange?.(next.pageIndex);
|
||||
}
|
||||
|
||||
if (typeof next.pageSize === "number" && next.pageSize !== pageSize) {
|
||||
onPageSizeChange?.(next.pageSize);
|
||||
}
|
||||
},
|
||||
|
||||
enableRowSelection,
|
||||
@ -272,11 +277,7 @@ export function DataTable<TData, TValue>({
|
||||
<TableFooter className="bg-background">
|
||||
<TableRow>
|
||||
<TableCell colSpan={100}>
|
||||
<DataTablePagination
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
table={table}
|
||||
/>
|
||||
<DataTablePagination table={table} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user