@visns-studio/visns-components 6.5.5 → 6.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.
@@ -34,6 +34,10 @@
34
34
  chips. default: 4px
35
35
  --nav-dropdown-item-padding padding on a dropdown menu row.
36
36
  default: 8px 20px
37
+ --nav-logo-height height of the logo plate, and the same figure
38
+ everywhere the rail, the content header and the
39
+ mobile drawer offset themselves by it.
40
+ default: 50px
37
41
 
38
42
  Two deliberate omissions: the icon-to-label gap inside a nav item and the
39
43
  padding on the chips themselves are internal alignment, not density — the
@@ -711,7 +715,7 @@
711
715
  A project whose page background is the same colour can merge the
712
716
  plate into the rail instead: `--nav-logo-bg: var(--nav-bg)`. */
713
717
  background: var(--nav-logo-bg, rgba(var(--tertiary-color-rgb), 0.95));
714
- height: 50px;
718
+ height: var(--nav-logo-height, 50px);
715
719
  display: flex;
716
720
  flex-wrap: nowrap;
717
721
  justify-content: center;
@@ -727,7 +731,7 @@
727
731
  width: 100%;
728
732
  position: relative;
729
733
  overflow-y: auto; /* Enable vertical scrolling */
730
- max-height: calc(100vh - 50px); /* Subtract the logo height */
734
+ max-height: calc(100vh - var(--nav-logo-height, 50px)); /* Subtract the logo height */
731
735
  scrollbar-width: none; /* Firefox */
732
736
  -ms-overflow-style: none; /* IE and Edge */
733
737
 
@@ -1029,7 +1033,7 @@
1029
1033
  background: var(--tertiary-color);
1030
1034
  box-sizing: border-box;
1031
1035
  padding: 0.15rem;
1032
- height: 50px;
1036
+ height: var(--nav-logo-height, 50px);
1033
1037
  box-shadow: var(--box-shadow-small);
1034
1038
  display: flex;
1035
1039
  flex-wrap: nowrap;
@@ -1150,8 +1154,8 @@
1150
1154
  below it: start the drawer under the 50px header instead of
1151
1155
  letting the header paint over the drawer's top row. This also
1152
1156
  keeps the hamburger tappable while the drawer is open. */
1153
- top: 50px;
1154
- height: calc(100% - 50px);
1157
+ top: var(--nav-logo-height, 50px);
1158
+ height: calc(100% - var(--nav-logo-height, 50px));
1155
1159
  transform: translateX(-2000px);
1156
1160
  transition: all 0.55s cubic-bezier(0.25, 0.8, 0.25, 1);
1157
1161
  will-change: transform;
@@ -1214,13 +1218,13 @@
1214
1218
 
1215
1219
  .content--full {
1216
1220
  /* The 50px bar is still there below this width. */
1217
- min-height: calc(100vh - 50px);
1221
+ min-height: calc(100vh - var(--nav-logo-height, 50px));
1218
1222
  }
1219
1223
 
1220
1224
  .drawer-backdrop {
1221
1225
  display: block;
1222
1226
  position: fixed;
1223
- top: 50px;
1227
+ top: var(--nav-logo-height, 50px);
1224
1228
  left: 0;
1225
1229
  right: 0;
1226
1230
  bottom: 0;
@@ -17,7 +17,7 @@
17
17
  justify-content: center;
18
18
  gap: 0.4rem;
19
19
  width: max-content;
20
- min-height: 2.5rem;
20
+ min-height: var(--control-height, 2.5rem);
21
21
  padding: 0 var(--spacing-md);
22
22
  border: 1px solid transparent;
23
23
  border-radius: var(--radius-sm);
@@ -102,7 +102,7 @@
102
102
  }
103
103
 
104
104
  @mixin button-small {
105
- min-height: 2rem;
105
+ min-height: var(--control-height-sm, 2rem);
106
106
  padding: 0 var(--spacing-sm);
107
107
  font-size: var(--font-size-xs);
108
108
  }
@@ -111,8 +111,8 @@
111
111
 
