metis-desk/packages/rdx-ui/src/components/entity-list.tsx
2026-10-25 01:19:17 +02:00

316 lines
9.4 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import type { ReactNode } from "react";
import { useId, useRef } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import type { EntityListState } from "../lib/entity-list-state";
import { entityTableAvailability } from "../lib/entity-list-state";
import type { EntityTableNavigation } from "../lib/entity-table-navigation";
import { EntityListPagination } from "./entity-list-controls";
import { EntityListStatus } from "./entity-list-status";
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,
actions,
locale = "es",
}: {
title: string;
description?: string;
icon?: ReactNode;
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>
<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>
);
}
export type EntityColumn<T> = EntityColumnMetadata & {
id: string;
label: string;
render: (item: T) => ReactNode;
value?: (item: T) => string | number | boolean | null | undefined;
defaultSort?: "asc" | "desc";
hideable?: boolean;
defaultVisible?: boolean;
};
export function EntityTable<T extends { id: string }>({
items,
columns,
title,
entityLabel = title,
locale = "es",
search,
onSearchChange,
searchPlaceholder,
filters,
filtersApplied = false,
onClearFilters,
filterKey = "",
columnVisibilityStorageKey,
columnGroups,
state,
navigation,
onRetry,
columnsDisabled,
loading = false,
unavailable = false,
emptyMessage,
rowClassName,
onRowClick,
layout = "content",
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
title: string;
entityLabel?: string | ((count: number) => string);
locale?: string;
search?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filters?: ReactNode;
filtersApplied?: boolean;
onClearFilters?: () => void;
filterKey?: string;
columnVisibilityStorageKey?: string | undefined;
columnGroups?: readonly EntityColumnGroup[];
state?: EntityListState | undefined;
navigation?: EntityTableNavigation | undefined;
onRetry?: (() => Promise<unknown>) | undefined;
columnsDisabled?: boolean | undefined;
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
rowClassName?: (item: T) => string;
onRowClick?: ((item: T) => void) | undefined;
layout?: "content" | "fill";
}) {
const id = useId();
const searchRef = useRef<HTMLInputElement>(null);
const labels = entityListLabels(locale);
const availability = entityTableAvailability(state, loading, unavailable);
const pending = availability.loading;
const missing = availability.unavailable;
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
useEntityTable({
items,
columns,
locale,
search,
onSearchChange,
filterKey,
columnVisibilityStorageKey,
navigation,
});
const info = entityTableInfo(
pending,
missing,
labels.loading,
labels.range(
range.from,
range.to,
total,
entityTableLabel(entityLabel, total, locale),
layout === "fill" ? "compact" : "full"
)
);
const message = entityTableMessage(
pending,
missing,
Boolean(currentSearch.trim() || filtersApplied),
labels,
emptyMessage
);
const clear = () => {
if (navigation?.onClearFilters) {
navigation.onClearFilters();
} else {
(onSearchChange ?? setLocalSearch)("");
onClearFilters?.();
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}
searchRef.current?.focus({ preventScroll: true });
};
function resetSortPage() {
if (!navigation) {
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}
}
const configuration = (
<EntityTableConfiguration
columnGroups={columnGroups}
columnMetadata={columns}
columnsDisabled={columnsDisabled}
locale={locale}
onResetColumns={() => table.setColumnVisibility({})}
onSort={resetSortPage}
presentation={layout === "fill" ? "picker" : "full"}
table={table}
/>
);
return (
<section
aria-busy={availability.sectionBusy}
aria-label={title}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "@container/entity-table flex min-h-72 flex-1 flex-col overflow-hidden [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:overscroll-contain [&>[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" : ""}`}
data-query-state={state?.status}
data-slot="entity-table"
>
<div
className={`flex shrink-0 flex-wrap justify-between border-b ${layout === "fill" ? "items-center gap-2 p-2" : "items-start gap-3 p-4"}`}
>
<div
className={
layout === "fill"
? "min-w-0 flex-1 @min-[32rem]/entity-table:max-w-sm"
: "w-full sm:max-w-sm"
}
>
<label className="sr-only" htmlFor={`${id}-search`}>
{searchPlaceholder ?? labels.search}
</label>
<Input
data-quick-search=""
id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder}
ref={searchRef}
value={currentSearch}
/>
</div>
{layout === "fill" && configuration}
<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>
{layout !== "fill" && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0 flex-1">
<EntityListStatus
onRetry={onRetry}
retryFocus={() => searchRef.current}
retryLabel={labels.retry}
state={availability.summaryState}
>
{info}
</EntityListStatus>
</div>
{configuration}
</div>
)}
<EntityTableContent
columns={columns}
loading={pending}
message={message}
onRowClick={onRowClick}
onSort={resetSortPage}
rowClassName={rowClassName}
state={state}
table={table}
title={title}
unavailable={missing}
/>
<footer
className={`flex shrink-0 flex-wrap items-center justify-between border-t ${layout === "fill" ? "gap-2 px-2 py-2" : "gap-3 px-4 py-3"}`}
>
<p
aria-live={layout === "fill" ? availability.legacyAnnouncement : undefined}
className="text-sm text-muted-foreground"
>
{info}
</p>
<EntityListPagination
disabled={availability.paginationDisabled}
id={id}
locale={locale}
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
page={range.page}
pageSize={pagination.pageSize}
pages={range.pages}
presentation={layout === "fill" ? "picker" : "full"}
/>
</footer>
</section>
);
}
function entityTableInfo(
loading: boolean,
unavailable: boolean,
loadingLabel: string,
info: string
) {
if (loading) {
return loadingLabel;
}
return unavailable ? "—" : info;
}
function entityTableLabel(
label: string | ((count: number) => string),
total: number,
locale: string
) {
return (typeof label === "function" ? label(total) : label).toLocaleLowerCase(locale);
}
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;
}
return filtered ? labels.noResults : (emptyMessage ?? labels.empty);
}