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 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" />

View File

@ -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,

View File

@ -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" />

View File

@ -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>