112
112
  @mixin input-base {
113
113
  width: 100%;
114
- min-height: 2.5rem;
115
- padding: 0 0.65rem;
114
+ min-height: var(--control-height, 2.5rem);
115
+ padding: 0 var(--input-padding-x, 0.65rem);
116
116
  border: 1px solid var(--border-color);
117
117
  border-radius: var(--radius-sm);
118
118
  background: var(--paper);
@@ -0,0 +1,85 @@
1
+ /* ============================================================================
2
+ Display density
3
+ ----------------------------------------------------------------------------
4
+ One opt-in mode — `large` — for the projects that run on a shop-floor
5
+ monitor, a wall-mounted screen or in front of someone who would rather not
6
+ lean in. Everything in this file hangs off `:root[data-density='large']`,
7
+ and that attribute is only ever written by GenericAuth when the app passes
8
+ the `density` prop:
9
+
10
+ <GenericAuth density="large" ... />
11
+
12
+ No prop, no attribute, no rule in here matches — an app that never opts in
13
+ renders exactly as it always has. There is nothing to import, disable or
14
+ fork on that path.
15
+
16
+ The lever is rem, not a table of overrides. `font-size: 112.5%` on the root
17
+ takes the browser's 16px up to 18px, and every rem-based token, control,
18
+ gap and type size in the library follows on its own — that is most of the
19
+ mode, done once. The custom properties below only exist to catch what rem
20
+ cannot reach: the px-valued hooks the components expose (row heights, cell
21
+ padding, fixed chrome heights, pocket squares) and the page-width caps that
22
+ should simply stop capping when the screen is being used as a big screen.
23
+
24
+ Overriding a value
25
+ ------------------
26
+ Apps override any of these from their own global stylesheet with the more
27
+ specific selector:
28
+
29
+ html:root[data-density='large'] {
30
+ --control-height: 3rem;
31
+ --grid-cell-padding: 12px 14px;
32
+ }
33
+
34
+ `html:root[...]` outweighs the plain `:root[...]` used here on specificity
35
+ alone, so it wins no matter which stylesheet the bundler emits first — no
36
+ `!important`, no import-order archaeology.
37
+
38
+ The root font-size has its own escape hatch so an app can dial the whole
39
+ mode up or down without restating a single hook:
40
+
41
+ html:root[data-density='large'] { --density-root-font-size: 125%; }
42
+
43
+ Anything the app sets on plain `:root` stays the shared value for both
44
+ densities — set it here only when `large` should differ.
45
+ ========================================================================= */
46
+
47
+ :root[data-density='large'] {
48
+ font-size: var(--density-root-font-size, 112.5%);
49
+
50
+ /* Page frame — a large display is asked for width, so stop capping it. */
51
+ --page-max-width: none;
52
+ --page-gutter-x: clamp(0.5rem, 1vw, 1rem);
53
+ --page-gutter-y: 0.75rem;
54
+ --page-gutter-narrow: 0.5rem;
55
+
56
+ /* Controls */
57
+ --control-height: 2.75rem;
58
+ --control-height-sm: 2.25rem;
59
+
60
+ /* Data grid — px, because the grid measures and lays out in px. */
61
+ --grid-toolbar-height: 46px;
62
+ --grid-cell-padding: 9px 10px;
63
+ --grid-checkbox-col-width: 72px;
64
+
65
+ /* Fixed chrome */
66
+ --content-header-height: 60px;
67
+ --nav-logo-height: 56px;
68
+
69
+ /* Field pockets (the square icon/affix tiles beside an input) */
70
+ --field-pocket-size: 44px;
71
+ --field-pocket-size-sm: 34px;
72
+ --field-suggestion-font-size: 16px;
73
+
74
+ /* Detail pages */
75
+ --detail-max-width: 1600px;
76
+ }
77
+
78
+ /* The row-select checkbox column, one step wider again on a tablet view —
79
+ the row is 60px tall there, so the tap zone should be square-ish. The JS
80
+ side of this number lives in utils/useDensity.js (checkboxColWidth); the
81
+ two must agree, since the grid lays the column out from the JS value and
82
+ these custom properties only hold the cell against Inovua's inline width. */
83
+ :root[data-density='large'] body.tablet-mode {
84
+ --grid-checkbox-col-width: 80px;
85
+ }
@@ -45,10 +45,14 @@ div[role="tooltip"],
45
45
  border-radius: 0 !important;
46
46
  }
