@visns-studio/visns-components 6.6.3 → 6.6.4

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/README.md CHANGED
@@ -607,6 +607,41 @@ The DataGrid component utilizes a modular column renderer system with 28 special
607
607
 
608
608
  All column renderers are exported from `@visns-studio/visns-components` and can be used individually or as part of the DataGrid component.
609
609
 
610
+ #### `autoGrow` — rows that outgrow the fixed tablet row height
611
+
612
+ In tablet mode (and in `large` density) the DataGrid runs at a **fixed** row
613
+ height from `DENSITY_GRID` — 52px or 60px — because the alternative, letting
614
+ the grid measure every row, depends on a rAF that a background-throttled tab
615
+ never runs, which clipped action buttons after an auto-refresh. The cost is
616
+ that a cell whose content genuinely needs a second or third line (a
617
+ `stageCounter` with enough chips to wrap) is cut off at the row height.
618
+
619
+ `"autoGrow": true` on a column buys those rows back, and only those rows:
620
+
621
+ ```jsx
622
+ {
623
+ id: 'headers',
624
+ label: 'Headers',
625
+ type: 'stageCounter',
626
+ autoGrow: true,
627
+ stageConfig: { key: 'headers', stageLabelKey: 'name' }
628
+ }
629
+ ```
630
+
631
+ - The cell's content is measured with a `ResizeObserver`; a row whose content
632
+ needs more than the fixed height grows to fit it, and every other row keeps
633
+ the fixed height exactly as before.
634
+ - The added height is the content's natural height plus the cell's **measured**
635
+ vertical padding and borders, so it tracks `--grid-cell-padding` and the
636
+ density in force rather than a constant.
637
+ - **Desktop density is unaffected** (`rowHeight` is `null` there, so rows
638
+ already measure naturally) and a grid with no `autoGrow` column takes exactly
639
+ the code path it did before.
640
+ - Group rows (`groupBy`) keep the default height; `treeGroupBy` parents and
641
+ members grow like any other row.
642
+ - Grown rows stay vertically centred, so neighbouring cells and the action
643
+ buttons line up as they always have.
644
+
610
645
  #### Dropdown Column Color Support
611
646
 
612
647
  The dropdown column type now supports automatic background color application based on the selected option's `colour` property. This feature works in both DataGrid and GenericEditableTable components.
package/package.json CHANGED
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.6.3",
94
+ "version": "6.6.4",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -144,6 +144,7 @@ import {
144
144
  renderPhoneColumn,
145
145
  renderAbnColumn,
146
146
  } from './columns/ColumnRenderers.jsx';
147
+ import AutoGrowCell from './columns/AutoGrowCell.jsx';
147
148
  import _ from 'lodash';
148
149
 
149
150
  // Enhanced multi-line content support for data cells
