@visns-studio/visns-components 6.24.4 → 6.25.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.
Files changed (43) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/columns/ColumnRenderers.jsx +3 -46
  8. package/src/components/columns/StackedRow.jsx +186 -0
  9. package/src/components/controls/DataGridSearch.jsx +110 -2
  10. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  11. package/src/components/generic/GenericAuth.jsx +50 -18
  12. package/src/components/generic/GenericDashboard.jsx +20 -1
  13. package/src/components/generic/GenericDetail.jsx +446 -259
  14. package/src/components/mapboxSearchBox.js +640 -0
  15. package/src/components/navBadges.js +63 -1
  16. package/src/components/navDrawer.js +147 -0
  17. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  18. package/src/components/sms/smsHelpers.js +15 -0
  19. package/src/components/styles/ClientAuth.module.scss +39 -0
  20. package/src/components/styles/DataGrid.module.scss +158 -5
  21. package/src/components/styles/Field.module.scss +52 -1
  22. package/src/components/styles/Form.module.scss +82 -0
  23. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  24. package/src/components/styles/GenericDashboard.module.scss +50 -0
  25. package/src/components/styles/GenericDetail.module.scss +63 -1
  26. package/src/components/styles/GenericDynamic.module.scss +23 -0
  27. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  28. package/src/components/styles/GenericIndex.module.scss +6 -1
  29. package/src/components/styles/Navigation.module.scss +460 -7
  30. package/src/components/styles/Sms.module.scss +92 -0
  31. package/src/components/styles/StackedRow.module.scss +182 -0
  32. package/src/components/styles/TicketConversation.module.scss +76 -0
  33. package/src/components/styles/Vault.module.scss +192 -0
  34. package/src/components/styles/density.css +10 -0
  35. package/src/components/styles/global-datagrid.css +163 -0
  36. package/src/components/styles/global.css +20 -0
  37. package/src/components/tickets/TicketConversation.jsx +13 -8
  38. package/src/components/utils/ConfirmDialog.js +22 -3
  39. package/src/components/utils/cardLayout.js +666 -0
  40. package/src/components/utils/contactChannels.js +130 -0
  41. package/src/components/utils/editPlacement.js +95 -0
  42. package/src/components/utils/useDensity.js +303 -7
  43. package/src/index.js +32 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Phone and email formatting, and the hrefs that make them dialable.