47
47
 
48
- /* Ensure consistent border radius for cells and reduce padding for compact rows */
49
- .InovuaReactDataGrid__cell {
48
+ /* Ensure consistent border radius for cells and reduce padding for compact rows.
49
+ :not(--no-padding): the grid zeroes padding inline on its spacer/expand
50
+ cells (group nesting columns) and sizes them width:0 when unused — an
51
+ unconditional !important here re-inflates those to pure padding and
52
+ shifts group rows out of line with the headers. */
53
+ .InovuaReactDataGrid__cell:not(.InovuaReactDataGrid__cell--no-padding) {
50
54
  border-radius: 0 !important;
51
- padding: 6px 8px !important; /* Reduced from default padding */
55
+ padding: var(--grid-cell-padding, 6px 8px) !important; /* Reduced from default padding */
52
56
  }
53
57
 
54
58
  /* Word wrap enabled cells - specific class for columns with wordWrap: true */
@@ -84,10 +88,13 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap span {
84
88
  max-height: 100% !important;
85
89
  }
86
90
 
87
- body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap {
91
+ body.tablet-mode
92
+ .InovuaReactDataGrid__cell.cell-word-wrap:not(
93
+ .InovuaReactDataGrid__cell--no-padding
94
+ ) {
88
95
  height: 100% !important;
89
96
  align-items: center !important;
90
- padding: 6px 8px !important;
97
+ padding: var(--grid-cell-padding, 6px 8px) !important;
91
98
  }
92
99
 
93
100
  body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
@@ -103,10 +110,10 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
103
110
  overflow: hidden !important;
104
111
  }
105
112
 
106
- /* Reduce header cell padding as well */
107
- .InovuaReactDataGrid__header-cell {
108
- padding: 6px 8px !important;
109
- }
113
+ /* NOTE: this fork renders header cells as .InovuaReactDataGrid__column-header
114
+ (there is no __header-cell class in its DOM), so header padding is left to
115
+ the module stylesheet; a __header-cell padding rule that used to sit here
116
+ was dead code and has been removed. */
110
117
 
111
118
  /* Fix for focus outline */
112
119
  .InovuaReactDataGrid:focus,
@@ -115,173 +122,49 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
115
122
  box-shadow: 0 0 0 2px rgba(var(--primary-rgb, 59, 130, 246), 0.2) !important;
116
123
  }
117
124
 
