feat(base): header select-all with tri-state checkbox

This commit is contained in:
Philipinho
2026-04-18 16:42:06 +01:00
parent 3fca962c9f
commit 4c4bbe9b15
5 changed files with 87 additions and 3 deletions
@@ -210,6 +210,7 @@ export function GridContainer({
table={table}
baseId={baseId}
columnOrder={table.getState().columnOrder}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
@@ -23,6 +23,8 @@ import {
IconUserEdit,
} from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
const typeIcons: Record<string, typeof IconLetterT> = {
@@ -44,10 +46,12 @@ const typeIcons: Record<string, typeof IconLetterT> = {
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
loadedRowIds: string[];
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
loadedRowIds,
}: GridHeaderCellProps) {
const property = header.column.columnDef.meta?.property as
| IBaseProperty
@@ -55,6 +59,8 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection();
const hasSelection = selectionCount > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
@@ -118,7 +124,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
return (
<div
ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""}`}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
@@ -129,7 +135,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
{...(isSortableDisabled ? {} : listeners)}
>
{isRowNumber ? (
flexRender(header.column.columnDef.header, header.getContext())
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
@@ -11,6 +11,7 @@ type GridHeaderProps = {
// Passed explicitly to break memo when columns change
// (table ref is stable from useReactTable, so memo won't fire without this)
columnOrder: ColumnOrderState;
loadedRowIds: string[];
onPropertyCreated?: () => void;
};
@@ -19,6 +20,7 @@ export const GridHeader = memo(function GridHeader({
baseId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnOrder: _columnOrder,
loadedRowIds,
onPropertyCreated,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
@@ -26,7 +28,7 @@ export const GridHeader = memo(function GridHeader({
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell key={header.id} header={header} />
<GridHeaderCell key={header.id} header={header} loadedRowIds={loadedRowIds} />
))}
{baseId && (
<CreatePropertyPopover
@@ -0,0 +1,48 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
};
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
loadedRowIds,
}: RowNumberHeaderCellProps) {
const { selectedIds, toggleAll } = useRowSelection();
const { checked, indeterminate } = useMemo(() => {
if (loadedRowIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedInLoaded = loadedRowIds.reduce(
(acc, id) => (selectedIds.has(id) ? acc + 1 : acc),
0,
);
return {
checked: selectedInLoaded === loadedRowIds.length,
indeterminate:
selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length,
};
}, [loadedRowIds, selectedIds]);
if (loadedRowIds.length === 0) return null;
return (
<div className={classes.rowNumberHeaderInner}>
<span className={classes.rowNumberHeaderHash}>#</span>
<span className={classes.rowNumberHeaderCheckbox}>
<Tooltip label="Select all loaded rows" withinPortal>
<Checkbox
size="xs"
checked={checked}
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
/>
</Tooltip>
</span>
</div>
);
});
@@ -347,3 +347,30 @@
.rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.rowNumberHeaderInner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.rowNumberHeaderHash {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs);
}
.rowNumberHeaderCheckbox {
display: none;
}
.headerCell:hover .rowNumberHeaderHash,
.hasSelection .rowNumberHeaderHash {
display: none;
}
.headerCell:hover .rowNumberHeaderCheckbox,
.hasSelection .rowNumberHeaderCheckbox {
display: inline-flex;
}