@visns-studio/visns-components 6.6.4 → 6.15.1

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 (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. package/src/index.js +233 -1
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The sign-in design language, as a mixin.
3
+ *
4
+ * Login.module.scss is the original: an asymmetric two-pane split, the brand
5
+ * photograph on the left and the task on the right, everything driven off the
6
+ * consuming app's token layer with neutral fallbacks. Before 6.7.0 it was the
7
+ * only screen in the journey that looked like that — TwoFactorAuth, Reset and
8
+ * Verify were still the pre-6.1 centred `.lcontainer / .lwrap / .login` card,
9
+ * so signing in with 2FA meant crossing a visible design seam halfway through.
10
+ *
11
+ * The rules live here rather than in one shared module because these are CSS
12
+ * modules: each screen needs its own scoped copy of the class names. Every
13
+ * screen `@use`s this file and `@include auth-shell;` at the top of its own
14
+ * module, then adds only what is particular to it.
15
+ *
16
+ * Anything changed here must be mirrored in Login.module.scss, which keeps its
17
+ * own copy so the login screen's rendering cannot be disturbed by edits made
18
+ * for another screen.
19
+ */
20
+
21
+ @use './authBrandPanel' as brand;
22
+
23
+ @mixin auth-shell {
24
+ /* ---------------------------------------------------------------------- */
25
+ /* frame */
26
+ /* ---------------------------------------------------------------------- */
27
+
28
+ .auth {
29
+ /* Local aliases only — every brand value comes from the consuming app's
30
+ token layer so this library stays reusable. The fallbacks are neutral
31
+ on purpose: a project that defines nothing gets a plain, working
32
+ screen rather than any one project's colours. Identical to the block
33
+ at the top of Login.module.scss. */
34
+ --ink: var(--header-color, #10192a);
35
+ --navy: var(--primary-color, #26364a);
36
+ --navy-deep: var(--primary-color-darker, #16202e);
37
+ --flag: var(--accent-color, #8a8f98);
38
+ --paper: var(--surface-color, #ffffff);
39
+ --warm: var(--hover-color, #f4f4f2);
40
+ --line: var(--border-color, #e4e1dc);
41
+ --muted: var(--muted-color, #6b7688);
42
+
43
+ position: fixed;
44
+ inset: 0;
45
+ display: grid;
46
+ grid-template-columns: 52fr 48fr;
47
+ background: var(--paper);
48
+ /* Read from a token first so an app can put its own face on the whole
49
+ journey; 'Barlow' is imported by the screens themselves. */
50
+ font-family: var(--auth-font-family, 'Barlow', system-ui, sans-serif);
51
+ color: var(--ink);
52
+
53
+ @media (max-width: 900px) {
54
+ grid-template-columns: 1fr;
55
+ }
56
+ }
57
+
58
+ // ---------------------------------------------------------------------
59
+ // brand panel — shared with the staff Login and the client split layout.
60
+ // See _authBrandPanel.scss; `hide` is the staff screens' collapse.
61
+ // ---------------------------------------------------------------------
62
+
63
+ @include brand.auth-brand-panel(hide);
64
+
65
+ /* ---------------------------------------------------------------------- */
66
+ /* form panel */
67
+ /* ---------------------------------------------------------------------- */
68
+
69
+ .panel {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ padding: 2rem clamp(1.5rem, 5vw, 4.5rem);
74
+ background: var(--paper);
75
+ overflow-y: auto;
76
+
77
+ @media (max-width: 900px) {
78
+ background: linear-gradient(var(--warm), var(--paper) 22rem);
79
+ }
80
+ }
81
+
82
+ .panelInner {
83
+ width: 100%;
84
+ max-width: 24rem;
85
+ text-align: left;
86
+ animation: rise 0.55s cubic-bezier(0.2, 0.7, 0.3, 1) both;
87
+ animation-delay: 0.08s;
88
+ }
89
+
90
+ .logo {
91
+ display: block;
92
+ width: auto;
93
+ height: 3.1rem;
94
+ margin-bottom: 2.4rem;
95
+ }
96
+
97
+ .title {
98
+ margin: 0 0 0.35rem;
99
+ font-size: 1.9rem;
100
+ font-weight: 700;
101
+ letter-spacing: -0.02em;
102
+ line-height: 1.1;
103
+ }
104
+
105
+ .subtitle {
106
+ margin: 0 0 2rem;
107
+ font-size: 0.95rem;
108
+ font-weight: 400;
109
+ color: var(--muted);
110
+ }
111
+
112
+ .form {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 0.9rem;
116
+ }
117
+
118
+ /* Floating label: the label sits in the field until it holds something, so
119
+ the field is self-describing without a separate line above it. */
120
+ .field {
121
+ position: relative;
122
+
123
+ input {
124
+ width: 100%;
125
+ height: 3.35rem;
126
+ padding: 1.15rem 0.9rem 0.4rem;
127
+ border: 1px solid var(--line);
128
+ border-radius: 0.6rem;
129
+ background: var(--paper);
130
+ font-family: inherit;
131
+ font-size: 1rem;
132
+ color: var(--ink);
133
+ transition:
134
+ border-color 0.15s ease,
135
+ box-shadow 0.15s ease;
136
+
137
+ &:hover {
138
+ border-color: #c9c5be;
139
+ }
140
+
141
+ &:focus {
142
+ outline: none;
143
+ border-color: var(--navy);
144
+ box-shadow: 0 0 0 3px
145
+ var(--focus-ring-soft, rgba(38, 54, 74, 0.16));
146
+ }
147
+
148
+ &:disabled {
149
+ background: var(--warm);
150
+ color: var(--muted);
151
+ cursor: default;
152
+ }
153
+ }
154
+
155
+ label {
156
+ position: absolute;
157
+ left: 0.95rem;
158
+ top: 1.05rem;
159
+ font-size: 1rem;
160
+ font-weight: 400;
161
+ color: var(--muted);
162
+ pointer-events: none;
163
+ transition:
164
+ transform 0.14s ease,
165
+ font-size 0.14s ease,
166
+ color 0.14s ease;
167
+ transform-origin: 0 0;
168
+ }
169
+
170
+ /* :not(:placeholder-shown) is why every input carries placeholder=" ". */
171
+ input:focus + label,
172
+ input:not(:placeholder-shown) + label {
173
+ transform: translateY(-0.72rem);
174
+ font-size: 0.72rem;
175
+ font-weight: 600;
176
+ letter-spacing: 0.02em;
177
+ color: var(--navy);
178
+ }
179
+
180
+ &:has(.reveal) input {
181
+ padding-right: 3rem;
182
+ }
183
+ }
184
+
185
+ /* Set imperatively by the submit handlers on a failed attempt, so it is a
186
+ plain global class rather than a module one. */
187
+ :global(.inputError) {
188
+ border-color: var(--danger-color, #b3261e) !important;
189
+ box-shadow: 0 0 0 3px var(--danger-ring, rgba(179, 38, 30, 0.14)) !important;
190
+ }
191
+
192
+ .reveal {
193
+ position: absolute;
194
+ right: 0.55rem;
195
+ top: 50%;
196
+ transform: translateY(-50%);
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ width: 2.1rem;
201
+ height: 2.1rem;
202
+ border: none;
203
+ border-radius: 0.4rem;
204
+ background: none;
205
+ color: var(--muted);
206
+ cursor: pointer;
207
+
208
+ &:hover {
209
+ background: var(--warm);
210
+ color: var(--ink);
211
+ }
212
+ }
213
+
214
+ .row {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: space-between;
218
+ gap: 1rem;
219
+ margin: 0.1rem 0 0.5rem;
220
+ }
221
+
222
+ .link {
223
+ font-size: 0.88rem;
224
+ font-weight: 600;
225
+ color: var(--navy);
226
+ text-decoration: none;
227
+ background: none;
228
+ border: none;
229
+ padding: 0;
230
+ font-family: inherit;
231
+ cursor: pointer;
232
+
233
+ &:hover {
234
+ text-decoration: underline;
235
+ }
236
+
237
+ &:disabled {
238
+ color: var(--muted);
239
+ cursor: default;
240
+ text-decoration: none;
241
+ }
242
+ }
243
+
244
+ /* Primary on these screens. Unlike Login — which keeps its email button
245
+ outlined because SSO is the filled action above it — verifying a code or
246
+ setting a password is the only thing the screen asks for, so it carries
247
+ the fill. */
248
+ .submit {
249
+ width: 100%;
250
+ height: 3.1rem;
251
+ border: 1px solid var(--navy);
252
+ border-radius: 0.6rem;
253
+ background: var(--navy);
254
+ color: #fff;
255
+ font-family: inherit;
256
+ font-size: 0.95rem;
257
+ font-weight: 600;
258
+ letter-spacing: 0.01em;
259
+ cursor: pointer;
260
+ transition:
261
+ border-color 0.15s ease,
262
+ background 0.15s ease,
263
+ transform 0.08s ease;
264
+
265
+ &:hover:not(:disabled) {
266
+ background: var(--navy-deep);
267
+ border-color: var(--navy-deep);
268
+ }
269
+
270
+ &:active:not(:disabled) {
271
+ transform: translateY(1px);
272
+ }
273
+
274
+ &:focus-visible {
275
+ outline: 3px solid var(--focus-ring-strong, rgba(38, 54, 74, 0.3));
276
+ outline-offset: 2px;
277
+ }
278
+
279
+ &:disabled {
280
+ opacity: 0.65;
281
+ cursor: default;
282
+ }
283
+ }
284
+
285
+ /* A quiet way to a secondary path. Not a link — it performs an action on
286
+ this page rather than navigating anywhere. */
287
+ .altToggle {
288
+ display: block;
289
+ width: 100%;
290
+ margin-top: 1rem;
291
+ padding: 0.6rem;
292
+ border: none;
293
+ border-radius: 0.5rem;
294
+ background: none;
295
+ font-family: inherit;
296
+ font-size: 0.9rem;
297
+ font-weight: 600;
298
+ color: var(--navy);
299
+ cursor: pointer;
300
+
301
+ &:hover {
302
+ background: var(--warm);
303
+ }
304
+
305
+ &:focus-visible {
306
+ outline: 3px solid var(--focus-ring-strong, rgba(38, 54, 74, 0.3));
307
+ outline-offset: 2px;
308
+ }
309
+
310
+ &:disabled {
311
+ color: var(--muted);
312
+ cursor: default;
313
+
314
+ &:hover {
315
+ background: none;
316
+ }
317
+ }
318
+ }
319
+
320
+ .divider {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 0.9rem;
324
+ margin: 1.4rem 0;
325
+ color: var(--muted);
326
+ font-size: 0.78rem;
327
+ letter-spacing: 0.08em;
328
+ text-transform: uppercase;
329
+
330
+ &::before,
331
+ &::after {
332
+ content: '';
333
+ flex: 1;
334
+ height: 1px;
335
+ background: var(--line);
336
+ }
337
+ }
338
+
339
+ /* A muted line under the form — expiry copy, "having trouble?", the sign
340
+ off. Never a control. */
341
+ .note {
342
+ margin: 1.4rem 0 0;
343
+ font-size: 0.82rem;
344
+ line-height: 1.5;
345
+ color: var(--muted);
346
+ text-align: left;
347
+ }
348
+
349
+ .foot {
350
+ margin: 2.5rem 0 0;
351
+ font-size: 0.78rem;
352
+ color: var(--muted);
353
+ }
354
+
355
+ @include brand.auth-rise-keyframes;
356
+
357
+ @media (prefers-reduced-motion: reduce) {
358
+ .brandInk,
359
+ .panelInner {
360
+ animation: none;
361
+ }
362
+
363
+ .submit,
364
+ .field input,
365
+ .field label {
366
+ transition: none;
367
+ }
368
+ }
369
+ }
@@ -444,6 +444,8 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
444
444
  .visns-native-date-filter {
445
445
  position: relative;
446
446
  display: flex;
447
+ /* Lets the rules below react to the column's width, not the window's. */
448
+ container-type: inline-size;
447
449
  /* stretch, not center: the input has to fill the 32px the theme pins the
448
450
  filter to, or the clickable area is smaller than the box it draws. */
449
451
  align-items: stretch;
@@ -483,14 +485,18 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
483
485
  min-width: 0;
484
486
  height: auto;
485
487
  margin: 0;
486
- padding: 0 2px 0 8px;
488
+ padding: 0 2px 0 6px;
487
489
  border: none;
488
490
  outline: none;
489
491
  background: none;
490
492
  color: inherit;
491
493
  font-family: inherit;
492
- font-size: inherit;
494
+ /* A notch under the grid's 14px: the locale mask is ten characters and
495
+ the default filter row is narrow. */
496
+ font-size: 13px;
497
+ letter-spacing: -0.01em;
493
498
  line-height: 1;
499
+ font-variant-numeric: tabular-nums;
494
500
  -webkit-appearance: none;
495
501
  appearance: none;
496
502
  }
@@ -516,6 +522,25 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
516
522
  opacity: 1;
517
523
  }
518
524
 
525
+ /* In a narrow column the glyph costs the last two digits of the year; the
526
+ field still opens the picker on click/Space, so drop the glyph first and the
527
+ clear button only when there is really no room. */
528
+ @container (max-width: 128px) {
529
+ .visns-native-date-filter__input::-webkit-calendar-picker-indicator {
530
+ display: none;
531
+ }
532
+
533
+ .visns-native-date-filter__input {
534
+ padding-right: 0;
535
+ }
536
+ }
537
+
538
+ @container (max-width: 104px) {
539
+ .visns-native-date-filter__clear {
540
+ display: none;
541
+ }
542
+ }
543
+
519
544
  /* Browser-supplied clear / spin affordances are replaced by our own button,
520
545
  which is consistent across engines. */
521
546
  .visns-native-date-filter__input::-webkit-clear-button,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Build-time environment variables, whichever bundler compiled this.
3
+ *
4
+ * `import.meta.env` is Vite's interface, and two files in this library read it
5
+ * directly (Fetch.jsx for the payload ceiling, DataGrid.jsx for the asset
6
+ * path). That made the whole library Vite-only: under webpack — which is what
7
+ * the Next.js portal runs — `import.meta` survives the compile as a bare `{}`,
8
+ * so the property read threw a TypeError on the first request the app made.
9
+ * The portal needed a DefinePlugin shim to get past it.
10
+ *
11
+ * Both accesses here are guarded and both are wrapped, because the bundlers
12
+ * fail differently: one leaves `import.meta` undefined, the other leaves it an
13
+ * object with no `env`. Vite's behaviour is unchanged — its value is still
14
+ * found first and still wins.
15
+ *
16
+ * Pure module (no React, no DOM) so it can be unit-tested with `node --test`.
17
+ */
18
+
19
+ /**
20
+ * Read one variable, or `undefined`.
21
+ *
22
+ * Lookup order: Vite's `import.meta.env`, then `process.env` under the name as
23
+ * given, then `process.env` under a `NEXT_PUBLIC_` prefix — Next.js will only
24
+ * expose a variable to the browser under that prefix, so a `VITE_FOO` becomes
25
+ * `NEXT_PUBLIC_FOO` there.
26
+ */
27
+ export const readBuildEnv = (key) => {
28
+ try {
29
+ // eslint-disable-next-line no-undef
30
+ const meta = typeof import.meta === 'undefined' ? null : import.meta;
31
+ const value = meta && meta.env ? meta.env[key] : undefined;
32
+
33
+ if (value !== undefined) {
34
+ return value;
35
+ }
36
+ } catch (error) {
37
+ // No `import.meta` in this module format. Expected under a CommonJS
38
+ // output and under Jest; never worth reporting.
39
+ }
40
+
41
+ try {
42
+ if (typeof process !== 'undefined' && process.env) {
43
+ const direct = process.env[key];
44
+
45
+ if (direct !== undefined) {
46
+ return direct;
47
+ }
48
+
49
+ return process.env[
50
+ `NEXT_PUBLIC_${key.replace(/^(VITE_|REACT_APP_)/, '')}`
51
+ ];
52
+ }
53
+ } catch (error) {
54
+ // No `process` either. Fall through to the caller's own default.
55
+ }
56
+
57
+ return undefined;
58
+ };
59
+
60
+ /** `readBuildEnv` with a default for the (common) absent case. */
61
+ export const buildEnv = (key, fallback = '') => {
62
+ const value = readBuildEnv(key);
63
+
64
+ return value === undefined || value === null ? fallback : value;
65
+ };
66
+
67
+ export default readBuildEnv;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Small, pure display helpers shared by the generic detail page and the
3
+ * DataGrid column renderers.
4
+ *
5
+ * These live in their own module — with no React and no CSS import — so the
6
+ * rules they encode can be tested directly under `node --test` rather than
7
+ * inferred from a rendered tree. Every consumer of a `.jsx` in this repo has
8
+ * to bring a bundler with it; this file deliberately does not.
9
+ */
10
+
11
+ /**
12
+ * How many items are in a value that a column is counting?
13
+ *
14
+ * The grid's `arrayCount` column reads a JSON field straight off the API
15
+ * payload, and what actually arrives there varies by backend and by driver:
16
+ * a real array, a JSON *string* that was never decoded (a `json` cast the
17
+ * model forgot), an object keyed by id, or null for a record that has none.
18
+ * A bare `value.length` handles exactly the first of those and yields
19
+ * `undefined` — rendered as an empty cell — for the rest, which reads as
20
+ * "unknown" when the truthful answer is a number.
21
+ *
22
+ * @param {*} value raw field value
23
+ * @returns {number} item count; 0 when there is nothing countable
24
+ */
25
+ export function arrayCountFrom(value) {
26
+ if (Array.isArray(value)) {
27
+ return value.length;
28
+ }
29
+
30
+ if (typeof value === 'string') {
31
+ const trimmed = value.trim();
32
+
33
+ if (trimmed === '') {
34
+ return 0;
35
+ }
36
+
37
+ try {
38
+ return arrayCountFrom(JSON.parse(trimmed));
39
+ } catch {
40
+ return 0;
41
+ }
42
+ }
43
+
44
+ // A keyed object (`{"1": {...}, "2": {...}}`) is the shape PHP produces
45
+ // when a list's keys stop being consecutive — same list, different JSON.
46
+ if (value && typeof value === 'object') {
47
+ return Object.keys(value).length;
48
+ }
49
+
50
+ return 0;
51
+ }
52
+
53
+ /**
54
+ * Is a resolved field value one the page should show a placeholder for?
55
+ *
56
+ * `null` reaches the UI as the string "null" often enough (a stringified
57
+ * payload, a concatenated relation path) that treating it as content puts the
58
+ * literal word on the page. Zero and `false` are answers, not blanks.
59
+ *
60
+ * @param {*} value
61
+ * @returns {boolean}
62
+ */
63
+ export function isBlankValue(value) {
64
+ if (value === null || value === undefined) {
65
+ return true;
66
+ }
67
+
68
+ if (typeof value === 'string') {
69
+ const trimmed = value.trim();
70
+
71
+ return trimmed === '' || trimmed === 'null' || trimmed === 'undefined';
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * The label shown above an overview value.
79
+ *
80
+ * Overview labels were written as inline run-in labels ("Label: value"), so a
81
+ * good number of the JSON configs carry their own trailing colon. Stacked
82
+ * above the value the colon is noise, and it is not something we can go and
83
+ * strip from every project's config — so it is normalised here, once.
84
+ *
85
+ * @param {*} label
86
+ * @returns {string}
87
+ */
88
+ export function overviewFieldLabel(label) {
89
+ if (label === null || label === undefined) {
90
+ return '';
91
+ }
92
+
93
+ return String(label).trim().replace(/[::]\s*$/, '');
94
+ }