118
- /* Checkbox column improvements for touch devices - Compact version */
119
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column'] {
120
- padding: 6px 8px !important; /* Reduced padding for compact rows */
121
- min-width: 60px !important;
122
- width: 60px !important;
123
- max-width: 60px !important;
124
- position: relative !important;
125
- cursor: pointer !important;
126
- display: flex !important;
127
- align-items: center !important;
128
- justify-content: center !important;
129
- /* Ensure the entire cell is clickable */
130
- user-select: none !important;
131
- -webkit-user-select: none !important;
132
- -moz-user-select: none !important;
133
- -ms-user-select: none !important;
134
- }
135
-
136
- /* Improve checkbox touch targets */
137
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
138
- input[type='checkbox'] {
139
- width: 20px !important;
140
- height: 20px !important;
141
- margin: 0 !important;
142
- cursor: pointer !important;
143
- position: relative !important;
144
- z-index: 2 !important;
145
- }
146
-
147
- /* Create larger touch target area around checkbox */
148
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
149
- .InovuaReactDataGrid__checkbox-wrapper,
150
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
125
+ /* Checkbox column.
126
+
127
+ The rules that used to live here keyed every declaration on
128
+ [data-column-id='__checkbox-column'] — an attribute this datagrid fork has
129
+ never rendered — so none of them ever matched: not the 60px width pins,
130
+ not the 44px touch wrappers. Discovered Aug 2026 when the column collapsed
131
+ to the grid's internal 40px default with no CSS backstop to catch it.
132
+
133
+ The column's width is owned by the JS side (checkboxColumnProps in
134
+ DataGrid.jsx, fed from utils/useDensity.js checkboxColWidth); CSS cannot
135
+ safely fight the grid's layout engine on width. What CSS can usefully add
136
+ is glyph size and cursor, on the classes that actually exist:
137
+ .InovuaReactDataGrid__checkbox is the toolkit checkbox root in body cells
138
+ (it draws a 16px svg with a proper viewBox, so CSS sizing scales it
139
+ cleanly), and the header cell holds a native input rendered by
140
+ DataGrid.jsx. Gated on large density; default-density apps keep the
141
+ rendering they have always had. */
142
+ :root[data-density='large']
143
+ .InovuaReactDataGrid__cell
151
144
  .InovuaReactDataGrid__checkbox {
152
- display: flex !important;
153
- align-items: center !important;
154
- justify-content: center !important;
155
- min-height: 44px !important;
156
- min-width: 44px !important;
157
- padding: 12px !important;
158
- margin: -12px !important;
159
- cursor: pointer !important;
160
- border-radius: 4px !important;
161
- transition: background-color 0.2s ease !important;
162
- }
163
-
164
- /* Touch-friendly hover effect for the entire cell */
165
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']:hover {
166
- background-color: rgba(var(--primary-rgb, 59, 130, 246), 0.05) !important;
167
- }
168
-
169
- /* Touch-friendly hover effect for checkbox wrapper */
170
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
171
- .InovuaReactDataGrid__checkbox-wrapper:hover,
172
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
173
- .InovuaReactDataGrid__checkbox:hover {
174
- background-color: rgba(var(--primary-rgb, 59, 130, 246), 0.1) !important;
145
+ cursor: pointer;
175
146
  }
176
147
 
177
- /* Header checkbox improvements - Compact version */
178
- .InovuaReactDataGrid__header-cell[data-column-id='__checkbox-column'] {
179
- padding: 6px 8px !important; /* Reduced padding for compact rows */
180
- min-width: 60px !important;
181
- width: 60px !important;
182
- max-width: 60px !important;
148
+ :root[data-density='large']
149
+ .InovuaReactDataGrid__cell
150
+ .InovuaReactDataGrid__checkbox svg {
151
+ width: 22px;
152
+ height: 22px;
183
153
  }
184
154
 
185
- .InovuaReactDataGrid__header-cell[data-column-id='__checkbox-column']
155
+ /* !important because global.css draws every non-react-toggle checkbox at a
156
+ fixed 14px !important — an inline style cannot beat that, and neither can
157
+ this rule without its own !important. The header cell in this fork is
158
+ .InovuaReactDataGrid__column-header (never __header-cell). */
159
+ :root[data-density='large']
160
+ .InovuaReactDataGrid__column-header
186
161
  input[type='checkbox'] {
187
- width: 20px !important;
188
- height: 20px !important;
189
- margin: 0 !important;
190
- cursor: pointer !important;
191
- }
192
-
193
- /* Prevent text selection in checkbox column */
194
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column'] {
195
- user-select: none !important;
196
- -webkit-user-select: none !important;
197
- -moz-user-select: none !important;
198
- -ms-user-select: none !important;
162
+ width: 22px !important;
163
+ height: 22px !important;
164
+ cursor: pointer;
199
165
  }
200
166
 
201
- /* Additional checkbox styling for better UX */
202
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
203
- .InovuaReactDataGrid__cell-content {
204
- display: flex !important;
205
- align-items: center !important;
206
- justify-content: center !important;
207
- width: 100% !important;
208
- height: 100% !important;
209
- }
210
167
 