3
+ *
4
+ * No React and no CSS import, for the same reason `utils/displayValue.js` has
5
+ * neither: the rules encoded here are testable under `node --test` rather than
6
+ * inferred from a rendered tree, and every consumer of a `.jsx` in this repo
7
+ * has to bring a bundler with it.
8
+ *
9
+ * `formatPhoneNumber` LIVED IN `columns/ColumnRenderers.jsx` and is moved here
10
+ * unchanged. It has two callers now — the phone column renderer, which formats
11
+ * a number for display, and the card row, which needs the same string beside a
12
+ * `tel:` href. Two copies of an Australian numbering table is how one screen
13
+ * starts spelling a mobile differently from the next.
14
+ */
15
+
16
+ /**
17
+ * An Australian phone number, spaced the way the country writes it.
18
+ *
19
+ * Anything that does not answer to a known pattern is returned VERBATIM rather
20
+ * than forced into one — an international number, an extension, a note somebody
21
+ * typed into the field. Reformatting a string we did not recognise is how a
22
+ * number becomes undialable.
23
+ *
24
+ * @param {*} phoneNumber raw field value
25
+ * @returns {string} formatted number, or `''` for a blank input
26
+ */
27
+ export const formatPhoneNumber = (phoneNumber) => {
28
+ if (!phoneNumber) return '';
29
+
30
+ // Remove all non-digit characters. `String()` is the ONE change made in the
31
+ // move out of ColumnRenderers: the column renderer hands this whatever the
32
+ // API put in the field, and a numeric phone column used to throw here. It
33
+ // can only turn a crash into a result — every string input takes the same
34
+ // branch it always did.
35
+ let cleanNumber = String(phoneNumber).replace(/\D/g, '');
36
+
37
+ // +61 spellings collapse to the local form so every branch below applies.
38
+ if (cleanNumber.match(/^61[2-9]\d{8}$/)) {
39
+ cleanNumber = '0' + cleanNumber.slice(2);
40
+ }
41
+
42
+ // Check if it's an Australian mobile number (starts with 04 and has 10 digits)
43
+ if (cleanNumber.match(/^04\d{8}$/)) {
44
+ // Mobile format: 0400 000 000
45
+ return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
46
+ }
47
+
48
+ // Check if it's a 1300/1800 number (10 digits starting with 1300 or 1800)
49
+ if (cleanNumber.match(/^1[38]00\d{6}$/)) {
50
+ // 1300/1800 format: 1300 000 000
51
+ return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
52
+ }
53
+
54
+ // Check if it's an Australian landline number (8 digits with area code, or 10 digits total)
55
+ if (cleanNumber.match(/^0[2-9]\d{8}$/)) {
56
+ // Landline format: (02) 0000 0000
57
+ return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
58
+ }
59
+
60
+ // If it doesn't match standard Australian patterns, return as is with some basic formatting
61
+ if (cleanNumber.length >= 8) {
62
+ // Generic formatting for longer numbers
63
+ if (cleanNumber.length === 8) {
64
+ return cleanNumber.replace(/(\d{4})(\d{4})/, '$1 $2');
65
+ } else if (cleanNumber.length === 9) {
66
+ return cleanNumber.replace(/(\d{1})(\d{4})(\d{4})/, '$1 $2 $3');
67
+ } else if (cleanNumber.length === 10) {
68
+ return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
69
+ }
70
+ }
71
+
72
+ // Return original if no formatting pattern matches
73
+ return phoneNumber;
74
+ };
75
+
76
+ /**
77
+ * The `tel:` target for a stored number.
78
+ *
79
+ * DELIBERATELY NOT `formatPhoneNumber`'s output: the spaces and brackets that
80
+ * make a number readable are not part of a dial string, and a leading `+` is
81
+ * the one non-digit that is — dropping it turns an international number into a
82
+ * local one that reaches somebody else entirely.
83
+ *
84
+ * Returns `null` when there is nothing to dial, so the caller renders plain
85
+ * text rather than a dead anchor.
86
+ *
87
+ * @param {*} value raw field value
88
+ * @returns {string|null}
89
+ */
90
+ export const telHref = (value) => {
91
+ if (value === null || value === undefined) {
92
+ return null;
93
+ }
94
+
95
+ const raw = String(value).trim();
96
+
97
+ if (raw === '') {
98
+ return null;
99
+ }
100
+
101
+ const digits = raw.replace(/[^\d+]/g, '');
102
+ // A `+` anywhere but the front is noise from a pasted string.
103
+ const normalised = digits.startsWith('+')
104
+ ? '+' + digits.slice(1).replace(/\+/g, '')
105
+ : digits.replace(/\+/g, '');
106
+
107
+ return normalised.replace(/\D/g, '').length >= 4 ? `tel:${normalised}` : null;
108
+ };
109
+
110
+ /**
111
+ * The `mailto:` target for a stored address.
112
+ *
113
+ * The bar is deliberately low — one `@` with something either side. This is a
114
+ * display decision, not validation: the address came out of the database and
115
+ * refusing to link it does not make it any more correct, while linking
116
+ * something that is plainly not an address (a note, a dash) draws a dead
117
+ * anchor.
118
+ *
119
+ * @param {*} value raw field value
120
+ * @returns {string|null}
121
+ */
122
+ export const mailtoHref = (value) => {
123
+ if (value === null || value === undefined) {
124
+ return null;
125
+ }
126
+
127
+ const raw = String(value).trim();
128
+
129
+ return /^[^\s@]+@[^\s@]+$/.test(raw) ? `mailto:${raw}` : null;
130
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Where a detail page's action cluster sits — the Edit button and the Save /
3
+ * Cancel pair that replace it, plus a table tab's bulk actions.
4
+ *
5
+ * No React and no CSS import, on the `utils/cardLayout.js` precedent: the only
6
+ * *decision* in the change is which of two placements a view config asked for,
7
+ * it is read by every detail page in every application, and it is testable
8
+ * under `node --test` rather than by squinting at a rendered tree.
9
+ *
10
+ * ---------------------------------------------------------------------------
11
+ * WHY THERE IS A CHOICE AT ALL
12
+ *
13
+ * The cluster has always been `position: fixed; bottom: 15px; right: 20px`, and
14
+ * every detail page in every VISNS CRM is laid out around that. It is the wrong
15
+ * place twice over and right once:
16
+ *
17
+ * - on a SHORT record the button is stranded in the bottom-right corner of an
18
+ * empty viewport, a screen away from the six fields it edits;
19
+ * - on a LONG one it floats over the content, and on a phone it lands on top
20
+ * of the row actions of whatever grid is under it;
21
+ * - but on a page that is one tall form, a control that follows the reader
22
+ * down is exactly right, and there are a lot of those.
23
+ *
24
+ * So this is not a fix, it is a placement, and the default does not move. A
25
+ * host that says nothing gets the floating bar it has always had, byte for
26
+ * byte; `header` is opt-in per view config.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * WHY THE CONFIG IS THE PRIMARY INPUT AND THE PROP ONLY OVERRIDES IT
30
+ *
31
+ * The consuming CRMs drive their detail pages from JSON view configs, and most
32
+ * of those are mounted by a bare `<GenericDetail setting={SomeDetail} />` with
33
+ * no host JSX to edit — several through a generic route table that has never
34
+ * heard of the page it is rendering. A prop-only switch would mean a JSX change
35
+ * per page to move a button, which is how a layout choice ends up applied to
36
+ * three pages out of thirty and nobody can say why.
37
+ *
38
+ * The prop still wins where it is given, because a host that has wrapped
39
+ * GenericDetail for one page (a facts band, a custom tab) is the one caller
40
+ * that knows something the config does not.
41
+ *
42
+ * An unknown value is NOT an error and must not become one: a config written
43
+ * against a later version of this library, or a typo, has to render the page
44
+ * rather than take it down over a button's coordinates. Unknown falls back to
45
+ * the default, which is also what every existing config means.
46
+ */
47
+
48
+ /** The floating bottom-right bar. Today's behaviour, and the default. */
49
+ export const EDIT_PLACEMENT_FLOATING = 'floating';
50
+
51
+ /** An inline cluster on the right of the record's title row. */
52
+ export const EDIT_PLACEMENT_HEADER = 'header';
53
+
54
+ /** Every placement this library understands. */
55
+ export const EDIT_PLACEMENTS = [
56
+ EDIT_PLACEMENT_FLOATING,
57
+ EDIT_PLACEMENT_HEADER,
58
+ ];
59
+
60
+ /**
61
+ * Resolve the placement for one detail page.
62
+ *
63
+ * @param {object} [setting] the view config GenericDetail was handed — read
64
+ * as `setting.editPlacement`, deliberately at the
65
+ * config ROOT beside `page` / `filters` / `tabs`
66
+ * rather than per tab: it is a statement about the
67
+ * page's chrome, and a button that moved as you
68
+ * changed tabs would read as a rendering fault.
69
+ * @param {string} [override] the `editPlacement` prop, when a host passed one.
70
+ * @returns {'floating'|'header'}
71
+ */
72
+ export function resolveEditPlacement(setting, override) {
73
+ if (EDIT_PLACEMENTS.includes(override)) {
74
+ return override;
75
+ }
76
+
77
+ const fromConfig = setting?.editPlacement;
78
+
79
+ if (EDIT_PLACEMENTS.includes(fromConfig)) {
80
+ return fromConfig;
81
+ }
82
+
83
+ return EDIT_PLACEMENT_FLOATING;
84
+ }
85
+
86
+ /**
87
+ * Whether the resolved placement is the header one.
88
+ *
89
+ * A one-line convenience so the render reads as a question about the page
90
+ * rather than a string comparison repeated at four call sites — the shape that
91
+ * eventually gets one of them wrong.
92
+ */
93
+ export function isHeaderPlacement(placement) {
94
+ return placement === EDIT_PLACEMENT_HEADER;
95
+ }
@@ -17,6 +17,28 @@ import { useMemo, useState, useEffect, useSyncExternalStore } from 'react';
17
17
  * to lay out in JS — the datagrid measures and sizes rows in px — reads them
18
18
  * through `useDensity()` so there is one table of numbers instead of a
19
19
  * `isTabletMode ? 52 : 32` at every call site.
20
+ *
21
+ * ---------------------------------------------------------------------------
22
+ * AND A THIRD SWITCH NOBODY HAS TO SET: `(pointer: coarse)`.
23
+ *
24
+ * Tablet mode was per-view opt-in only, which meant the 44px tap targets and
25
+ * the 52px rows existed on precisely the handful of views whose JSON happened
26
+ * to carry `"tabletMode": true` — and nowhere else, including on every phone
27
+ * and tablet opening every other grid in the product. A 32px row with a 28px
28
+ * icon button in it is not a target; the finger covers it and the tap lands on
29
+ * the row underneath.
30
+ *
31
+ * So a coarse primary pointer now counts as tablet mode on its own. `pointer`
32
+ * reports the PRIMARY pointing device, not "is touch available anywhere": a
33
+ * laptop with a touchscreen and a trackpad answers `fine` and is unaffected,
34
+ * which is the reason this is safe to turn on for everybody. A phone, a
35
+ * tablet and a kiosk answer `coarse` and get the roomy geometry without a line
36
+ * of configuration.
37
+ *
38
+ * The explicit switch still works and still means the same thing — it is now
39
+ * "treat this view as touch even on a mouse", which is exactly what the plant
40
+ * floor asked for when a gloved hand is driving a desktop. The two are ORed,
41
+ * never checked against each other.
20
42
  */
21
43
 
22
44
  /**
@@ -77,9 +99,209 @@ export const DEFAULT_DENSITY = 'default';
77
99
  const SERVER_SNAPSHOT = `${DEFAULT_DENSITY}|false`;
78
100
 
79
101
  /**
80
- * Read both switches off the document as one string. A string (not an object)
102
+ * The media query that stands in for "this is being driven by a finger".
103
+ *
104
+ * Exported so the CSS side can be grepped against one string rather than four
105
+ * hand-typed copies drifting apart — `styles/DataGrid.module.scss`,
106
+ * `styles/global-datagrid.css` and `styles/density.css` all carry this exact
107
+ * condition beside their `body.tablet-mode` selectors.
108
+ */
109
+ export const COARSE_POINTER_QUERY = '(pointer: coarse)';
110
+
111
+ /**
112
+ * The width below which a grid stops trying to fit its columns on screen.
113
+ *
114
+ * DELIBERATELY NOT PART OF `DENSITY_GRID`, and not folded into the density
115
+ * snapshot either. Density answers "how big does a target have to be"; this
116
+ * answers "is there enough width to lay seven columns out side by side". They
117
+ * are different questions with different answers — a phone is coarse AND
118
+ * narrow, a kiosk is coarse and 1920px wide, a desktop browser dragged to a
119
+ * third of the screen is narrow and fine — and `tests/density.test.mjs` pins
120
+ * both the density table and the parsed snapshot shape literally, on the
121
+ * reasoning that those numbers are every existing deployment's layout.
122
+ *
123
+ * Exported so the CSS half can be grepped against one string rather than
124
+ * hand-typed copies drifting apart — `styles/global-datagrid.css` carries this
125
+ * exact width.
126
+ */
127
+ export const NARROW_VIEWPORT_QUERY = '(max-width: 640px)';
128
+
129
+ /**
130
+ * The floor a column gets when the viewport is narrow, whatever the density.
131
+ *
132
+ * `DENSITY_GRID.columnMinWidth` is 80px, which is what seven flex columns
133
+ * collapse to on a phone — and 80px is not a column, it is an ellipsis. The
134
+ * measured widths on /contacts: "First Name" needs 127px of label alone,
135
+ * "Work Phone" 136px, before the sort icon beside it. Every header rendered as
136
+ * "FI…", "LA…", "TI…" and every cell truncated with it.
137
+ *
138
+ * 160px clears the widest of those with room for the icon. Seven of them is
139
+ * 1120px inside a 356px grid, which is the honest answer: the grid scrolls
140
+ * sideways INSIDE ITS OWN BODY (`.InovuaReactDataGrid__body` is already the
141
+ * scroller — the page itself does not move) and every column is readable when
142
+ * it is reached. The alternative on offer is seven columns of nothing.
143
+ *
144
+ * A view config's own `column.minWidth` still wins, so a genuinely narrow
145
+ * column (a quantity, a status chip) is one line of JSON away.
146
+ */
147
+ export const NARROW_COLUMN_MIN_WIDTH = 160;
148
+
149
+ /**
150
+ * Whether a media query currently matches, as a `useSyncExternalStore` pair.
151
+ *
152
+ * Same guards as `hasCoarsePointer` and for the same reasons: `matchMedia` is
153
+ * missing in jsdom and in every server render, and Safari only grew
154
+ * `addEventListener` on MediaQueryList in 14. Absent means `false`, which is
155
+ * the pre-existing behaviour in both cases — a desktop-width grid.
156
+ */
157
+ const mediaMatches = (query) => {
158
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
159
+ return false;
160
+ }
161
+
162
+ try {
163
+ return window.matchMedia(query).matches;
164
+ } catch (unsupported) {
165
+ return false;
166
+ }
167
+ };
168
+
169
+ const subscribeToQuery = (query) => (onChange) => {
170
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
171
+ return () => {};
172
+ }
173
+
174
+ try {
175
+ const list = window.matchMedia(query);
176
+
177
+ if (typeof list.addEventListener === 'function') {
178
+ list.addEventListener('change', onChange);
179
+
180
+ return () => list.removeEventListener('change', onChange);
181
+ }
182
+
183
+ if (typeof list.addListener === 'function') {
184
+ list.addListener(onChange);
185
+
186
+ return () => list.removeListener(onChange);
187
+ }
188
+ } catch (unsupported) {
189
+ // A browser that cannot evaluate the query never reports a change; the
190
+ // snapshot already read `false` for the same reason.
191
+ }
192
+
193
+ return () => {};
194
+ };
195
+
196
+ /**
197
+ * Whether the viewport is too narrow to lay columns out side by side.
198
+ *
199
+ * @returns {boolean}
200
+ */
201
+ export const useNarrowViewport = () => {
202
+ const subscribe = useMemo(
203
+ () => subscribeToQuery(NARROW_VIEWPORT_QUERY),
204
+ []
205
+ );
206
+ const getSnapshot = useMemo(
207
+ () => () => mediaMatches(NARROW_VIEWPORT_QUERY),
208
+ []
209
+ );
210
+
211
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
212
+ };
213
+
214
+ /**
215
+ * The attribute an app writes to turn phone card rows on for every grid in it.
216
+ *
217
+ * Same mechanism as `data-density` and for the same reason: it is set by
218
+ * whoever owns the page — GenericAuth, from its `cardRows` prop — rather than
219
+ * by the component reading it, and it has to be readable from CSS as well as
220
+ * from JS.
221
+ *
222
+ * OPT-IN AT THE LIBRARY, because three applications consume this and a
223
+ * heuristic that titles an unreviewed view badly is a worse phone experience
224
+ * than the sideways-scrolling table it replaced. A view can still turn card
225
+ * rows on for itself with `tableSetting.cardLayout`, and off with
226
+ * `cardLayout: false`, whatever this attribute says.
227
+ */
228
+ export const CARD_ROWS_ATTRIBUTE = 'data-card-rows';
229
+
230
+ const readCardRows = () => {
231
+ if (typeof document === 'undefined' || !document.documentElement) {
232
+ return 'off';
233
+ }
234
+
235
+ return (
236
+ document.documentElement.getAttribute(CARD_ROWS_ATTRIBUTE) || 'off'
237
+ );
238
+ };
239
+
240
+ const subscribeToCardRows = (onChange) => {
241
+ if (
242
+ typeof document === 'undefined' ||
243
+ typeof MutationObserver === 'undefined' ||
244
+ !document.documentElement
245
+ ) {
246
+ return () => {};
247
+ }
248
+
249
+ const observer = new MutationObserver(onChange);
250
+
251
+ observer.observe(document.documentElement, {
252
+ attributes: true,
253
+ attributeFilter: [CARD_ROWS_ATTRIBUTE],
254
+ });
255
+
256
+ return () => observer.disconnect();
257
+ };
258
+
259
+ /**
260
+ * Whether this application has opted its grids into phone card rows.
261
+ *
262
+ * @returns {boolean}
263
+ */
264
+ export const useCardRowsEnabled = () => {
265
+ const enabled = useSyncExternalStore(
266
+ subscribeToCardRows,
267
+ readCardRows,
268
+ () => 'off'
269
+ );
270
+
271
+ return enabled === 'on' || enabled === 'true';
272
+ };
273
+
274
+ /**
275
+ * Whether the primary pointing device is coarse.
276
+ *
277
+ * Wrapped rather than called inline because `matchMedia` is missing in jsdom
278
+ * and in every server render, and a hook that throws during the first snapshot
279
+ * takes the whole page with it. Absent means `false`, which is the pre-existing
280
+ * behaviour and the conservative answer: a mouse-sized target on a touch screen
281
+ * is awkward, a touch-sized one on a desktop grid is wrong.
282
+ */
283
+ const hasCoarsePointer = () => {
284
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
285
+ return false;
286
+ }
287
+
288
+ try {
289
+ return window.matchMedia(COARSE_POINTER_QUERY).matches;
290
+ } catch (unsupported) {
291
+ return false;
292
+ }
293
+ };
294
+
295
+ /**
296
+ * Read every switch off the document as one string. A string (not an object)
81
297
  * so `useSyncExternalStore` can compare snapshots by value — returning a fresh
82
298
  * object from `getSnapshot` makes React re-render forever.
299
+ *
300
+ * The class and the pointer are ORed into ONE flag rather than reported
301
+ * separately: everything downstream — the row heights, the action column, the
302
+ * checkbox column — wants the same answer to the same question, which is "how
303
+ * big does a target have to be here". Which of the two switches made it true
304
+ * is not a distinction any caller has ever needed.
83
305
  */