@@ -959,6 +960,197 @@ const DataGrid = forwardRef(
959
960
  // prevents action buttons from being clipped.
960
961
  const { grid } = useDensity();
961
962
 
963
+ // ---------------------------------------------------------------
964
+ // Per-column autoGrow
965
+ // ---------------------------------------------------------------
966
+ //
967
+ // A column with `"autoGrow": true` lets the ROWS whose content does
968
+ // not fit the fixed height above grow to fit — and only those rows.
969
+ // Nothing here runs unless such a column exists AND the density gives
970
+ // a numeric `grid.rowHeight`; on desktop (`rowHeight === null`) the
971
+ // grid already measures every row, so autoGrow has nothing to add.
972
+ //
973
+ // Why this drives the grid's RowHeightManager instead of passing
974
+ // `rowHeight` as a function: the datagrid's types advertise
975
+ // `rowHeight: number | ((rowIndex) => number) | null`, but in this
976
+ // fork the function form is not implemented. `RowHeightManager`
977
+ // stores it as `rowHeightFn` and never reads it again, and
978
+ // `factory.tsx` derives `naturalRowHeight: typeof props.rowHeight !==
979
+ // 'number'` — so a function rowHeight silently turns on exactly the
980
+ // lazy per-row rAF measurement the fixed height exists to avoid.
981
+ //
982
+ // What IS implemented is the manager's per-index override map: every
983
+ // row's height comes from `rowHeightManager.getRowHeight(index)`
984
+ // (packages/react-virtual-list-pro/src/Row.tsx), which reads that map
985
+ // first and falls back to the fixed height; `setHeights(map)`
986
+ // re-indexes the offsets and emits `index`, which `VirtualList`
987
+ // listens for and answers with a full re-layout. The cells follow the
988
+ // row because Content/Row.tsx passes the row's own height down as the
989
+ // cell's `initialRowHeight` on unexpanded rows, so a grown row's other
990
+ // cells and its action buttons stay vertically centred in the taller
991
+ // box exactly as they are today.
992
+ //
993
+ // Heights are keyed by ROW ID (stable across sorting, filtering and
994
+ // virtualised scrolling) and only converted to the manager's
995
+ // index-keyed map at flush time, from the grid's own computed data —
996
+ // which includes group rows, so indexes stay right under groupBy/tree.
997
+ const autoGrowHeightsRef = useRef(new Map());
998
+ const autoGrowRowIndexRef = useRef(new Map());
999
+ const autoGrowAppliedRef = useRef(null);
1000
+ const autoGrowRafRef = useRef(0);
1001
+
1002
+ const flushAutoGrowHeights = useCallback(() => {
1003
+ autoGrowRafRef.current = 0;
1004
+
1005
+ const gridApi = gridRef.current;
1006
+ const manager = gridApi?.rowHeightManager;
1007
+ const baseRowHeight = grid.rowHeight;
1008
+
1009
+ if (!manager || typeof baseRowHeight !== 'number') {
1010
+ return;
1011
+ }
1012
+
1013
+ const heights = autoGrowHeightsRef.current;
1014
+ const next = {};
1015
+
1016
+ // The grid's computed data is the authority on index → row: it is
1017
+ // the array the virtual list indexes into, group rows included.
1018
+ // The `onRenderRow` record is only a fallback for the frame
1019
+ // before `handle` has published the computed props.
1020
+ const data = Array.isArray(gridApi?.data) ? gridApi.data : null;
1021
+
1022
+ if (data) {
1023
+ const present = new Set();
1024
+
1025
+ for (let index = 0; index < data.length; index++) {
1026
+ const row = data[index];
1027
+
1028
+ // Group rows keep the default height — they carry a title,
1029
+ // not a column's content.
1030
+ if (!row || row.__group) {
1031
+ continue;
1032
+ }
1033
+
1034
+ const rowId = row.id;
1035
+
1036
+ if (rowId === undefined || rowId === null) {
1037
+ continue;
1038
+ }
1039
+
1040
+ const key = String(rowId);
1041
+
1042
+ present.add(key);
1043
+
1044
+ const measured = heights.get(key);
1045
+
1046
+ if (measured > baseRowHeight) {
1047
+ next[index] = measured;
1048
+ }
1049
+ }
1050
+
1051
+ // Rows that left the page cannot be measured again, so drop
1052
+ // their heights rather than letting the map grow forever.
1053
+ heights.forEach((_value, key) => {
1054
+ if (!present.has(key)) {
1055
+ heights.delete(key);
1056
+ }
1057
+ });
1058
+ } else {
1059
+ autoGrowRowIndexRef.current.forEach((key, index) => {
1060
+ const measured = heights.get(key);
1061
+
1062
+ if (measured > baseRowHeight) {
1063
+ next[index] = measured;
1064
+ }
1065
+ });
1066
+ }
1067
+
1068
+ // setHeights re-indexes and emits, and the re-layout re-renders
1069
+ // the rows — which measures them again. Applying an unchanged map
1070
+ // would therefore loop, so compare before writing.
1071
+ const serialised = JSON.stringify(next);
1072
+
1073
+ if (serialised === autoGrowAppliedRef.current) {
1074
+ return;
1075
+ }
1076
+
1077
+ autoGrowAppliedRef.current = serialised;
1078
+
1079
+ if (typeof manager.setHeights === 'function') {
1080
+ manager.setHeights(next);
1081
+ }
1082
+ }, [grid]);
1083
+
1084
+ // One flush per frame: a page of 100 rows measuring on mount is 100
1085
+ // reports and one re-layout.
1086
+ const scheduleAutoGrowFlush = useCallback(() => {
1087
+ if (autoGrowRafRef.current) {
1088
+ return;
1089
+ }
1090
+
1091
+ if (typeof requestAnimationFrame === 'undefined') {
1092
+ flushAutoGrowHeights();
1093
+ return;
1094
+ }
1095
+
1096
+ autoGrowRafRef.current =
1097
+ requestAnimationFrame(flushAutoGrowHeights);
1098
+ }, [flushAutoGrowHeights]);
1099
+
1100
+ /** `AutoGrowCell`'s callback: one row's natural height, in px. */
1101
+ const reportAutoGrowHeight = useCallback(
1102
+ (rowId, height, force) => {
1103
+ if (
1104
+ rowId === undefined ||
1105
+ rowId === null ||
1106
+ !Number.isFinite(height)
1107
+ ) {
1108
+ return;
1109
+ }
1110
+
1111
+ const key = String(rowId);
1112
+ const previous = autoGrowHeightsRef.current.get(key);
1113
+ const changed =
1114
+ previous === undefined || Math.abs(previous - height) > 1;
1115
+
1116
+ if (changed) {
1117
+ autoGrowHeightsRef.current.set(key, height);
1118
+ }
1119
+
1120
+ // `force` covers the mount/row-changed case: the height may be
1121
+ // identical while the INDEX it has to be applied at is not.
1122
+ if (changed || force) {
1123
+ scheduleAutoGrowFlush();
1124
+ }
1125
+ },
1126
+ [scheduleAutoGrowFlush]
1127
+ );
1128
+
1129
+ useEffect(
1130
+ () => () => {
1131
+ if (
1132
+ autoGrowRafRef.current &&
1133
+ typeof cancelAnimationFrame !== 'undefined'
1134
+ ) {
1135
+ cancelAnimationFrame(autoGrowRafRef.current);
1136
+ autoGrowRafRef.current = 0;
1137
+ }
1138
+ },
1139
+ []
1140
+ );
1141
+
1142
+ // Leaving the fixed-height densities hands row heights back to the
1143
+ // grid; anything measured under the old density is meaningless.
1144
+ useEffect(() => {
1145
+ if (typeof grid.rowHeight === 'number') {
1146
+ return;
1147
+ }
1148
+
1149
+ autoGrowHeightsRef.current.clear();
1150
+ autoGrowRowIndexRef.current.clear();
1151
+ autoGrowAppliedRef.current = null;
1152
+ }, [grid]);
1153
+
962
1154
  // Effect to handle autoRefresh setting
963
1155
  useEffect(() => {
964
1156
  if (ajaxSetting && ajaxSetting.autoRefresh !== undefined) {
@@ -1921,7 +2113,11 @@ const DataGrid = forwardRef(
1921
2113
  }
1922
2114
 
1923
2115
  // Show confirmation dialog for other actions to prevent accidental clicks
1924
- const confirmMessage = `Are you sure you want to perform "${iconConfig.label}" action on group "${groupValue}" (${groupRows.length} rows)?`;
2116
+ const confirmMessage = `Are you sure you want to perform "${
2117
+ iconConfig.label
2118
+ }" action on group "${getGroupDisplayLabel(groupValue)}" (${
2119
+ groupRows.length
2120
+ } rows)?`;
1925
2121
 
1926
2122
  confirmDialog({
1927
2123
  title: 'Confirm Group Action',
@@ -1959,6 +2155,28 @@ const DataGrid = forwardRef(
1959
2155
  );
1960
2156
  };
1961
2157
 
2158
+ /**
2159
+ * `groupBySetting.labelKey` is display-only: when set, the group row
2160
+ * shows the named field taken from the group's first row instead of
2161
+ * the raw group value (falling back to it when the field is empty).
2162
+ * The group KEY is still `groupValue` — it decides membership,
2163
+ * selection, collapse state, `handleGroupAction`/`shouldShowGroupAction`
2164
+ * and what the backend receives (`groupLabel`).
2165
+ */
2166
+ const getGroupDisplayLabel = (groupValue) => {
2167
+ const labelKey = ajaxSetting?.groupBySetting?.labelKey;
2168
+
2169
+ if (!labelKey) {
2170
+ return groupValue;
2171
+ }
2172
+
2173
+ const label = getGroupRows(groupValue)[0]?.[labelKey];
2174
+
2175
+ return label === null || label === undefined || label === ''
2176
+ ? groupValue
2177
+ : label;
2178
+ };
2179
+
1962
2180
  /**
1963
2181
  * The show-condition grammar shared by group action icons and row
1964
2182
  * action icons:
@@ -2096,7 +2314,9 @@ const DataGrid = forwardRef(
2096
2314
  iconConfig.fetch.messageFromResponse &&
2097
2315
  result?.data?.message
2098
2316
  ? result.data.message
2099
- : `${iconConfig.label} - Group: ${groupValue} completed successfully`
2317
+ : `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2318
+ groupValue
2319
+ )} completed successfully`
2100
2320
  );
2101
2321
 
2102
2322
  // Reload grid data if needed
@@ -2113,7 +2333,9 @@ const DataGrid = forwardRef(
2113
2333
  } catch (error) {
2114
2334
  console.error('Group action fetch error:', error);
2115
2335
  toast.error(
2116
- `${iconConfig.label} - Group: ${groupValue} failed: ${error.message}`
2336
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2337
+ groupValue
2338
+ )} failed: ${error.message}`
2117
2339
  );
2118
2340
  }
2119
2341
  } else if (iconConfig.formModal) {
@@ -2137,7 +2359,11 @@ const DataGrid = forwardRef(
2137
2359
  // Fallback to existing switch logic if no fetch config
2138
2360
  // Skip toast notification for edit actions
2139
2361
  if (iconConfig.id !== 'edit') {
2140
- toast.info(`${iconConfig.label} - Group: ${groupValue}`);
2362
+ toast.info(
2363
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2364
+ groupValue
2365
+ )}`
2366
+ );
2141
2367
  }
2142
2368
 
2143
2369
  switch (iconConfig.id) {
@@ -2684,6 +2910,26 @@ const DataGrid = forwardRef(
2684
2910
 
2685
2911
  const onRenderRow = useCallback(
2686
2912
  (rowProps) => {
2913
+ // Index → row id, the fallback map autoGrow flushes from
2914
+ // before the grid has published its computed data (see the
2915
+ // autoGrow block above). Group rows are skipped: they keep the
2916
+ // default height.
2917
+ const autoGrowIndex =
2918
+ rowProps?.rowIndex ?? rowProps?.realIndex;
2919
+
2920
+ if (
2921
+ typeof autoGrowIndex === 'number' &&
2922
+ rowProps?.data &&
2923
+ !rowProps.data.__group &&
2924
+ rowProps.data.id !== undefined &&
2925
+ rowProps.data.id !== null
2926
+ ) {
2927
+ autoGrowRowIndexRef.current.set(
2928
+ autoGrowIndex,
2929
+ String(rowProps.data.id)
2930
+ );
2931
+ }
2932
+
2687
2933
  // save the original handlers to be called later
2688
2934
  const { onClick } = rowProps;
2689
2935
 
@@ -4594,16 +4840,91 @@ const DataGrid = forwardRef(
4594
4840
  };
4595
4841
  };
4596
4842
 
4843
+ /**
4844
+ * `"autoGrow": true` on a column definition.
4845
+ *
4846
+ * Opt-in, and only meaningful where the grid runs at a
4847
+ * fixed row height — on desktop (`grid.rowHeight === null`)
4848
+ * rows already measure naturally, so this returns the
4849
+ * column untouched and the code path below is the one that
4850
+ * has always run.
4851
+ *
4852
+ * Outermost decorator on purpose: it has to measure the
4853
+ * FINAL content of the cell, including the tree indent and
4854
+ * any row-action icons the decorators below add.
4855
+ */
4856
+ const decorateAutoGrow = (gridColumn, columnDef) => {
4857
+ if (
4858
+ columnDef?.autoGrow !== true ||
4859
+ typeof grid.rowHeight !== 'number' ||
4860
+ !gridColumn
4861
+ ) {
4862
+ return gridColumn;
4863
+ }
4864
+
4865
+ const originalRender = gridColumn.render;
4866
+ const existingClassName = gridColumn.className;
4867
+
4868
+ // The class relaxes the nowrap/ellipsis clamp that
4869
+ // global-datagrid.css puts on every non-wrapping cell;
4870
+ // without it the content can never take a second line
4871
+ // and so can never be taller than the row.
4872
+ const withAutoGrowClass = (base) =>
4873
+ [base, 'cell-auto-grow'].filter(Boolean).join(' ');
4874
+
4875
+ return {
4876
+ ...gridColumn,
4877
+ className:
4878
+ typeof existingClassName === 'function'
4879
+ ? (...args) =>
4880
+ withAutoGrowClass(
4881
+ existingClassName(...args)
4882
+ )
4883
+ : withAutoGrowClass(existingClassName),
4884
+ render: (arg) => {
4885
+ const content = originalRender
4886
+ ? originalRender(arg)
4887
+ : arg?.value ?? null;
4888
+
4889
+ const row = arg?.data;
4890
+
4891
+ // Group rows keep the default height, and an
4892
+ // empty cell has nothing to grow for.
4893
+ if (
4894
+ !row ||
4895
+ row.__group ||
4896
+ content === null ||
4897
+ content === undefined ||
4898
+ content === false
4899
+ ) {
4900
+ return content;
4901
+ }
4902
+
4903
+ return (
4904
+ <AutoGrowCell
4905
+ rowId={row.id}
4906
+ onMeasure={reportAutoGrowHeight}
4907
+ >
4908
+ {content}
4909
+ </AutoGrowCell>
4910
+ );
4911
+ },
4912
+ };
4913
+ };
4914
+
4597
4915
  // decorateTreeChildIndent sits INSIDE
4598
4916
  // decorateTreeParentActions: the latter draws parent rows
4599
4917
  // itself and delegates everything else downward, so the
4600
4918
  // member indent has to be the render it delegates to.
4601
4919
  const newColumns = columns.map((column) =>
4602
- decorateColumnRowActions(
4603
- decorateTreeParentActions(
4604
- decorateTreeChildIndent(
4605
- guardTreeParentRender(
4606
- renderColumn(column),
4920
+ decorateAutoGrow(
4921
+ decorateColumnRowActions(
4922
+ decorateTreeParentActions(
4923
+ decorateTreeChildIndent(
4924
+ guardTreeParentRender(
4925
+ renderColumn(column),
4926
+ column
4927
+ ),
4607
4928
  column
4608
4929
  ),
4609
4930
  column
@@ -5328,126 +5649,130 @@ const DataGrid = forwardRef(
5328
5649
  aria-label="Select all items in this group"
5329
5650
  />
5330
5651
  )}
5331
- <button
5332
- className={`group-expand-btn ${styles.groupExpandBtn}`}
5333
- onClick={(e) => {
5334
- e.stopPropagation();
5335
- if (
5336
- typeof toggleGroup ===
5337
- 'function'
5338
- ) {
5339
- toggleGroup();
5652
+ <div
5653
+ className={`group-controls ${styles.groupControls}`}
5654
+ >
5655
+ <button
5656
+ className={`group-expand-btn ${styles.groupExpandBtn}`}
5657
+ onClick={(e) => {
5658
+ e.stopPropagation();
5659
+ if (
5660
+ typeof toggleGroup ===
5661
+ 'function'
5662
+ ) {
5663
+ toggleGroup();
5664
+ }
5665
+ }}
5666
+ onTouchStart={(e) => {
5667
+ e.stopPropagation();
5668
+ handleTouchStart(
5669
+ e,
5670
+ 'group-expand',
5671
+ collapsed
5672
+ ? 'Expand group'
5673
+ : 'Collapse group'
5674
+ );
5675
+ }}
5676
+ onTouchEnd={
5677
+ handleTouchEnd
5340
5678
  }
5341
- }}
5342
- onTouchStart={(e) => {
5343
- e.stopPropagation();
5344
- handleTouchStart(
5345
- e,
5346
- 'group-expand',
5679
+ onTouchMove={
5680
+ handleTouchMove
5681
+ }
5682
+ data-tooltip-id="system-tooltip"
5683
+ data-tooltip-content={
5347
5684
  collapsed
5348
5685
  ? 'Expand group'
5349
5686
  : 'Collapse group'
5350
- );
5351
- }}
5352
- onTouchEnd={
5353
- handleTouchEnd
5354
- }
5355
- onTouchMove={
5356
- handleTouchMove
5357
- }
5358
- data-tooltip-id="system-tooltip"
5359
- data-tooltip-content={
5360
- collapsed
5361
- ? 'Expand group'
5362
- : 'Collapse group'
5363
- }
5364
- >
5365
- {collapsed
5366
- ? '+'
5367
- : '−'}
5368
- </button>
5369
- {/* Dynamic group action buttons */}
5370
- {ajaxSetting?.groupBySetting?.icons?.map(
5371
- (
5372
- iconConfig,
5373
- index
5374
- ) => {
5375
- // Check if this icon should be shown based on show conditions
5376
- if (
5377
- !shouldShowGroupAction(
5378
- iconConfig,
5379
- groupValue
5380
- )
5381
- ) {
5382
- return null;
5383
5687
  }
5688
+ >
5689
+ {collapsed
5690
+ ? '+'
5691
+ : '−'}
5692
+ </button>
5693
+ {/* Dynamic group action buttons */}
5694
+ {ajaxSetting?.groupBySetting?.icons?.map(
5695
+ (
5696
+ iconConfig,
5697
+ index
5698
+ ) => {
5699
+ // Check if this icon should be shown based on show conditions
5700
+ if (
5701
+ !shouldShowGroupAction(
5702
+ iconConfig,
5703
+ groupValue
5704
+ )
5705
+ ) {
5706
+ return null;
5707
+ }
5384
5708
 
5385
- const IconComponent =
5386
- getGroupIconComponent(
5387
- iconConfig.id
5388
- );
5709
+ const IconComponent =
5710
+ getGroupIconComponent(
5711
+ iconConfig.id
5712
+ );
5389
5713
 
5390
- // Generate different color classes for multiple buttons
5391
- const colorClasses =
5392
- [
5393
- 'success',
5394
- 'warning',
5395
- 'info',
5396
- 'purple',
5397
- 'pink',
5398
- ];
5399
- const colorClass =
5400
- colorClasses[
5401
- index %
5402
- colorClasses.length
5403
- ];
5404
-
5405
- return (
5406
- <button
5407
- key={`group-action-${index}`}
5408
- className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5409
- onClick={(
5410
- e
5411
- ) => {
5412
- e.stopPropagation();
5413
- handleGroupAction(
5414
- groupValue,
5415
- iconConfig
5416
- );
5417
- }}
5418
- onTouchStart={(
5419
- e
5420
- ) => {
5421
- e.stopPropagation();
5422
- handleTouchStart(
5423
- e,
5424
- `group-${iconConfig.id}-${index}`,
5425
- iconConfig.label
5426
- );
5427
- }}
5428
- onTouchEnd={
5429
- handleTouchEnd
5430
- }
5431
- onTouchMove={
5432
- handleTouchMove
5433
- }
5434
- data-tooltip-id="system-tooltip"
5435
- data-tooltip-content={
5436
- iconConfig.label
5437
- }
5438
- >
5439
- <IconComponent
5440
- strokeWidth={
5441
- 2
5714
+ // Generate different color classes for multiple buttons
5715
+ const colorClasses =
5716
+ [
5717
+ 'success',
5718
+ 'warning',
5719
+ 'info',
5720
+ 'purple',
5721
+ 'pink',
5722
+ ];
5723
+ const colorClass =
5724
+ colorClasses[
5725
+ index %
5726
+ colorClasses.length
5727
+ ];
5728
+
5729
+ return (
5730
+ <button
5731
+ key={`group-action-${index}`}
5732
+ className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5733
+ onClick={(
5734
+ e
5735
+ ) => {
5736
+ e.stopPropagation();
5737
+ handleGroupAction(
5738
+ groupValue,
5739
+ iconConfig
5740
+ );
5741
+ }}
5742
+ onTouchStart={(
5743
+ e
5744
+ ) => {
5745
+ e.stopPropagation();
5746
+ handleTouchStart(
5747
+ e,
5748
+ `group-${iconConfig.id}-${index}`,
5749
+ iconConfig.label
5750
+ );
5751
+ }}
5752
+ onTouchEnd={
5753
+ handleTouchEnd
5442
5754
  }
5443
- size={
5444
- 14
5755
+ onTouchMove={
5756
+ handleTouchMove
5445
5757
  }
5446
- />
5447
- </button>
5448
- );
5449
- }
5450
- ) || null}
5758
+ data-tooltip-id="system-tooltip"
5759
+ data-tooltip-content={
5760
+ iconConfig.label
5761
+ }
5762
+ >
5763
+ <IconComponent
5764
+ strokeWidth={
5765
+ 2
5766
+ }
5767
+ size={
5768
+ 14
5769
+ }
5770
+ />
5771
+ </button>
5772
+ );
5773
+ }
5774
+ ) || null}
5775
+ </div>
5451
5776
  <div
5452
5777
  className={
5453
5778
  styles.groupInfo
@@ -5458,7 +5783,9 @@ const DataGrid = forwardRef(
5458
5783
  styles.groupValue
5459
5784
  }
5460
5785
  >
5461
- {groupValue}
5786
+ {getGroupDisplayLabel(
5787
+ groupValue
5788
+ )}
5462
5789
  {/* Important marker */}
5463
5790
  {hasImportantMarker && (
5464
5791
  <AlertCircle
@@ -0,0 +1,141 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * The measuring wrapper behind a column's `"autoGrow": true`.
5
+ *
6
+ * Why it exists
7
+ * -------------
8
+ * In tablet mode (and in `large` density generally) the grid runs with a
9
+ * FIXED `rowHeight` — see the density table in `utils/useDensity.js` and the
10
+ * comment above `rowHeight={grid.rowHeight}` in `DataGrid.jsx`. A fixed height
11
+ * is what keeps action buttons from being clipped after an auto-refresh on an
12
+ * idle tab, because Inovua's alternative (`rowHeight={null}`) measures every
13
+ * row with a lazily-scheduled rAF, which a background-throttled tab never runs.
14
+ *
15
+ * The cost of that trade is that a cell whose content genuinely needs three
16
+ * lines — a `stageCounter` column with enough chips to wrap twice — gets cut
17
+ * off at the row's height. `autoGrow` buys back JUST those rows: this wrapper
18
+ * measures the content's natural height and hands it up, and the grid raises
19
+ * that ONE row to fit. Every other row keeps the fixed height, so the property
20
+ * that made the fixed height worth having (no measurement in the common path)
21
+ * is preserved.
22
+ *
23
+ * How the measurement stays honest
24
+ * --------------------------------
25
+ * The cell itself is height-clamped (`.InovuaReactDataGrid__cell` gets an
26
+ * inline `height` from the row's height, and `global-datagrid.css` gives it
27
+ * `overflow: hidden`), so measuring the CELL would only ever report the clamp
28
+ * back. This `div` is a plain block inside the cell's content wrapper with no
29
+ * height of its own, so its `scrollHeight` is the content's natural height
30
+ * whatever the cell around it is doing.
31
+ *
32
+ * The number reported is a ROW height, not a content height: the owning cell's
33
+ * vertical padding and borders are read off `getComputedStyle` and added, so
34
+ * the figure tracks `--grid-cell-padding` and the density that sets it instead
35
+ * of a constant that would drift the moment either changed.
36
+ *
37
+ * @param {object} props
38
+ * @param {*} props.rowId id of the row this cell belongs to; the
39
+ * key the grid stores the height under.
40
+ * @param {Function} props.onMeasure `(rowId, heightPx, force) => void`
41
+ * @param {import('react').ReactNode} props.children the column's own render.
42
+ */
43
+ const AutoGrowCell = ({ rowId, onMeasure, children }) => {
44
+ const nodeRef = useRef(null);
45
+ // Last height handed up, so a ResizeObserver that fires on every column
46
+ // resize does not re-report a height nothing has changed about.
47
+ const lastReportedRef = useRef(null);
48
+
49
+ useEffect(() => {
50
+ const node = nodeRef.current;
51
+
52
+ if (
53
+ !node ||
54
+ rowId === undefined ||
55
+ rowId === null ||
56
+ typeof onMeasure !== 'function'
57
+ ) {
58
+ return undefined;
59
+ }
60
+
61
+ /**
62
+ * Padding + borders of the cell this content sits in. Measured rather
63
+ * than assumed: `--grid-cell-padding` is density-driven and a caller
64
+ * can override it per grid.
65
+ */
66
+ const cellChrome = () => {
67
+ if (
68
+ typeof window === 'undefined' ||
69
+ typeof window.getComputedStyle !== 'function' ||
70
+ typeof node.closest !== 'function'
71
+ ) {
72
+ return 0;
73
+ }
74
+
75
+ const cell = node.closest('.InovuaReactDataGrid__cell');
76
+
77
+ if (!cell) {
78
+ return 0;
79
+ }
80
+
81
+ const cs = window.getComputedStyle(cell);
82
+ const sum =
83
+ (parseFloat(cs.paddingTop) || 0) +
84
+ (parseFloat(cs.paddingBottom) || 0) +
85
+ (parseFloat(cs.borderTopWidth) || 0) +
86
+ (parseFloat(cs.borderBottomWidth) || 0);
87
+
88
+ return Number.isFinite(sum) ? sum : 0;
89
+ };
90
+
91
+ /**
92
+ * @param {boolean} force report even when the height has not moved —
93
+ * used on mount and whenever the row under this cell changes,
94
+ * because the grid keys heights by row index and an unchanged
95
+ * height at a NEW index still has to be re-applied.
96
+ */
97
+ const report = (force) => {
98
+ if (!nodeRef.current) {
99
+ return;
100
+ }
101
+
102
+ const natural = nodeRef.current.scrollHeight || 0;
103
+
104
+ if (!natural) {
105
+ return;
106
+ }
107
+
108
+ const height = Math.ceil(natural + cellChrome());
109
+ const last = lastReportedRef.current;
110
+
111
+ if (!force && last !== null && Math.abs(last - height) <= 1) {
112
+ return;
113
+ }
114
+
115
+ lastReportedRef.current = height;
116
+ onMeasure(rowId, height, !!force);
117
+ };
118
+
119
+ report(true);
120
+
121
+ // No ResizeObserver (jsdom, older Safari): the mount measurement above
122
+ // still applies, the cell just will not follow later reflows.
123
+ if (typeof ResizeObserver === 'undefined') {
124
+ return undefined;
125
+ }
126
+
127
+ const observer = new ResizeObserver(() => report(false));
128
+
129
+ observer.observe(node);
130
+
131
+ return () => observer.disconnect();
132
+ }, [rowId, onMeasure]);
133
+
134
+ return (
135
+ <div ref={nodeRef} className="autoGrowCell" style={{ width: '100%' }}>
136
+ {children}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ export default AutoGrowCell;
@@ -1011,6 +1011,16 @@
1011
1011
  position: relative;
1012
1012
  }
1013
1013
 
1014
+ // The expand toggle and the group-action buttons as one block, so a
1015
+ // consuming stylesheet can give the controls a minimum width (lining the
1016
+ // label up with a column) while still letting a row with many buttons push
1017
+ // the label right instead of running underneath it.
1018
+ .groupControls {
1019
+ display: flex;
1020
+ align-items: center;
1021
+ flex: 0 0 auto;
1022
+ }
1023
+
1014
1024
  .groupHeaderCheckbox {
1015
1025
  width: 22px;
1016
1026
  height: 22px;
@@ -110,6 +110,35 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
110
110
  overflow: hidden !important;
111
111
  }
112
112
 
113
+ /* Auto-grow cells — a column with `"autoGrow": true`.
114
+
115
+ The opposite trade to the tablet word-wrap clamp above: instead of the cell
116
+ clamping its content to the row, the ROW grows to its content (DataGrid
117
+ measures it through AutoGrowCell and raises that one row via the grid's
118
+ RowHeightManager). So the content must be allowed to take a second and third
119
+ line — the nowrap/ellipsis rule that applies to every other non-wrapping
120
+ cell would otherwise make the content one line tall forever and there would
121
+ be nothing to grow for.
122
+
123
+ The CELL keeps `overflow: hidden`: between the frame the content grows in
124
+ and the frame the row grows in, the extra lines are clipped at the row
125
+ boundary rather than spilling over the row below, which is what the grid
126
+ looks like today. The inner wrapper stays `overflow: visible` so nothing
127
+ clips once the row has caught up, and `align-self: center` on the grid's own
128
+ content wrapper keeps the value vertically centred in the taller row, next
129
+ to the neighbouring cells and the action buttons. */
130
+ .InovuaReactDataGrid__cell.cell-auto-grow,
131
+ .InovuaReactDataGrid__cell.cell-auto-grow .InovuaReactDataGrid__cell__content,
132
+ .InovuaReactDataGrid__cell.cell-auto-grow .autoGrowCell {
133
+ white-space: normal !important;
134
+ text-overflow: clip !important;
135
+ }
136
+
137
+ .InovuaReactDataGrid__cell.cell-auto-grow .InovuaReactDataGrid__cell__content,
138
+ .InovuaReactDataGrid__cell.cell-auto-grow .autoGrowCell {
139
+ overflow: visible !important;
140
+ }
141
+
113
142
  /* NOTE: this fork renders header cells as .InovuaReactDataGrid__column-header
114
143
  (there is no __header-cell class in its DOM), so header padding is left to
115
144
  the module stylesheet; a __header-cell padding rule that used to sit here