211
- /* Focus styles for accessibility */
212
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
213
- input[type='checkbox']:focus {
214
- outline: 2px solid rgba(var(--primary-rgb, 59, 130, 246), 0.5) !important;
215
- outline-offset: 2px !important;
216
- }
217
-
218
- /* Ensure checkbox column doesn't interfere with row hover */
219
- .InovuaReactDataGrid__row:hover
220
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column'] {
221
- background-color: inherit !important;
222
- }
223
-
224
- /* Mobile-specific improvements - Compact version */
225
- @media (max-width: 768px) {
226
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column'] {
227
- min-width: 70px !important;
228
- width: 70px !important;
229
- max-width: 70px !important;
230
- padding: 8px 12px !important; /* Reduced padding for compact rows */
231
- }
232
-
233
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
234
- input[type='checkbox'] {
235
- width: 24px !important;
236
- height: 24px !important;
237
- }
238
-
239
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
240
- .InovuaReactDataGrid__checkbox-wrapper,
241
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
242
- .InovuaReactDataGrid__checkbox {
243
- min-height: 48px !important;
244
- min-width: 48px !important;
245
- padding: 16px !important;
246
- margin: -16px !important;
247
- }
248
-
249
- .InovuaReactDataGrid__header-cell[data-column-id='__checkbox-column'] {
250
- min-width: 70px !important;
251
- width: 70px !important;
252
- max-width: 70px !important;
253
- padding: 8px 12px !important; /* Reduced padding for compact rows */
254
- }
255
-
256
- .InovuaReactDataGrid__header-cell[data-column-id='__checkbox-column']
257
- input[type='checkbox'] {
258
- width: 24px !important;
259
- height: 24px !important;
260
- }
261
- }
262
-
263
- /* Touch device specific improvements - Compact version */
264
- @media (hover: none) and (pointer: coarse) {
265
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column'] {
266
- padding: 12px !important; /* Reduced padding for compact rows */
267
- }
268
-
269
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
270
- input[type='checkbox'] {
271
- width: 28px !important;
272
- height: 28px !important;
273
- }
274
-
275
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
276
- .InovuaReactDataGrid__checkbox-wrapper,
277
- .InovuaReactDataGrid__cell[data-column-id='__checkbox-column']
278
- .InovuaReactDataGrid__checkbox {
279
- min-height: 52px !important;
280
- min-width: 52px !important;
281
- padding: 18px !important;
282
- margin: -18px !important;
283
- }
284
- }
285
168
 
286
169
  /* Group expand/collapse icon fixes - Enhanced for better visibility */
287
170
  .InovuaReactDataGrid__group-expand-icon,
