feat(base): floating selection action bar with bulk delete

This commit is contained in:
Philipinho
2026-04-18 16:44:07 +01:00
parent 4c4bbe9b15
commit 05406640f0
4 changed files with 117 additions and 0 deletions
@@ -24,6 +24,7 @@ import { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { SelectionActionBar } from "./selection-action-bar";
import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36;
@@ -251,6 +252,7 @@ export function GridContainer({
)}
<AddRowButton onClick={handleAddRow} />
{baseId && <SelectionActionBar baseId={baseId} />}
</div>
</div>
</DndContext>
@@ -0,0 +1,53 @@
import { memo } from "react";
import { ActionIcon, Button, Transition } from "@mantine/core";
import { IconTrash, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import classes from "@/features/base/styles/grid.module.css";
type SelectionActionBarProps = {
baseId: string;
};
export const SelectionActionBar = memo(function SelectionActionBar({
baseId,
}: SelectionActionBarProps) {
const { t } = useTranslation();
const { selectionCount, clear } = useRowSelection();
const { deleteSelected, isPending } = useDeleteSelectedRows(baseId);
const isOpen = selectionCount > 0;
return (
<Transition mounted={isOpen} transition="slide-up" duration={150}>
{(styles) => (
<div className={classes.selectionActionBarWrapper} style={styles}>
<div className={classes.selectionActionBar}>
<span className={classes.selectionActionBarCount}>
{t("{{count}} selected", { count: selectionCount })}
</span>
<Button
size="xs"
color="red"
variant="light"
leftSection={<IconTrash size={14} />}
loading={isPending}
onClick={() => void deleteSelected()}
>
{t("Delete")}
</Button>
<ActionIcon
size="sm"
variant="subtle"
onClick={clear}
aria-label={t("Clear selection")}
>
<IconX size={14} />
</ActionIcon>
</div>
</div>
)}
</Transition>
);
});
@@ -0,0 +1,35 @@
import { useCallback } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteRowsMutation } from "@/features/base/queries/base-row-query";
const BATCH_SIZE = 500;
export function useDeleteSelectedRows(baseId: string) {
const { t } = useTranslation();
const { selectedIds, clear } = useRowSelection();
const mutation = useDeleteRowsMutation();
const deleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
chunks.push(ids.slice(i, i + BATCH_SIZE));
}
try {
for (const chunk of chunks) {
await mutation.mutateAsync({ baseId, rowIds: chunk });
}
notifications.show({
message: t("{{count}} rows deleted", { count: ids.length }),
});
clear();
} catch {
// mutation onError already shows notification
}
}, [baseId, selectedIds, mutation, clear, t]);
return { deleteSelected, isPending: mutation.isPending };
}
@@ -374,3 +374,30 @@
.hasSelection .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper {
position: sticky;
bottom: 16px;
display: flex;
justify-content: center;
pointer-events: none;
z-index: 5;
grid-column: 1 / -1;
}
.selectionActionBar {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-lg);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.selectionActionBarCount {
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}