@visns-studio/visns-components 6.5.4 → 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.
@@ -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
  };