@visns-studio/visns-components 6.32.2 → 6.34.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 (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -0,0 +1,131 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * WHICH SHELL THE APPLICATION IS WEARING, readable from anywhere.
5
+ *
6
+ * `GenericAuth` (and `GenericMain` below it) takes a `layout` prop with three
7
+ * values, and it decides the whole shape of the page:
8
+ *
9
+ * 'full' — the one-line header bar across the top, content underneath.
10
+ * 'cms' — a fixed sidebar rail down the left, content beside it.
11
+ * 'simple' — the same rail, with the simpler content frame.
12
+ *
13
+ * The prop is threaded from GenericAuth into GenericMain into Navigation, and
14
+ * it stops there. Anything further down the tree — a screen mounted by the
15
+ * route table, a panel inside that screen — is several components away from
16
+ * ever being told, and the answer changes how a page wants to lay itself out:
17
+ * a horizontal tab strip is right on a page that already has a rail down its
18
+ * left, and a vertical rail beside a vertical rail is not.
19
+ *
20
+ * So the shell mirrors itself onto `<html>` as `data-layout`, exactly the way
21
+ * the `density` prop mirrors itself as `data-density` (see GenericAuth, and
22
+ * `useCardRowsEnabled` in useDensity.js for the reading half). One attribute,
23
+ * written once by whoever owns the page, readable by CSS and by JS without a
24
+ * prop being threaded through six components that do not care.
25
+ *
26
+ * ABSENT MEANS 'full'. That is not a guess dressed up as a default: 'full' is
27
+ * GenericAuth's own default, it is what every app that never passed the prop
28
+ * renders, and it is what a server render — where there is no document to read
29
+ * — has to answer so the markup it produces matches the markup the browser
30
+ * produces on hydration.
31
+ */
32
+
33
+ /** The attribute GenericAuth writes on `<html>`. */
34
+ export const SHELL_LAYOUT_ATTRIBUTE = 'data-layout';
35
+
36
+ /** What an unset (or unrecognised) attribute means. */
37
+ export const DEFAULT_SHELL_LAYOUT = 'full';
38
+
39
+ /** Every layout GenericAuth accepts. */
40
+ export const SHELL_LAYOUTS = ['full', 'cms', 'simple'];
41
+
42
+ /** The ones that draw a navigation rail down the left edge. */
43
+ export const SIDEBAR_SHELL_LAYOUTS = ['cms', 'simple'];
44
+
45
+ /**
46
+ * A layout name, or `'full'` for anything that is not one.
47
+ *
48
+ * Unrecognised values are not passed through: the point of this module is that
49
+ * callers can branch on the answer without re-checking it, and a typo in a
50
+ * host's config should land on the layout every app already renders rather
51
+ * than on a fourth, undefined one.
52
+ *
53
+ * @param {string} [value]
54
+ * @returns {'full'|'cms'|'simple'}
55
+ */
56
+ export const normaliseShellLayout = (value) =>
57
+ SHELL_LAYOUTS.includes(value) ? value : DEFAULT_SHELL_LAYOUT;
58
+
59
+ /**
60
+ * Does this shell put its navigation in a rail down the left?
61
+ *
62
+ * The question almost every caller actually has — 'cms' and 'simple' differ in
63
+ * the content frame, not in where the navigation lives, and no layout decision
64
+ * in this library has ever needed to tell the two apart.
65
+ *
66
+ * @param {string} [layout] A layout name; anything else counts as 'full'.
67
+ * @returns {boolean}
68
+ */
69
+ export const isSidebarShell = (layout) =>
70
+ SIDEBAR_SHELL_LAYOUTS.includes(normaliseShellLayout(layout));
71
+
72
+ /**
73
+ * The shell layout, read off the document once.
74
+ *
75
+ * SSR-safe: no document means 'full'. Use this in an event handler or a one-off
76
+ * calculation; a component that has to re-render when the attribute changes
77
+ * wants `useShellLayout()` instead.
78
+ *
79
+ * @returns {'full'|'cms'|'simple'}
80
+ */
81
+ export const getShellLayout = () => {
82
+ if (typeof document === 'undefined' || !document.documentElement) {
83
+ return DEFAULT_SHELL_LAYOUT;
84
+ }
85
+
86
+ return normaliseShellLayout(
87
+ document.documentElement.getAttribute(SHELL_LAYOUT_ATTRIBUTE)
88
+ );
89
+ };
90
+
91
+ /**
92
+ * Notify on the attribute changing.
93
+ *
94
+ * It changes exactly once in a normal app — when GenericAuth's insertion
95
+ * effect runs, before the first paint — but that once is after the first
96
+ * render of anything mounted outside GenericAuth (a Storybook-style harness,
97
+ * a test), and a screen that read 'full' at that moment and never heard again
98
+ * would keep the wrong layout for the life of the page.
99
+ */
100
+ const subscribeToShellLayout = (onChange) => {
101
+ if (
102
+ typeof document === 'undefined' ||
103
+ typeof MutationObserver === 'undefined' ||
104
+ !document.documentElement
105
+ ) {
106
+ return () => {};
107
+ }
108
+
109
+ const observer = new MutationObserver(onChange);
110
+
111
+ observer.observe(document.documentElement, {
112
+ attributes: true,
113
+ attributeFilter: [SHELL_LAYOUT_ATTRIBUTE],
114
+ });
115
+
116
+ return () => observer.disconnect();
117
+ };
118
+
119
+ /**
120
+ * The shell layout, as a subscription.
121
+ *
122
+ * @returns {'full'|'cms'|'simple'}
123
+ */
124
+ export const useShellLayout = () =>
125
+ useSyncExternalStore(
126
+ subscribeToShellLayout,
127
+ getShellLayout,
128
+ () => DEFAULT_SHELL_LAYOUT
129
+ );
130
+
131
+ export default useShellLayout;
package/src/index.js CHANGED
@@ -13,6 +13,24 @@ import {
13
13
  DENSITY_GRID,
14
14
  SECONDARY_GRID_HEIGHT,
15
15
  } from './components/utils/useDensity';
16
+ // Which shell the app is wearing ('full' | 'cms' | 'simple'), mirrored onto
17
+ // <html> by GenericAuth as `data-layout`. Screens below the route table read
18
+ // it from here instead of being handed the prop.
19
+ import {
20
+ getShellLayout,
21
+ useShellLayout,
22
+ isSidebarShell,
23
+ SHELL_LAYOUT_ATTRIBUTE,
24
+ } from './components/utils/useShellLayout';
25
+ // The collapsed-rail preference: remembered per browser, published on <html>
26
+ // as `data-nav-collapsed`.
27
+ import {
28
+ useNavCollapsed,
29
+ getNavCollapsed,
30
+ setNavCollapsed,
31
+ NAV_COLLAPSED_ATTRIBUTE,
32
+ NAV_COLLAPSED_STORAGE_KEY,
33
+ } from './components/utils/navCollapsed';
16
34
  import { readBuildEnv, buildEnv } from './components/utils/buildEnv';
17
35
  // Project-wide default for how record CRUD forms are presented — the side
18
36
  // sheet unless an app calls setDefaultFormLayout('modal') at bootstrap. A
@@ -93,6 +111,11 @@ export * from './components/columns/ColumnRenderers.jsx';
93
111
  /** Auth Specific Components */
94
112
  import Login from './components/auth/Login';
95
113
  import Profile from './components/auth/Profile';
114
+ // The enrol-once prompt. Rendered by GenericAuth when `passkeys` is on;
115
+ // exported for a host with its own shell, or its own idea of when to ask.
116
+ import PasskeyEnrolPrompt, {
117
+ PasskeyEnrolDialog,
118
+ } from './components/auth/PasskeyEnrolPrompt';
96
119
  import Reset from './components/auth/Reset';
97
120
  import Verify from './components/auth/Verify';
98
121
  import TwoFactorAuth from './components/auth/TwoFactorAuth';
@@ -113,6 +136,7 @@ import {
113
136
  DEFAULT_CLIENT_PATHS,
114
137
  resolveAuthEndpoints,
115
138
  resolveClientPaths,
139
+ resolvePasskeyDestroyUrl,
116
140
  } from './components/auth/authEndpoints';
117
141
  import {
118
142
  DEFAULT_CONTACT_HINTS,
@@ -138,6 +162,31 @@ import {
138
162
  isPasskeyCancellation,
139
163
  isPasskeySupported,
140
164
  } from './components/auth/webauthn';
165
+ import {
166
+ DEFAULT_PASSKEY_PROMPT_COPY,
167
+ DEFAULT_PASSKEY_SNOOZE_DAYS,
168
+ PASSKEY_PROMPT_STORAGE_PREFIX,
169
+ countPasskeys,
170
+ defaultPasskeyName,
171
+ isPasskeyPromptBlocked,
172
+ neverAskPasskeyPrompt,
173
+ passkeyPromptStorageKey,
174
+ readPasskeyPromptRecord,
175
+ resolvePasskeyOptions,
176
+ shouldPromptForPasskey,
177
+ snoozePasskeyPrompt,
178
+ writePasskeyPromptRecord,
179
+ } from './components/auth/passkeyPrompt';
180
+ import {
181
+ deletePasskey,
182
+ enrolPasskey,
183
+ listPasskeys,
184
+ } from './components/auth/passkeyClient';
185
+ import {
186
+ PASSKEYS_ATTRIBUTE,
187
+ getPasskeysEnabled,
188
+ usePasskeysEnabled,
189
+ } from './components/utils/usePasskeysEnabled';
141
190
  import {
142
191
  CLIENT_AUTH_PROTOCOLS,
143
192
  DEFAULT_CLIENT_PROTOCOL,
@@ -223,7 +272,19 @@ import {
223
272
 
224
273
  // Messaging — the SMS inbox. SmsInboxBadge mounts through Navigation's
225
274
  // `renderers` slot; SmsInbox is the page behind it, SmsThreadPanel is the
226
- // conversation on its own for embedding elsewhere.
275
+ // conversation on its own for embedding elsewhere. SmsCampaigns is the bulk
276
+ // side of the same module — one message to an imported list of numbers — and
277
+ // brings the opt-out register with it.
278
+ import SmsCampaigns, {
279
+ CAMPAIGN_STATUSES,
280
+ DEFAULT_CAMPAIGN_SETTINGS,
281
+ RECIPIENT_STATUSES,
282
+ defaultCampaignName,
283
+ estimateMinutes,
284
+ parsePastedRecipients,
285
+ readSheetRows,
286
+ recipientsFromRecords,
287
+ } from './components/sms/SmsCampaigns';
227
288
  import SmsClientConversations from './components/sms/SmsClientConversations';
228
289
  import SmsComposeModal from './components/sms/SmsComposeModal';
229
290
  import SmsInbox from './components/sms/SmsInbox';
@@ -434,10 +495,14 @@ export {
434
495
  VaultManager,
435
496
  VaultQuickSearch,
436
497
  VaultShareModal,
498
+ CAMPAIGN_STATUSES,
437
499
  clientRecipient,
438
500
  conversationCountLabel,
501
+ DEFAULT_CAMPAIGN_SETTINGS,
439
502
  DEFAULT_SMS_ENDPOINTS,
503
+ defaultCampaignName,
440
504
  describeTransport,
505
+ estimateMinutes,
441
506
  fillTemplate,
442
507
  groupMessagesByDay,
443
508
  initialsFor,
@@ -447,7 +512,11 @@ export {
447
512
  nextPlaceholder,
448
513
  normaliseNumberForDisplay,
449
514
  orderThreadsByRecency,
515
+ parsePastedRecipients,
450
516
  pickInitialThread,
517
+ readSheetRows,
518
+ recipientsFromRecords,
519
+ RECIPIENT_STATUSES,
451
520
  relativeTime,
452
521
  resolveSmsEndpoints,
453
522
  segmentCount,
@@ -455,6 +524,7 @@ export {
455
524
  SMS_LIMITS,
456
525
  SMS_MAX_SEGMENTS,
457
526
  SMS_STATUS_LABELS,
527
+ SmsCampaigns,
458
528
  SmsClientConversations,
459
529
  SmsComposeModal,
460
530
  SmsInbox,
@@ -616,6 +686,29 @@ export {
616
686
  getPasskeyCredential,
617
687
  isPasskeyCancellation,
618
688
  isPasskeySupported,
689
+ // The enrol-once prompt, its decision, and the three requests behind it.
690
+ PasskeyEnrolPrompt,
691
+ PasskeyEnrolDialog,
692
+ DEFAULT_PASSKEY_PROMPT_COPY,
693
+ DEFAULT_PASSKEY_SNOOZE_DAYS,
694
+ PASSKEY_PROMPT_STORAGE_PREFIX,
695
+ PASSKEYS_ATTRIBUTE,
696
+ countPasskeys,
697
+ defaultPasskeyName,
698
+ deletePasskey,
699
+ enrolPasskey,
700
+ getPasskeysEnabled,
701
+ isPasskeyPromptBlocked,
702
+ listPasskeys,
703
+ neverAskPasskeyPrompt,
704
+ passkeyPromptStorageKey,
705
+ readPasskeyPromptRecord,
706
+ resolvePasskeyDestroyUrl,
707
+ resolvePasskeyOptions,
708
+ shouldPromptForPasskey,
709
+ snoozePasskeyPrompt,
710
+ usePasskeysEnabled,
711
+ writePasskeyPromptRecord,
619
712
  SectionEditor,
620
713
  SectionGroupedReport,
621
714
  SectionTypeSelector,
@@ -637,6 +730,15 @@ export {
637
730
  twoFactorSuccessTarget,
638
731
  useAvailableHeight,
639
732
  useDensity,
733
+ useShellLayout,
734
+ getShellLayout,
735
+ isSidebarShell,
736
+ SHELL_LAYOUT_ATTRIBUTE,
737
+ useNavCollapsed,
738
+ getNavCollapsed,
739
+ setNavCollapsed,
740
+ NAV_COLLAPSED_ATTRIBUTE,
741
+ NAV_COLLAPSED_STORAGE_KEY,
640
742
  SECONDARY_GRID_HEIGHT,
641
743
  VariableInserter,
642
744
  verifyContactFor,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The open tab of a GenericDetail page, remembered in the address bar as
3
+ * `?tab=<filter id>`. Two pure-ish helpers over `window.location` and
4
+ * `window.history`, kept out of the component so `node --test` can drive them
5
+ * with a fake window — GenericDetail itself needs a DOM, a router and a
6
+ * server to mount.
7
+ */
8
+
9
+ /** The query parameter the open tab is remembered in. */
10
+ export const TAB_PARAM = 'tab';
11
+
12
+ /**
13
+ * Apply `?tab=<id>` to a freshly built tab list: the named top-level filter is
14
+ * shown and the rest are not. Returns the list untouched when the parameter
15
+ * is absent, names no top-level filter, or already names the shown one.
16
+ *
17
+ * The active class is borrowed from whichever entry the config marks shown
18
+ * (`subactive` in most view configs, `activetab` on a simple strip) so the
19
+ * strip is styled exactly as a click would style it; children, where a tab
20
+ * has them, are left as the config wrote them — the first child is the one a
21
+ * click on the parent would open too.
22
+ */
23
+ export const openTabFromUrl = (list) => {
24
+ let wanted = null;
25
+
26
+ try {
27
+ wanted = new URLSearchParams(window.location.search).get(TAB_PARAM);
28
+ } catch (e) {
29
+ return list;
30
+ }
31
+
32
+ if (!wanted || !Array.isArray(list)) return list;
33
+
34
+ const target = list.find((f) => f && String(f.id) === wanted);
35
+
36
+ if (!target || target.show) return list;
37
+
38
+ const shown = list.find((f) => f && f.show);
39
+ const activeClass = (shown && shown.class) || 'subactive';
40
+
41
+ return list.map((f) => {
42
+ const on = f === target;
43
+
44
+ return { ...f, show: on, active: on, class: on ? activeClass : '' };
45
+ });
46
+ };
47
+
48
+ /**
49
+ * Put the shown top-level tab into the address bar as `?tab=<id>`, keeping
50
+ * every other query parameter and the hash, through `replaceState` so no
51
+ * history entry is added and the router is not involved. A list with nothing
52
+ * shown writes nothing.
53
+ */
54
+ export const writeTabToUrl = (list) => {
55
+ const shown = list.find((f) => f && f.show && f.id !== undefined && f.id !== null);
56
+
57
+ if (!shown) return;
58
+
59
+ try {
60
+ const url = new URL(window.location.href);
61
+
62
+ if (url.searchParams.get(TAB_PARAM) === String(shown.id)) return;
63
+
64
+ url.searchParams.set(TAB_PARAM, String(shown.id));
65
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
66
+ } catch (e) {
67
+ /* A sandbox with no history object — nothing to remember into. */
68
+ }
69
+ };
70
+