2026-10-07 15:27:23 +00:00
|
|
|
|
import { Menu } from "@base-ui/react/menu";
|
2026-10-07 16:29:44 +00:00
|
|
|
|
import { EllipsisIcon } from "lucide-react";
|
2026-10-07 15:27:23 +00:00
|
|
|
|
import type { ReactNode } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
import { entityListLabels, entityListPages } from "../lib/entity-list";
|
|
|
|
|
|
|
|
|
|
|
|
import { Button } from "./button";
|
|
|
|
|
|
import { SelectWithAction } from "./select-with-action";
|
|
|
|
|
|
|
|
|
|
|
|
const ENTITY_PAGE_SIZES = [10, 25, 50, 100];
|
|
|
|
|
|
|
|
|
|
|
|
export function EntityRowActions({ children, label }: { children: ReactNode; label: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Menu.Root>
|
2026-10-07 16:29:44 +00:00
|
|
|
|
<Menu.Trigger
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
render={<Button size="icon-sm" variant="ghost" />}
|
|
|
|
|
|
title={label}
|
|
|
|
|
|
>
|
|
|
|
|
|
<EllipsisIcon aria-hidden="true" />
|
2026-10-07 15:27:23 +00:00
|
|
|
|
</Menu.Trigger>
|
|
|
|
|
|
<Menu.Portal>
|
|
|
|
|
|
<Menu.Positioner align="end" className="z-50" sideOffset={4}>
|
|
|
|
|
|
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</Menu.Popup>
|
|
|
|
|
|
</Menu.Positioner>
|
|
|
|
|
|
</Menu.Portal>
|
|
|
|
|
|
</Menu.Root>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
export function EntityAction({
|
|
|
|
|
|
children,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
disabled = false,
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Menu.Item
|
|
|
|
|
|
className={`cursor-pointer rounded-sm px-3 py-2 text-sm outline-none data-highlighted:bg-accent data-disabled:pointer-events-none data-disabled:opacity-50 ${className ?? ""}`}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</Menu.Item>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
export function EntityStatus({
|
|
|
|
|
|
children,
|
|
|
|
|
|
active = true,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
active?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="inline-flex items-center gap-2 text-sm">
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={
|
|
|
|
|
|
active
|
|
|
|
|
|
? "size-2 shrink-0 rounded-full bg-primary"
|
|
|
|
|
|
: "size-2 shrink-0 rounded-full bg-muted-foreground"
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
export function EntityListPagination({
|
|
|
|
|
|
locale,
|
|
|
|
|
|
page,
|
|
|
|
|
|
pages,
|
|
|
|
|
|
pageSize,
|
|
|
|
|
|
onPage,
|
|
|
|
|
|
onPageSize,
|
|
|
|
|
|
id,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
pages: number;
|
|
|
|
|
|
pageSize: number;
|
|
|
|
|
|
onPage: (page: number) => void;
|
|
|
|
|
|
onPageSize: (size: number) => void;
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const labels = entityListLabels(locale);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<nav aria-label={labels.page} className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground" htmlFor={`${id}-size`}>
|
|
|
|
|
|
{labels.pageSize}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<SelectWithAction
|
|
|
|
|
|
id={`${id}-size`}
|
|
|
|
|
|
label={labels.pageSize}
|
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
|
const size = Number(value);
|
|
|
|
|
|
if (ENTITY_PAGE_SIZES.includes(size)) {
|
|
|
|
|
|
onPageSize(size);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
options={ENTITY_PAGE_SIZES.map((size) => ({ value: String(size), label: String(size) }))}
|
|
|
|
|
|
placeholder={labels.pageSize}
|
|
|
|
|
|
value={String(pageSize)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
aria-label={labels.previous}
|
|
|
|
|
|
disabled={page === 0}
|
|
|
|
|
|
onClick={() => onPage(page - 1)}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
‹
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{entityListPages(page, pages).map((index) => (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
aria-current={index === page ? "page" : undefined}
|
|
|
|
|
|
aria-label={`${labels.page} ${index + 1}`}
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
onClick={() => onPage(index)}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant={index === page ? "secondary" : "ghost"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{index + 1}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
aria-label={labels.next}
|
|
|
|
|
|
disabled={page + 1 >= pages}
|
|
|
|
|
|
onClick={() => onPage(page + 1)}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
›
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|