@cerebruminc/cerebellum 20.5.0 → 20.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [20.6.0](https://github.com/cerebruminc/cerebellum/compare/v20.5.0...v20.6.0) (2026-09-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **data-table:** add warning if getRowId is missing when checkboxes are used ([60788a1](https://github.com/cerebruminc/cerebellum/commit/60788a15ef7b2e0fc868acc8d0744dc60234055e))
9
+ * **datatable:** add a checkbox and radio row-selection column ([e79c816](https://github.com/cerebruminc/cerebellum/commit/e79c8164f5e424ffbcf02b37756b2da884c450be))
10
+ * **datatable:** add row-selection stories for checkboxes and radios ([2d0d12d](https://github.com/cerebruminc/cerebellum/commit/2d0d12dd0a2865df06096fe6cf3b61527db2943a))
11
+ * **mantine-theme:** support Radio in the mantineTheme ([72e8573](https://github.com/cerebruminc/cerebellum/commit/72e857346fe2d6d86aebcacd3044dc3fea250497))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **tooltip:** update the Tooltip styles ([155f939](https://github.com/cerebruminc/cerebellum/commit/155f939a1e5bc22c9a270de98775a681297a5884))
17
+ * **userCard:** fix UserCard spacing ([0bcd605](https://github.com/cerebruminc/cerebellum/commit/0bcd605b0a6a94ee7e0994e20794e06328ef37b9))
18
+
3
19
  ## [20.5.0](https://github.com/cerebruminc/cerebellum/compare/v20.4.0...v20.5.0) (2026-09-09)
4
20
 
5
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "20.5.0",
3
+ "version": "20.6.0",
4
4
  "description": "Cerebrum's React Component Library",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -70,7 +70,7 @@
70
70
  /* Layout groups */
71
71
  .optionsBox {
72
72
  margin-top: 27px;
73
- padding-bottom: 10px;
73
+ padding-bottom: 27px;
74
74
  }
75
75
 
76
76
  .nonScrollingGroup {
@@ -314,7 +314,7 @@
314
314
  }
315
315
 
316
316
  .orgDivider.orgDivider {
317
- margin: 16px -7px 16px;
317
+ margin: 0 -7px 16px;
318
318
  }
319
319
 
320
320
  .signOutDivider.signOutDivider {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file src/helpers/deprecation.test.ts
3
- * @summary Pins the "once per session" contract of the styled-components deprecation notice.
3
+ * @summary Pins the "once" contract of both notices this package emits.
4
4
  * @remarks
5
5
  * The value of this notice depends entirely on it being quiet: one line, once. A regression
6
6
  * that made it fire per render would put it in front of developers hundreds of times a page
@@ -103,3 +103,76 @@ describe("warnStyledComponentsDeprecated", () => {
103
103
  expect(warn).toHaveBeenCalledTimes(1);
104
104
  });
105
105
  });
106
+
107
+ /*
108
+ * `warnOnceFor` counts per scope rather than per session, which is the whole point:
109
+ * one misconfigured table should not swallow the warning owed to a second one, while
110
+ * that table's own re-renders and StrictMode double-mount stay a single line.
111
+ */
112
+ describe("warnOnceFor", () => {
113
+ let warn: jest.SpyInstance;
114
+ const originalNodeEnv = process.env.NODE_ENV;
115
+
116
+ beforeEach(() => {
117
+ warn = jest.spyOn(console, "warn").mockImplementation(() => {});
118
+ });
119
+
120
+ afterEach(() => {
121
+ warn.mockRestore();
122
+ process.env.NODE_ENV = originalNodeEnv;
123
+ });
124
+
125
+ it("warns on the first call for a scope", () => {
126
+ freshModule().warnOnceFor({}, "first");
127
+
128
+ expect(warn).toHaveBeenCalledWith("first");
129
+ });
130
+
131
+ it("stays silent on every later call for the same scope", () => {
132
+ const { warnOnceFor } = freshModule();
133
+ const scope = {};
134
+
135
+ warnOnceFor(scope, "same");
136
+ warnOnceFor(scope, "same");
137
+ warnOnceFor(scope, "same");
138
+
139
+ expect(warn).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("warns once for each scope, so a second offender is still heard", () => {
143
+ const { warnOnceFor } = freshModule();
144
+
145
+ warnOnceFor({}, "same");
146
+ warnOnceFor({}, "same");
147
+
148
+ expect(warn).toHaveBeenCalledTimes(2);
149
+ });
150
+
151
+ it("counts each distinct message separately within one scope", () => {
152
+ const { warnOnceFor } = freshModule();
153
+ const scope = {};
154
+
155
+ warnOnceFor(scope, "one");
156
+ warnOnceFor(scope, "two");
157
+
158
+ expect(warn).toHaveBeenCalledTimes(2);
159
+ });
160
+
161
+ it("stays silent in production, where the notice reaches no developer", () => {
162
+ process.env.NODE_ENV = "production";
163
+
164
+ freshModule().warnOnceFor({}, "hidden");
165
+
166
+ expect(warn).not.toHaveBeenCalled();
167
+ });
168
+
169
+ /* The deprecation opt-out is for a long migration; it should not hide a live bug. */
170
+ it("is not silenced by setDeprecationWarningsEnabled(false)", () => {
171
+ const { setDeprecationWarningsEnabled, warnOnceFor } = freshModule();
172
+
173
+ setDeprecationWarningsEnabled(false);
174
+ warnOnceFor({}, "still shown");
175
+
176
+ expect(warn).toHaveBeenCalledWith("still shown");
177
+ });
178
+ });
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * @file src/helpers/deprecation.ts
3
- * @exports setDeprecationWarningsEnabled, warnStyledComponentsDeprecated
4
- * @summary The single place this package announces that its styled-components layer is deprecated.
3
+ * @exports setDeprecationWarningsEnabled, warnOnceFor, warnStyledComponentsDeprecated
4
+ * @summary The single place this package warns a developer about anything.
5
5
  * @remarks
6
+ * Two notices live here, for one reason: each depends on module state to stay quiet, and a
7
+ * stray `console.warn` elsewhere would not respect it. The deprecation notice is the
8
+ * original; `warnOnceFor` is the general form, for a misconfiguration a consumer can fix.
6
9
  * The original styled-components components are deprecated, but not removed, in
7
10
  * favor of the Mantine layer under `src/mantine`. The notice is emitted once per app
8
11
  * session from `ThemeProvider`'s mount effect rather than from each component — an effect
@@ -51,3 +54,27 @@ export const warnStyledComponentsDeprecated = () => {
51
54
  hasWarned = true;
52
55
  console.warn(MESSAGE);
53
56
  };
57
+
58
+ const warnedByScope = new WeakMap<object, Set<string>>();
59
+
60
+ /**
61
+ * Warns once per `scope` — a long-lived object the notice belongs to, such as a
62
+ * `useReactTable` instance, whose identity `useReactTable` keeps stable for the life of
63
+ * the component. Two misconfigured tables therefore each get heard, while re-renders and
64
+ * StrictMode's double-mount of one table are counted once. A `WeakMap` so a scope that
65
+ * unmounts is collectable rather than pinned for the session.
66
+ *
67
+ * Deliberately not gated on `setDeprecationWarningsEnabled`: that switch is for a team
68
+ * living with the deprecation through a long migration, and silencing it should not also
69
+ * hide a bug they can fix today.
70
+ */
71
+ export const warnOnceFor = (scope: object, message: string) => {
72
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
73
+
74
+ const seen = warnedByScope.get(scope) ?? new Set<string>();
75
+ if (seen.has(message)) return;
76
+
77
+ seen.add(message);
78
+ warnedByScope.set(scope, seen);
79
+ console.warn(message);
80
+ };
package/src/index.ts CHANGED
@@ -681,6 +681,7 @@ export {
681
681
  DataTableRoot,
682
682
  DataTableRowActions,
683
683
  dataTableRowActionsColumn,
684
+ dataTableSelectionColumn,
684
685
  DataTableSidebar,
685
686
  DataTableTotalRecords,
686
687
  } from "./mantine/components/DataTable";
@@ -703,6 +704,8 @@ export type {
703
704
  DataTableRowOptionGroup,
704
705
  DataTableRowOptionIcon,
705
706
  DataTableSectionProps,
707
+ DataTableSelectionColumnOptions,
708
+ DataTableSelectionMode,
706
709
  DataTableSidebarProps,
707
710
  DataTableTotalRecordsProps,
708
711
  } from "./mantine/components/DataTable";
@@ -358,6 +358,48 @@
358
358
  justify-content: center;
359
359
  }
360
360
 
361
+ /* ----- Row selection ----- */
362
+
363
+ /*
364
+ * CheckboxCell's geometry: the control sits in the 40px track its `meta.fixedWidth`
365
+ * already cleared of side padding, with the legacy 17px lead. Single-class, like
366
+ * .rowActionsCell beside it -- this is our own div, not a Mantine slot, so there is
367
+ * nothing to outrank.
368
+ */
369
+ .selectionCell {
370
+ align-items: center;
371
+ display: flex;
372
+ height: 100%;
373
+ padding-left: 17px;
374
+ }
375
+
376
+ /*
377
+ * Composed onto .selectionCell in the header, for `CheckboxHeaderCell`'s extra pixel
378
+ * of lead and to sit the control on the header labels rather than above them.
379
+ *
380
+ * `flex-end` rather than the centring above, and no `padding-bottom` of the legacy
381
+ * cell's own: the labels are bottom-aligned in the header, so the control belongs at
382
+ * the bottom of the cell's content box too. Centring put it 11px high, because
383
+ * `CheckboxHeaderCell` *was* the grid cell and owned that padding, whereas this
384
+ * wrapper sits inside a `.th` that already pads 11px at the bottom -- so restating it
385
+ * here counted it twice, once as the cell's inset and once as the control's.
386
+ */
387
+ .selectionHeaderCell {
388
+ align-items: flex-end;
389
+ padding-left: 17px;
390
+ }
391
+
392
+ /*
393
+ * Mantine draws the checkbox glyph at 60% of the control, so the 20px control this
394
+ * column needs would render a 12px glyph -- smaller than the legacy Checkbox's 15px
395
+ * and than every other checkbox in the Mantine layer, which are both 25px x 60%.
396
+ * 75% puts it back at 15px. The radio needs no equivalent: its icon is pinned to
397
+ * the control's size, so it fills the 20px the legacy radio also measured.
398
+ */
399
+ .selectionCheckboxIcon.selectionCheckboxIcon {
400
+ width: 75%;
401
+ }
402
+
361
403
  /*
362
404
  * Keyed on plain `.tr.tr:hover` rather than the elevated-hover selector above, so
363
405
  * `disableHighlight` drops the row treatment without also disabling the button.
@@ -108,15 +108,17 @@ import Form, { FormProps as RJSFormProps } from "@rjsf/core";
108
108
  import { GenericObjectType, RJSFSchema } from "@rjsf/utils";
109
109
  import { Row, RowData, flexRender, useReactTable } from "@tanstack/react-table";
110
110
  import { clsx } from "clsx";
111
- import React, { createContext, FC, Fragment, PropsWithChildren, useCallback, useContext, useRef } from "react";
111
+ import React, { createContext, FC, Fragment, PropsWithChildren, useCallback, useContext, useEffect, useRef } from "react";
112
112
  import { Sort, SortDown, SortUp } from "../../../components/Icons";
113
113
 
114
114
  import { EmptySearchIcon } from "../../../assets/EmptySearchIcon";
115
+ import { warnOnceFor } from "../../../helpers/deprecation";
115
116
  import { MantineRJSForm } from "../../rjsf/createMantineRJSForm";
116
117
  import { mantineValidator } from "../../rjsf/mantineValidator";
117
118
  import { showBar } from "../../services/notifications";
118
119
 
119
120
  import classes from "./DataTable.module.css";
121
+ import type { DataTableSelectionMode } from "./DataTableSelection";
120
122
 
121
123
  /**
122
124
  * The per-column styling a column def asks for, through `columnDef.meta`.
@@ -176,6 +178,14 @@ export type DataTableColumnMeta = {
176
178
  * for how it interacts with density and `fixedWidth`.
177
179
  */
178
180
  paddingRight?: number | string;
181
+ /**
182
+ * Set by `dataTableSelectionColumn`, and the only field here that is not styling.
183
+ * It is what tells `DataTableBody` the table has a selection column and which
184
+ * control it renders, so a row click can select without a prop saying so. Not for
185
+ * hand-authoring: it does not render a control, it only describes the one the
186
+ * factory rendered.
187
+ */
188
+ selectionMode?: DataTableSelectionMode;
179
189
  /**
180
190
  * The old ColumnType's `textStyle`, and the same four values: the legacy BodyS*
181
191
  * styles were all 15px and differed only in weight and colour.
@@ -832,6 +842,12 @@ const cellProps = (meta: DataTableColumnMeta | undefined) => {
832
842
  */
833
843
  const bodyCellProps = (meta: DataTableColumnMeta | undefined) => ({ ...cellProps(meta), "data-text-style": meta?.textStyle });
834
844
 
845
+ const MISSING_GET_ROW_ID_WARNING = [
846
+ "[@cerebruminc/cerebellum] DataTable has a row-selection column but its table has no `getRowId`.",
847
+ "TanStack then keys row selection by each record's index in `data`, so a selection silently moves to whatever record takes that index whenever `data` changes -- filtered, reordered, or fetched a page at a time.",
848
+ "Pass `getRowId: (record) => record.id` -- or whatever identifies your record -- to useReactTable.",
849
+ ].join(" ");
850
+
835
851
  export const DataTableBody = <TRecord,>({
836
852
  density,
837
853
  disableHighlight,
@@ -850,6 +866,68 @@ export const DataTableBody = <TRecord,>({
850
866
  const rows = dataTable.rt.getRowModel().rows;
851
867
  const hasNoRecords = !dataTable.isLoading && rows.length === 0;
852
868
 
869
+ /*
870
+ * Read off `dataTableSelectionColumn`'s own meta rather than taken as a prop. Its
871
+ * presence is what says the table has selection at all -- which matters because
872
+ * TanStack's `enableRowSelection` defaults to on, so `row.getCanSelect()` is true
873
+ * for every table that never asked for selection and would make all of them
874
+ * click-to-select. Its value is the control the column renders, which is the one
875
+ * thing a row handler cannot otherwise know.
876
+ */
877
+ const selectionMode = dataTable.rt.getAllLeafColumns().find((column) => column.columnDef.meta?.selectionMode)?.columnDef
878
+ .meta?.selectionMode;
879
+
880
+ /*
881
+ * A selection column with no `getRowId` is always a bug, never a choice: TanStack keys
882
+ * `rowSelection` by `row.id`, which is the record's *index in `data`* until the caller
883
+ * configures one. Client-side paging and sorting are safe, because neither changes
884
+ * `data` -- it is replacing that array, as a filtered or server-paged table does, that
885
+ * silently moves the selection to whatever record now sits at the index. Nothing here
886
+ * can fix it -- `getRowId` is an option on the caller's own `useReactTable` call,
887
+ * resolved before DataTable is handed the result -- so saying so is the most this
888
+ * layer can do.
889
+ *
890
+ * In an effect, so a discarded or server-side render cannot spend the one warning.
891
+ */
892
+ useEffect(() => {
893
+ if (selectionMode && !dataTable.rt.options.getRowId) {
894
+ warnOnceFor(dataTable.rt, MISSING_GET_ROW_ID_WARNING);
895
+ }
896
+ }, [dataTable.rt, selectionMode]);
897
+
898
+ /*
899
+ * `onRowClick` wins, so a table can have both a row action and a selection column
900
+ * without the two fighting over the same click. Otherwise a selection column makes
901
+ * the whole row a selection target, so selection is never a 40px one. Undefined
902
+ * when neither applies, which leaves the row unclickable rather than clickable and
903
+ * inert.
904
+ *
905
+ * The row does what a click on its own control would: a checkbox toggles, and a
906
+ * radio only ever selects, because a native radio does not clear itself when
907
+ * clicked again.
908
+ */
909
+ const activateRow = onRowClick
910
+ ? onRowClick
911
+ : selectionMode
912
+ ? (row: Row<TRecord>) => row.toggleSelected(selectionMode === "radio" ? true : undefined)
913
+ : undefined;
914
+
915
+ /*
916
+ * Per row rather than per table: a row that cannot be selected must not carry the
917
+ * pointer cursor or a click that would do nothing.
918
+ */
919
+ const canActivateRow = (row: Row<TRecord>) => Boolean(activateRow) && (Boolean(onRowClick) || row.getCanSelect());
920
+
921
+ /*
922
+ * Only a row that carries a handler of its own is a tab stop. Under a selection
923
+ * column the control inside the row is already focusable and already does exactly
924
+ * what the row does, so a tab stop on the row as well would be two stops per row
925
+ * for one action -- and the keyboard user would meet the row's focus ring before
926
+ * the control that explains it. The mouse path is unaffected: the row keeps its
927
+ * pointer cursor and its click either way.
928
+ */
929
+ const rowIsFocusable = Boolean(onRowClick);
930
+
853
931
  return (
854
932
  /*
855
933
  * ScrollArea rather than a plain overflow container, so the scrollbar is the
@@ -967,19 +1045,25 @@ export const DataTableBody = <TRecord,>({
967
1045
  const selected = isRowSelected?.(row);
968
1046
  const isSelected = Boolean(selected);
969
1047
  const isBusy = Boolean(isRowLoading?.(row));
970
- // A busy row is inert: neither the mouse nor Enter/Space reaches the
971
- // caller, and the pointer cursor goes with them.
972
- const clickable = Boolean(onRowClick) && !isBusy;
1048
+ /*
1049
+ * A busy row is inert: neither the mouse nor Enter/Space reaches the
1050
+ * caller, and the pointer cursor goes with them. `canActivateRow`
1051
+ * rather than `onRowClick`, so that now covers a click that would
1052
+ * have selected the row as well as one the caller handles.
1053
+ */
1054
+ const isActivatable = canActivateRow(row) && !isBusy;
973
1055
  return (
974
1056
  <TableTr
975
1057
  aria-busy={isBusy || undefined}
976
1058
  /*
977
- * A clickable row is reachable by Tab and activated by Enter
978
- * or Space. No role="button" — that would strip the row's
979
- * own role and break the table for screen readers — so the
980
- * keys are handled explicitly instead of coming free.
1059
+ * A row with `onRowClick` is reachable by Tab and activated
1060
+ * by Enter or Space. No role="button" — that would strip the
1061
+ * row's own role and break the table for screen readers — so
1062
+ * the keys are handled explicitly instead of coming free.
1063
+ * A row that is clickable only because the table has a
1064
+ * selection column is not a tab stop; its control is.
981
1065
  */
982
- data-clickable={clickable || undefined}
1066
+ data-clickable={isActivatable || undefined}
983
1067
  // The same attribute the whole-table skeleton rows carry, so
984
1068
  // .tr:not([data-loading]) already excludes a busy row from hover.
985
1069
  data-loading={isBusy || undefined}
@@ -988,15 +1072,19 @@ export const DataTableBody = <TRecord,>({
988
1072
  data-selected={isSelected || undefined}
989
1073
  data-testid={getRowTestId?.(row, isSelected)}
990
1074
  key={row.id}
991
- onClick={clickable ? (e) => onRowClick?.(row, e) : undefined}
1075
+ onClick={isActivatable ? (e) => activateRow?.(row, e) : undefined}
992
1076
  /*
993
1077
  * Still bound while busy, unlike onClick: the row keeps the focus
994
1078
  * it had when it went busy, so Space has to keep being swallowed
995
1079
  * or it would scroll the table under the user. Only the call to
996
1080
  * the caller drops out.
1081
+ *
1082
+ * Bound on `rowIsFocusable` rather than on `isActivatable`, which
1083
+ * folds in `isBusy` — gating on that would unbind the handler for
1084
+ * exactly the row that still needs it.
997
1085
  */
998
1086
  onKeyDown={
999
- onRowClick
1087
+ rowIsFocusable && activateRow
1000
1088
  ? (event) => {
1001
1089
  // Only the row itself: a button or link inside a cell
1002
1090
  // handles its own keys, and its event bubbles here.
@@ -1008,14 +1096,17 @@ export const DataTableBody = <TRecord,>({
1008
1096
  if (isBusy) {
1009
1097
  return;
1010
1098
  }
1011
- onRowClick(row, event);
1099
+ activateRow(row, event);
1012
1100
  }
1013
1101
  : undefined
1014
1102
  }
1015
- // -1 rather than no attribute while busy: out of the tab order
1016
- // either way, but still focusable, so a row focused when it went
1017
- // busy keeps focus instead of dropping it on <body>.
1018
- tabIndex={onRowClick ? (isBusy ? -1 : 0) : undefined}
1103
+ /*
1104
+ * -1 rather than no attribute while busy: out of the tab order
1105
+ * either way, but still focusable, so a row focused when it went
1106
+ * busy keeps focus instead of dropping it on <body>. A row that was
1107
+ * never a tab stop has no focus to keep, so it stays absent.
1108
+ */
1109
+ tabIndex={rowIsFocusable ? (isBusy ? -1 : 0) : undefined}
1019
1110
  >
1020
1111
  {row.getVisibleCells().map((cell) => {
1021
1112
  const content = flexRender(cell.column.columnDef.cell, cell.getContext());