Mejorada la búsqueda rápida

This commit is contained in:
David Arranz 2026-04-01 20:04:03 +02:00
parent e76bdd9970
commit ee97a5800b
4 changed files with 131 additions and 105 deletions

View File

@ -20,6 +20,8 @@ type SimpleSearchInputProps = {
const SEARCH_HISTORY_KEY = "search_history"; const SEARCH_HISTORY_KEY = "search_history";
const normalizeSearchValue = (value: string) => value.trim().replace(/\s+/g, " ");
export const SimpleSearchInput = ({ export const SimpleSearchInput = ({
value, value,
onSearchChange, onSearchChange,
@ -33,84 +35,102 @@ export const SimpleSearchInput = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Evita que el siguiente debounce pise una búsqueda inmediata
const skipNextDebouncedEmitRef = useRef(false);
const debouncedValue = useDebounce(searchValue, 300); const debouncedValue = useDebounce(searchValue, 300);
// Load from localStorage on mount useEffect(() => {
setSearchValue(value);
}, [value]);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(SEARCH_HISTORY_KEY); 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(() => { useEffect(() => {
onSearchChange(debouncedValue); if (skipNextDebouncedEmitRef.current) {
skipNextDebouncedEmitRef.current = false;
return;
}
onSearchChange(normalizeSearchValue(debouncedValue));
}, [debouncedValue, onSearchChange]); }, [debouncedValue, onSearchChange]);
// Save history to localStorage
const saveHistory = (term: string) => { const saveHistory = (term: string) => {
if (!term.trim()) return; const cleaned = normalizeSearchValue(term);
const cleaned = term.trim(); if (!cleaned) return;
const newHistory = [cleaned, ...history.filter((h) => h !== cleaned)].slice(0, maxHistory);
setHistory(newHistory); setHistory((prev) => {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); 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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const cleaned = e.target.value.trimStart().replace(/\s+/g, " "); setSearchValue(e.target.value);
setSearchValue(cleaned); };
const handleImmediateSearch = (rawValue: string) => {
const nextValue = normalizeSearchValue(rawValue);
skipNextDebouncedEmitRef.current = true;
setSearchValue(nextValue);
setLastSearch(nextValue);
onSearchChange(nextValue);
saveHistory(nextValue);
setOpen(false);
}; };
const handleClear = () => { const handleClear = () => {
setSearchValue(""); handleImmediateSearch("");
onSearchChange(""); inputRef.current?.focus();
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 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) { if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault(); e.preventDefault();
onSearchChange(searchValue); handleImmediateSearch(currentValue);
setLastSearch(searchValue); return;
saveHistory(searchValue);
setOpen(false);
} }
// Shift+Enter → repetir última búsqueda
if (e.key === "Enter" && e.shiftKey) { if (e.key === "Enter" && e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (lastSearch) { if (!lastSearch) return;
onSearchChange(lastSearch);
setSearchValue(lastSearch); handleImmediateSearch(lastSearch);
} return;
} }
// Ctrl/Cmd+Enter → limpiar
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
handleClear(); handleClear();
inputRef.current?.focus();
} }
}; };
const handleSelectHistory = (term: string) => { const handleSelectHistory = (term: string) => {
setSearchValue(term); handleImmediateSearch(term);
onSearchChange(term);
setLastSearch(term);
setOpen(false);
}; };
return ( return (
<div className="relative flex flex-1 items-center gap-4"> <div className="relative flex flex-1 items-center gap-4">
<InputGroup className="bg-background " data-disabled={loading}> <InputGroup className="bg-background">
<InputGroupInput <InputGroupInput
autoComplete="off" autoComplete="off"
disabled={loading}
inputMode="search" inputMode="search"
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => history.length > 0 && setOpen(true)} onFocus={() => history.length > 0 && setOpen(true)}
@ -120,26 +140,33 @@ export const SimpleSearchInput = ({
spellCheck={false} spellCheck={false}
value={searchValue} value={searchValue}
/> />
<InputGroupAddon> <InputGroupAddon>
<SearchIcon aria-hidden /> <SearchIcon aria-hidden className="text-muted-foreground" />
</InputGroupAddon> </InputGroupAddon>
<InputGroupAddon align="inline-end"> <InputGroupAddon align="inline-end">
{loading && ( {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> </InputGroupAddon>
</InputGroup> </InputGroup>
{!(searchValue || loading) && ( {!(searchValue || loading) && (
<Button variant="outline"> <Button onClick={() => handleImmediateSearch(searchValue)} type="button" variant="outline">
{t("components.simple_search_input.search_button", "Search")} {t("components.simple_search_input.search_button", "Search")}
</Button> </Button>
)} )}
{searchValue && !loading && ( {searchValue && !loading && (
<Button <Button
aria-label={t("components.simple_search_input.clear_search", "Clear search")} aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer" className="cursor-pointer"
onClick={handleClear} onClick={handleClear}
type="button"
variant="outline" variant="outline"
> >
<XIcon aria-hidden className="size-4" /> <XIcon aria-hidden className="size-4" />

View File

@ -6,7 +6,7 @@ import { useListCustomersQuery } from "../../shared";
export const useListCustomersController = () => { export const useListCustomersController = () => {
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300); const debouncedQ = useDebounce(search, 300);
@ -24,7 +24,27 @@ export const useListCustomersController = () => {
const query = useListCustomersQuery({ criteria }); const query = useListCustomersQuery({ criteria });
const setSearchValue = (value: string) => { 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 { return {
@ -40,7 +60,7 @@ export const useListCustomersController = () => {
pageIndex, pageIndex,
pageSize, pageSize,
setPageIndex, setPageIndex,
setPageSize, setPageSize: setPageSizeValue,
search, search,
setSearchValue, setSearchValue,

View File

@ -24,53 +24,36 @@ import type { DataTableMeta } from "./data-table.tsx";
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
onPageChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
className?: string; className?: string;
} }
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
table,
onPageChange,
onPageSizeChange,
className,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination; const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
const meta = table.options.meta as DataTableMeta<TData>; const meta = table.options.meta as DataTableMeta<TData>;
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length; const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
// Normalización segura
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0; const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 10; const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 10;
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const pageCount = table.getPageCount() || Math.max(1, Math.ceil((totalRows || 0) / pageSize));
const hasSelected = table.getFilteredSelectedRowModel().rows.length > 0; const hasSelected = table.getFilteredSelectedRowModel().rows.length > 0;
// Rango visible (1-based en UI)
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0; const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0; const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
// Handlers de navegación controlada const gotoPage = (index: number) => {
const notify = (next: Partial<{ pageIndex: number; pageSize: number }>) => const nextIndex = Math.max(0, Math.min(index, pageCount - 1));
table.options.onPaginationChange?.({ pageIndex, pageSize, ...next }); table.setPageIndex(nextIndex);
};
const gotoPage = (index: number) => const handlePageSizeChange = (size: string) => {
onPageChange ? onPageChange(index) : notify({ pageIndex: index }); table.setPageSize(Number(size));
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);
return ( return (
<div className={cn("flex items-center justify-between text-muted-foreground", className)}> <div className={cn("flex items-center justify-between text-muted-foreground", className)}>
{/* Información izquierda */} <div className="flex flex-1 flex-col items-center gap-4 sm:flex-row">
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 ">
<span aria-live="polite"> <span aria-live="polite">
{t("components.datatable.pagination.showing_range", { start, end, total: totalRows })} {t("components.datatable.pagination.showing_range", { start, end, total: totalRows })}
</span> </span>
@ -85,12 +68,11 @@ export function DataTablePagination<TData>({
)} )}
</div> </div>
{/* Controles derecha */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{t("components.datatable.pagination.rows_per_page")}</span> <span>{t("components.datatable.pagination.rows_per_page")}</span>
<Select onValueChange={handlePageSizeChange} value={String(pageSize)}> <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)} /> <SelectValue placeholder={String(pageSize)} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -102,16 +84,15 @@ export function DataTablePagination<TData>({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Pagination> <Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_first_page")} aria-label={t("components.datatable.pagination.goto_first_page")}
className="px-2.5 cursor-pointer" className="cursor-pointer px-2.5"
isActive={pageIndex > 0} isActive={pageIndex > 0}
onClick={() => { onClick={() => pageIndex > 0 && gotoPage(0)}
if (pageIndex > 0) gotoFirstPage();
}}
size="sm" size="sm"
> >
<ChevronsLeftIcon className="size-4" /> <ChevronsLeftIcon className="size-4" />
@ -121,29 +102,28 @@ export function DataTablePagination<TData>({
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_previous_page")} aria-label={t("components.datatable.pagination.goto_previous_page")}
className="px-2.5 cursor-pointer" className="cursor-pointer px-2.5"
isActive={pageIndex > 0} isActive={pageIndex > 0}
onClick={() => { onClick={() => pageIndex > 0 && gotoPage(pageIndex - 1)}
if (pageIndex > 0) gotoPreviousPage();
}}
size="sm" size="sm"
> >
<ChevronLeftIcon className="size-4" /> <ChevronLeftIcon className="size-4" />
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
<span aria-live="polite" className="text-sm text-muted-foreground px-2"> <span aria-live="polite" className="px-2 text-sm text-muted-foreground">
{t("components.datatable.pagination.page_of", { page: pageIndex + 1, of: pageCount })} {t("components.datatable.pagination.page_of", {
page: pageIndex + 1,
of: pageCount,
})}
</span> </span>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_next_page")} 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} isActive={pageIndex < pageCount - 1}
onClick={() => { onClick={() => pageIndex < pageCount - 1 && gotoPage(pageIndex + 1)}
if (pageIndex < pageCount - 1) gotoNextPage();
}}
size="sm" size="sm"
> >
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
@ -153,11 +133,9 @@ export function DataTablePagination<TData>({
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_last_page")} 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} isActive={pageIndex < pageCount - 1}
onClick={() => { onClick={() => pageIndex < pageCount - 1 && gotoPage(pageCount - 1)}
if (pageIndex < pageCount - 1) gotoLastPage();
}}
size="sm" size="sm"
> >
<ChevronsRightIcon className="size-4" /> <ChevronsRightIcon className="size-4" />

View File

@ -137,10 +137,10 @@ export function DataTable<TData, TValue>({
getRowId: getRowId:
getRowId ?? getRowId ??
((originalRow: TData, i: number) => ((originalRow: TData, i: number) => {
typeof (originalRow as any).id !== "undefined" const row = originalRow as { id?: string | number };
? String((originalRow as any).id) return row.id !== undefined ? String(row.id) : String(i);
: String(i)), }),
state: { state: {
columnSizing: colSizes, columnSizing: colSizes,
@ -152,16 +152,21 @@ export function DataTable<TData, TValue>({
}, },
manualPagination, manualPagination,
pageCount: manualPagination autoResetPageIndex: false,
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
: undefined, pageCount: manualPagination ? Math.max(1, Math.ceil((totalItems ?? 0) / pageSize)) : undefined,
// Propagar cambios al padre // Propagar cambios al padre
onPaginationChange: (updater) => { onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater({ pageIndex, pageSize }) : updater; const next = typeof updater === "function" ? updater({ pageIndex, pageSize }) : updater;
if (typeof next.pageIndex === "number") onPageChange?.(next.pageIndex); if (typeof next.pageIndex === "number" && next.pageIndex !== pageIndex) {
if (typeof next.pageSize === "number") onPageSizeChange?.(next.pageSize); onPageChange?.(next.pageIndex);
}
if (typeof next.pageSize === "number" && next.pageSize !== pageSize) {
onPageSizeChange?.(next.pageSize);
}
}, },
enableRowSelection, enableRowSelection,
@ -272,11 +277,7 @@ export function DataTable<TData, TValue>({
<TableFooter className="bg-background"> <TableFooter className="bg-background">
<TableRow> <TableRow>
<TableCell colSpan={100}> <TableCell colSpan={100}>
<DataTablePagination <DataTablePagination table={table} />
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
table={table}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>