metis-desk/packages/rdx-ui/src/components/entity-list.tsx

220 lines
6.6 KiB
TypeScript
Raw Normal View History

import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
2026-10-07 15:27:23 +00:00
import type { ReactNode } from "react";
import { useId } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
import { entityListLabels } from "../lib/entity-list";
import { EntityListPagination } from "./entity-list-controls";
import { EntityTableConfiguration } from "./entity-table-configuration";
import { EntityTableContent } from "./entity-table-content";
export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-controls";
export function EntityListHeader({
title,
description,
icon,
2026-10-07 15:27:23 +00:00
actions,
locale = "es",
}: {
title: string;
description?: string;
icon?: ReactNode;
2026-10-07 15:27:23 +00:00
actions?: ReactNode;
locale?: string;
}) {
return (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-2xl font-semibold">
{icon && (
<span
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center [&>svg]:size-6"
>
{icon}
</span>
)}
{title}
</h2>
2026-10-07 15:27:23 +00:00
<p className="mt-1 text-base font-medium text-muted-foreground">
{description ??
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
</p>
</div>
{actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
</header>
);
}
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
export type EntityColumn<T> = {
id: string;
label: string;
render: (item: T) => ReactNode;
value?: (item: T) => string | number | boolean | null | undefined;
defaultSort?: "asc" | "desc";
2026-10-07 15:27:23 +00:00
hideable?: boolean;
};
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
export function EntityTable<T extends { id: string }>({
items,
columns,
title,
entityLabel = title,
locale = "es",
search,
onSearchChange,
searchPlaceholder,
filters,
filtersApplied = false,
onClearFilters,
filterKey = "",
loading = false,
unavailable = false,
emptyMessage,
rowClassName,
onRowClick,
layout = "content",
2026-10-07 15:27:23 +00:00
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
title: string;
entityLabel?: string;
locale?: string;
search?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filters?: ReactNode;
filtersApplied?: boolean;
onClearFilters?: () => void;
filterKey?: string;
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
rowClassName?: (item: T) => string;
onRowClick?: ((item: T) => void) | undefined;
layout?: "content" | "fill";
2026-10-07 15:27:23 +00:00
}) {
const id = useId();
const labels = entityListLabels(locale);
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
useEntityTable({ items, columns, search, onSearchChange, filterKey });
const info = entityTableInfo(
loading,
unavailable,
labels.loading,
labels.range(range.from, range.to, total, entityLabel.toLocaleLowerCase(locale))
);
const message = entityTableMessage(
loading,
unavailable,
Boolean(currentSearch || filtersApplied),
labels,
emptyMessage
);
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
const clear = () => {
(onSearchChange ?? setLocalSearch)("");
onClearFilters?.();
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
};
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
return (
<section
aria-busy={loading}
aria-label={title}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "flex min-h-96 flex-1 flex-col overflow-hidden sm:min-h-80 [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
2026-10-07 15:27:23 +00:00
>
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b p-4">
2026-10-07 15:27:23 +00:00
<div className="w-full sm:max-w-sm">
<label className="sr-only" htmlFor={`${id}-search`}>
{searchPlaceholder ?? labels.search}
</label>
<Input
data-quick-search=""
2026-10-07 15:27:23 +00:00
id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder}
value={currentSearch}
/>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 [&>fieldset]:min-w-40">
{filters}
{(currentSearch || filtersApplied) && (
<Button onClick={clear} size="sm" variant="outline">
{labels.clear}
</Button>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
2026-10-07 15:27:23 +00:00
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
<EntityTableConfiguration
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
table={table}
2026-10-10 10:01:58 +00:00
/>
2026-10-07 15:27:23 +00:00
</div>
2026-10-07 15:27:23 +00:00
<EntityTableContent
columns={columns}
loading={loading}
message={message}
onRowClick={onRowClick}
2026-10-07 15:27:23 +00:00
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
rowClassName={rowClassName}
table={table}
title={title}
unavailable={unavailable}
2026-10-10 10:01:58 +00:00
/>
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
2026-10-07 15:27:23 +00:00
<p className="text-sm text-muted-foreground">{info}</p>
<EntityListPagination
id={id}
locale={locale}
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
page={range.page}
pageSize={pagination.pageSize}
pages={range.pages}
/>
</footer>
</section>
);
}
function entityTableInfo(
loading: boolean,
unavailable: boolean,
loadingLabel: string,
info: string
) {
if (loading) {
return loadingLabel;
}
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
return unavailable ? "—" : info;
}
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
function entityTableMessage(
loading: boolean,
unavailable: boolean,
filtered: boolean,
labels: ReturnType<typeof entityListLabels>,
emptyMessage: string | undefined
) {
if (loading) {
return labels.loading;
}
if (unavailable) {
return labels.unavailable;
}
2026-10-10 10:01:58 +00:00
2026-10-07 15:27:23 +00:00
return filtered ? labels.noResults : (emptyMessage ?? labels.empty);
}