@@ -0,0 +1,225 @@
1
+ import { useMemo, useState, useEffect, useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * Display density, in JS.
5
+ *
6
+ * Two independent switches decide how roomy the UI is, and both live on the
7
+ * document rather than in React state, because both are set by whoever owns
8
+ * the page rather than by the component reading them:
9
+ *
10
+ * - `data-density` on `<html>` — the app-wide opt-in, written by GenericAuth
11
+ * from its `density` prop (see `styles/density.css` for the CSS half).
12
+ * - `tablet-mode` on `<body>` — the per-view opt-in, written by GenericIndex
13
+ * when a view config sets `"tabletMode": true`.
14
+ *
15
+ * They compose: a tablet view inside a large-density app is the roomiest of
16
+ * the four combinations. CSS reads both selectors directly; anything that has
17
+ * to lay out in JS — the datagrid measures and sizes rows in px — reads them
18
+ * through `useDensity()` so there is one table of numbers instead of a
19
+ * `isTabletMode ? 52 : 32` at every call site.
20
+ */
21
+
22
+ /**
23
+ * The px geometry the datagrid cannot express in CSS.
24
+ *
25
+ * `default.desktop` / `default.tablet` are the values the grid has always
26
+ * used and must not drift — `tests/density.test.mjs` pins them.
27
+ *
28
+ * rowHeight fixed row height, or null to let the grid measure rows.
29
+ * minRowHeight floor applied to measured rows.
30
+ * rowUnit the height one row actually occupies, used to work out how
31
+ * many rows fit a page.
32
+ * columnMinWidth floor for a column with no explicit `minWidth`.
33
+ * actionColUnit width one action icon needs inside an action column.
34
+ * checkboxColWidth fixed width of the row-select checkbox column.
35
+ */
36
+ export const DENSITY_GRID = {
37
+ default: {
38
+ desktop: {
39
+ rowHeight: null,
40
+ minRowHeight: 32,
41
+ rowUnit: 32,
42
+ columnMinWidth: 80,
43
+ actionColUnit: 34,
44
+ checkboxColWidth: 60,
45
+ },
46
+ tablet: {
47
+ rowHeight: 52,
48
+ minRowHeight: 52,
49
+ rowUnit: 52,
50
+ columnMinWidth: 80,
51
+ actionColUnit: 44,
52
+ checkboxColWidth: 60,
53
+ },
54
+ },
55
+ large: {
56
+ desktop: {
57
+ rowHeight: null,
58
+ minRowHeight: 40,
59
+ rowUnit: 40,
60
+ columnMinWidth: 96,
61
+ actionColUnit: 44,
62
+ checkboxColWidth: 72,
63
+ },
64
+ tablet: {
65
+ rowHeight: 60,
66
+ minRowHeight: 60,
67
+ rowUnit: 60,
68
+ columnMinWidth: 96,
69
+ actionColUnit: 52,
70
+ checkboxColWidth: 80,
71
+ },
72
+ },
73
+ };
74
+
75
+ export const DEFAULT_DENSITY = 'default';
76
+
77
+ const SERVER_SNAPSHOT = `${DEFAULT_DENSITY}|false`;
78
+
79
+ /**
80
+ * Read both switches off the document as one string. A string (not an object)
81
+ * so `useSyncExternalStore` can compare snapshots by value — returning a fresh
82
+ * object from `getSnapshot` makes React re-render forever.
83
+ */
84
+ const readSnapshot = () => {
85
+ if (typeof document === 'undefined') {
86
+ return SERVER_SNAPSHOT;
87
+ }
88
+
89
+ const density =
90
+ document.documentElement?.getAttribute('data-density') ||
91
+ DEFAULT_DENSITY;
92
+ const isTablet = Boolean(
93
+ document.body && document.body.classList.contains('tablet-mode')
94
+ );
95
+
96
+ return `${density}|${isTablet}`;
97
+ };
98
+
99
+ const getServerSnapshot = () => SERVER_SNAPSHOT;
100
+
101
+ /**
102
+ * Notify on either switch changing. Two observers rather than one on the
103
+ * document, because we only care about one attribute on each of two nodes and
104
+ * a subtree observer would fire on every class change anywhere in the app.
105
+ */
106
+ const subscribe = (onChange) => {
107
+ if (
108
+ typeof document === 'undefined' ||
109
+ typeof MutationObserver === 'undefined'
110
+ ) {
111
+ return () => {};
112
+ }
113
+
114
+ const observers = [];
115
+
116
+ if (document.documentElement) {
117
+ const rootObserver = new MutationObserver(onChange);
118
+ rootObserver.observe(document.documentElement, {
119
+ attributes: true,
120
+ attributeFilter: ['data-density'],
121
+ });
122
+ observers.push(rootObserver);
123
+ }
124
+
125
+ if (document.body) {
126
+ const bodyObserver = new MutationObserver(onChange);
127
+ bodyObserver.observe(document.body, {
128
+ attributes: true,
129
+ attributeFilter: ['class'],
130
+ });
131
+ observers.push(bodyObserver);
132
+ }
133
+
134
+ return () => observers.forEach((observer) => observer.disconnect());
135
+ };
136
+
137
+ /**
138
+ * Resolve a snapshot string into the shape callers want.
139
+ *
140
+ * @param {string} snapshot `"<density>|<isTabletMode>"`
141
+ * @returns {{density: string, isLarge: boolean, isTabletMode: boolean, grid: object}}
142
+ */
143
+ export const parseDensitySnapshot = (snapshot) => {
144
+ const [rawDensity, rawTablet] = String(snapshot).split('|');
145
+ // An unrecognised attribute value styles as nothing in CSS, so it must
146
+ // measure as nothing here too.
147
+ const density = DENSITY_GRID[rawDensity] ? rawDensity : DEFAULT_DENSITY;
148
+ const isTabletMode = rawTablet === 'true';
149
+
150
+ return {
151
+ density,
152
+ isLarge: density === 'large',
153
+ isTabletMode,
154
+ grid: DENSITY_GRID[density][isTabletMode ? 'tablet' : 'desktop'],
155
+ };
156
+ };
157
+
158
+ /**
159
+ * @returns {{density: string, isLarge: boolean, isTabletMode: boolean, grid: object}}
160
+ * `grid` is a stable reference out of `DENSITY_GRID`, so it is safe
161
+ * in a dependency array.
162
+ */
163
+ export const useDensity = () => {
164
+ const snapshot = useSyncExternalStore(
165
+ subscribe,
166
+ readSnapshot,
167
+ getServerSnapshot
168
+ );
169
+
170
+ return useMemo(() => parseDensitySnapshot(snapshot), [snapshot]);
171
+ };
172
+
173
+ /**
174
+ * Height left on screen below `ref`'s element, for panes that should fill the
175
+ * window instead of taking a fraction of it.
176
+ *
177
+ * Returns `null` until it can measure — no element, no window, or a
178
+ * measurement that comes out non-positive (the element is display:none, or
179
+ * scrolled out of view) — so callers can keep their own fallback rather than
180
+ * flashing a zero-height pane.
181
+ *
182
+ * @param {{current: HTMLElement|null}} ref wrapper to measure from.
183
+ * @param {object} [options]
184
+ * @param {boolean} [options.enabled=true] false parks the hook at null.
185
+ * @param {number} [options.bottomGutter=16] px left below the element.
186
+ * @param {*} [options.recomputeKey] change it to force a re-measure
187
+ * (e.g. the app's window height).
188
+ * @returns {number|null}
189
+ */
190
+ export const useAvailableHeight = (
191
+ ref,
192
+ { enabled = true, bottomGutter = 16, recomputeKey } = {}
193
+ ) => {
194
+ const [height, setHeight] = useState(null);
195
+
196
+ useEffect(() => {
197
+ if (!enabled || typeof window === 'undefined') {
198
+ setHeight(null);
199
+ return undefined;
200
+ }
201
+
202
+ const measure = () => {
203
+ const node = ref?.current;
204
+
205
+ if (!node || typeof node.getBoundingClientRect !== 'function') {
206
+ setHeight(null);
207
+ return;
208
+ }
209
+
210
+ const { top } = node.getBoundingClientRect();
211
+ const next = window.innerHeight - top - bottomGutter;
212
+
213
+ setHeight(Number.isFinite(next) && next > 0 ? next : null);
214
+ };
215
+
216
+ measure();
217
+ window.addEventListener('resize', measure);
218
+
219
+ return () => window.removeEventListener('resize', measure);
220
+ }, [ref, enabled, bottomGutter, recomputeKey]);
221
+
222
+ return height;
223
+ };
224
+
225
+ export default useDensity;
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import SortableList from './components/sorting/List';
7
7
  /** Utility Components */
8
8
  import { confirmDialog } from './components/utils/ConfirmDialog';
9
9
  import { showConfirmDialog } from './components/generic/ConfirmationDialog';
10
+ import { useDensity, useAvailableHeight, DENSITY_GRID } from './components/utils/useDensity';
10
11
 
11
12
  /** CRM Components */
12
13
  import AsyncSelect from './components/AsyncSelect';
@@ -121,6 +122,7 @@ export {
121
122
  DataGrid,
122
123
  DataGridSearch,
123
124
  DatePickerPortal,
125
+ DENSITY_GRID,
124
126
  Download,
125
127
  DropZone,
126
128
  CategorizedDropZone,
@@ -173,6 +175,8 @@ export {
173
175
  StandardModal,
174
176
  Table,
175
177
  TableFilter,
178
+ useAvailableHeight,
179
+ useDensity,
176
180
  VariableInserter,
177
181
  Verify,
178
182
  };