84
306
  const readSnapshot = () => {
85
307
  if (typeof document === 'undefined') {
@@ -89,9 +311,9 @@ const readSnapshot = () => {
89
311
  const density =
90
312
  document.documentElement?.getAttribute('data-density') ||
91
313
  DEFAULT_DENSITY;
92
- const isTablet = Boolean(
93
- document.body && document.body.classList.contains('tablet-mode')
94
- );
314
+ const isTablet =
315
+ Boolean(document.body && document.body.classList.contains('tablet-mode')) ||
316
+ hasCoarsePointer();
95
317
 
96
318
  return `${density}|${isTablet}`;
97
319
  };
@@ -99,9 +321,16 @@ const readSnapshot = () => {
99
321
  const getServerSnapshot = () => SERVER_SNAPSHOT;
100
322
 
101
323
  /**
102
- * Notify on either switch changing. Two observers rather than one on the
324
+ * Notify on any switch changing. Two observers rather than one on the
103
325
  * 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.
326
+ * a subtree observer would fire on every class change anywhere in the app
327
+ * plus one media-query listener for the pointer.
328
+ *
329
+ * The pointer listener is not theoretical: an iPad with a Magic Keyboard
330
+ * attached answers `fine` and detached answers `coarse`, and the change
331
+ * arrives with no resize, no navigation and no mutation of the document. Every
332
+ * grid on screen has to re-measure when it happens, or the rows stay at
333
+ * whatever height they were built for.
105
334
  */
106
335
  const subscribe = (onChange) => {
107
336
  if (
@@ -112,6 +341,31 @@ const subscribe = (onChange) => {
112
341
  }
113
342
 
114
343
  const observers = [];
344
+ /* Collected separately from `observers` because a MediaQueryList is
345
+ detached with `removeEventListener`, not `disconnect()`. */
346
+ const detachers = [];
347
+
348
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
349
+ try {
350
+ const pointer = window.matchMedia(COARSE_POINTER_QUERY);
351
+
352
+ // Safari only grew `addEventListener` on MediaQueryList in 14; the
353
+ // deprecated `addListener` is the fallback and still works
354
+ // everywhere. Neither is guaranteed, hence the guards.
355
+ if (typeof pointer.addEventListener === 'function') {
356
+ pointer.addEventListener('change', onChange);
357
+ detachers.push(() =>
358
+ pointer.removeEventListener('change', onChange)
359
+ );
360
+ } else if (typeof pointer.addListener === 'function') {
361
+ pointer.addListener(onChange);
362
+ detachers.push(() => pointer.removeListener(onChange));
363
+ }
364
+ } catch (unsupported) {
365
+ // A browser that cannot evaluate the query simply never reports a
366
+ // change; the snapshot already read `false` for the same reason.
367
+ }
368
+ }
115
369
 
116
370
  if (document.documentElement) {
117
371
  const rootObserver = new MutationObserver(onChange);
@@ -131,7 +385,10 @@ const subscribe = (onChange) => {
131
385
  observers.push(bodyObserver);
132
386
  }
133
387
 
134
- return () => observers.forEach((observer) => observer.disconnect());
388
+ return () => {
389
+ observers.forEach((observer) => observer.disconnect());
390
+ detachers.forEach((detach) => detach());
391
+ };
135
392
  };
136
393
 
137
394
  /**
@@ -385,6 +642,45 @@ export const rowsForHeight = (height, chrome, rowUnit) => {
385
642
  return Math.max(1, Math.floor(usable / rowUnit));
386
643
  };
387
644
 
645
+ /**
646
+ * The shortest a grid may be drawn: its own chrome, plus room for `minRows`.
647
+ *
648
+ * THIS IS THE FLOOR THAT WAS MISSING, and its absence is why a grid on a phone
649
+ * renders a column header, a filter row, a pager and NO DATA AT ALL.
650
+ *
651
+ * `useAvailableHeight` answers "how much room is left between this element's
652
+ * top edge and the bottom of the window", and that answer is applied to the
653
+ * grid verbatim. On a phone the app header, the page title and a six-tile stat
654
+ * strip that wraps to three rows put the grid's top edge 610px down a 740px
655
+ * viewport, so the answer is 130px — while the grid's own furniture (a 39px
656
+ * column header, a 43px filter row and a 41px pager) is 123px. The body is
657
+ * handed the remaining SEVEN pixels, less than a row in any density, so the
658
+ * grid paints its chrome and nothing else.
659
+ *
660
+ * `availableHeightFrom` cannot catch this: it only rejects a measurement at or
661
+ * below zero, and 130 is a perfectly positive number. The measurement is not
662
+ * wrong — there really is only 130px left — it is just not a height any grid
663
+ * can be. A grid shorter than its own chrome is never the right answer on any
664
+ * device, so the floor is unconditional rather than guarded on width: below it
665
+ * the grid overflows its slot and THE PAGE SCROLLS, which is the behaviour
666
+ * everybody already expects from a page whose content does not fit.
667
+ *
668
+ * It only ever engages when `available` is smaller than the floor, so a
669
+ * desktop index page — which gets several hundred px — is untouched.
670
+ *
671
+ * @param {number} chrome column header + filter row + footer + pager.
672
+ * @param {number} pitch the height one data row occupies.
673
+ * @param {number} minRows the fewest rows worth drawing a grid for.
674
+ * @returns {number}
675
+ */
676
+ export const gridHeightFloor = (chrome, pitch, minRows) => {
677
+ const rows = Number.isFinite(minRows) && minRows > 0 ? minRows : 1;
678
+ const safeChrome = Number.isFinite(chrome) && chrome > 0 ? chrome : 0;
679
+ const safePitch = Number.isFinite(pitch) && pitch > 0 ? pitch : 0;
680
+
681
+ return safeChrome + rows * safePitch;
682
+ };
683
+
388
684
  /** How long to wait after a layout change before re-measuring, in ms. */
389
685
  export const REMEASURE_DEBOUNCE_MS = 100;
390
686
 
package/src/index.js CHANGED
@@ -48,6 +48,26 @@ import Table from './components/DataGrid';
48
48
  import TableFilter from './components/TableFilter';
49
49
  import CellWithTooltip from './components/cells/CellWithTooltip';
50
50
  import DataGridSearch from './components/controls/DataGridSearch';
51
+ // Phone card rows: the pure mapper that decides what a stacked row says, and
52
+ // the row itself. Exported so a consuming app can check what its own view
53
+ // configs derive to without mounting a grid at 375px.
54
+ import StackedRow from './components/columns/StackedRow';
55
+ import {
56
+ deriveCardLayout,
57
+ cardRowHeight,
58
+ CARD_COLUMN_NAME,
59
+ MAX_BODY_LINES,
60
+ } from './components/utils/cardLayout';
61
+ // Where a detail page's Edit button sits. Exported for the same reason
62
+ // `deriveCardLayout` is: a consuming app can check what its own view configs
63
+ // resolve to without mounting GenericDetail.
64
+ import {
65
+ resolveEditPlacement,
66
+ isHeaderPlacement,
67
+ EDIT_PLACEMENTS,
68
+ EDIT_PLACEMENT_FLOATING,
69
+ EDIT_PLACEMENT_HEADER,
70
+ } from './components/utils/editPlacement';
51
71
  import AutoRefreshControls from './components/controls/AutoRefreshControls';
52
72
  import AudioPlayer from './components/controls/AudioPlayer';
53
73
  import GalleryModal from './components/modals/GalleryModal';
@@ -72,6 +92,7 @@ import AuthShell from './components/auth/AuthShell';
72
92
  import AuthLoading from './components/auth/AuthLoading';
73
93
  import AuthBrandPanel from './components/auth/AuthBrandPanel';
74
94
  import ClientAuthFrame from './components/auth/ClientAuthFrame';
95
+ import ClientAuthScreen from './components/auth/ClientAuthScreen';
75
96
  import ClientAuth from './components/auth/ClientAuth';
76
97
  import ClientLogin from './components/auth/ClientLogin';
77
98
  import ClientOTPVerify from './components/auth/ClientOTPVerify';
@@ -446,6 +467,7 @@ export {
446
467
  ClientAssociationManager,
447
468
  ClientAuth,
448
469
  ClientAuthFrame,
470
+ ClientAuthScreen,
449
471
  ClientLogin,
450
472
  ClientOTPVerify,
451
473
  ClientPortal,
@@ -456,11 +478,17 @@ export {
456
478
  confirmDialog,
457
479
  contactHint,
458
480
  CustomFetch,
481
+ CARD_COLUMN_NAME,
482
+ cardRowHeight,
459
483
  DataGrid,
460
484
  DataGridSearch,
485
+ deriveCardLayout,
461
486
  DatePickerPortal,
462
487
  DEFAULT_AUTH_ENDPOINTS,
463
488
  DEFAULT_CLIENT_PATHS,
489
+ EDIT_PLACEMENT_FLOATING,
490
+ EDIT_PLACEMENT_HEADER,
491
+ EDIT_PLACEMENTS,
464
492
  DEFAULT_CLIENT_PROTOCOL,
465
493
  DEFAULT_CONTACT_HINTS,
466
494
  DENSITY_GRID,
@@ -532,6 +560,8 @@ export {
532
560
  resolveAuthEndpoints,
533
561
  resolveClientPaths,
534
562
  resolveClientProtocol,
563
+ resolveEditPlacement,
564
+ isHeaderPlacement,
535
565
  resolveFormVariant,
536
566
  // Passkeys (WebAuthn). Exported so a consuming app can build its own
537
567
  // enrolment screen against the same marshalling the login screen uses.
@@ -550,6 +580,8 @@ export {
550
580
  showConfirmDialog,
551
581
  SortableList,
552
582
  StagePopupModal,
583
+ MAX_BODY_LINES,
584
+ StackedRow,
553
585
  StandardModal,
554
586
  syncCsrfFromResponse,
555
587
  syncCsrfToken,