@visns-studio/visns-components 6.32.3 → 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 +470 -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 +274 -15
  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 +322 -5
  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,273 @@
1
+ /* ============================================================================
2
+ StandardModal — the overlay and the panel
3
+ ----------------------------------------------------------------------------
4
+ Every measurement here used to be a JavaScript object built per render from
5
+ `window.innerWidth` and a user-agent sniff, which is why this file is new:
6
+
7
+ - THE SNIFF WAS WRONG for the most common phone-shaped viewport there is.
8
+ `isMobile` required `isMobileUA || (width <= 768 && hasTouch)`, so a
9
+ 390px window with no touch — a responsive-mode browser, a narrow window
10
+ on a laptop, a webview that does not advertise touch — was "desktop" and
11
+ got `min-width: 600px` inside a 390px viewport. That is the horizontal
12
+ scrollbar under a dialog.
13
+ - INLINE STYLES CANNOT EXPRESS A FALLBACK, and the one that matters on a
14
+ phone is `100dvh` with `100vh` behind it. A sheet at `height: 100vh` on
15
+ iOS Safari is taller than the visual viewport by the height of the
16
+ browser chrome, so the bottom of the panel — where the Save bar lives —
17
+ is underneath it. In CSS the two declarations sit one after the other
18
+ and the engine picks the one it understands.
19
+ - MEDIA QUERIES APPLY AT THE FIRST PAINT. State initialised from
20
+ `window.innerWidth` and corrected by a resize listener cannot, which is
21
+ a frame of desktop-width dialog on the way in.
22
+
23
+ `customStyles.modal` / `customStyles.overlay` are still inline at the call
24
+ site and therefore still win over everything here, which is what the SMS
25
+ composer and the gallery rely on.
26
+
27
+ ----------------------------------------------------------------------------
28
+ THE Z-INDEX ORDER, stated once because three separate stacking decisions
29
+ have to agree and none of them can see the others:
30
+
31
+ 995 the content header (sticky) Navigation.module.scss
32
+ 996 the rail's pinned account actions Navigation.module.scss
33
+ 1050 the sidebar rail itself Navigation.module.scss
34
+ 1200 THIS OVERLAY --modal-z here
35
+ 1300 date pickers, incl. the portal --datepicker-z global.css
36
+ 9999+ react-select menus portalled to MultiSelect.jsx,
37
+ document.body ColumnRenderers.jsx
38
+
39
+ The overlay was 1000 — BELOW the 1050 rail, so on a sidebar layout the rail
40
+ painted over the dialog's left edge and stayed clickable through it. It is
41
+ 1200 now: above every piece of app chrome, and below everything a dialog can
42
+ itself open, which is the other half of the rule. Both of the things a form
43
+ opens — a date picker and a portalled select menu — render into `<body>`,
44
+ outside this overlay's stacking context, so each needs a number ABOVE it or
45
+ it opens behind the form it belongs to. The date pickers were 1060, chosen
46
+ years ago to clear "Bootstrap modals (1050)"; moving this overlay to 1200
47
+ moved them to 1300 in the same change.
48
+ ========================================================================= */
49
+
50
+ .overlay {
51
+ position: fixed;
52
+ inset: 0;
53
+ display: flex;
54
+ z-index: var(--modal-z, 1200);
55
+ /* A scroll inside the panel that reaches its end must not continue into
56
+ the page behind it — the iOS behaviour where the dialog stops moving and
57
+ the list underneath starts. */
58
+ overscroll-behavior: contain;
59
+ }
60
+
61
+ /* --- the centred dialog --------------------------------------------------- */
62
+
63
+ .overlay--modal {
64
+ align-items: center;
65
+ justify-content: center;
66
+ padding: 20px;
67
+ background-color: rgba(0, 0, 0, 0.5);
68
+ }
69
+
70
+ .overlay--top {
71
+ align-items: flex-start;
72
+ padding-top: 5%;
73
+ }
74
+
75
+ /* --- the side sheet ------------------------------------------------------- */
76
+
77
+ .overlay--sheet {
78
+ align-items: stretch;
79
+ justify-content: flex-end;
80
+ padding: 0;
81
+ /* Was a flat 50% black. A scrim that heavy on a sheet buries the list the
82
+ sheet exists to keep visible, so the sheet dims less. */
83
+ background-color: rgba(15, 23, 32, 0.32);
84
+ }
85
+
86
+ /* ============================================================================
87
+ The panel
88
+ ----------------------------------------------------------------------------
89
+ ONE SCROLLER, and it is this element. The panel used to scroll AND
90
+ `.modal__content` inside it used to scroll, both capped at 85vh — so the
91
+ form's sticky Save bar stuck to the bottom of the inner box, which began
92
+ below the header and therefore ended below the bottom of the outer one. The
93
+ only way to reach Save on a long form was to scroll the inner region to its
94
+ end and then scroll the outer one as well. On a phone, where the sheet was
95
+ also `100vh` against a shorter visual viewport, it was simply not reachable.
96
+
97
+ `.modal__content` gives its scroll up (see Form.module.scss) and the header
98
+ and the button bar are sticky inside this one, so both stay on screen at
99
+ every height.
100
+ ========================================================================= */
101
+
102
+ .panel {
103
+ position: relative;
104
+ box-sizing: border-box;
105
+ background-color: #fff;
106
+ overflow-y: auto;
107
+ overscroll-behavior: contain;
108
+ /* Momentum scrolling, and the thing that stops a touch drag that starts on
109
+ a label from panning the page instead of the panel. */
110
+ -webkit-overflow-scrolling: touch;
111
+ }
112
+
113
+ .panel--modal {
114
+ border-radius: 8px;
115
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
116
+ max-height: 90vh;
117
+ max-height: 90dvh;
118
+ }
119
+
120
+ .panel--small {
121
+ width: 400px;
122
+ min-width: 400px;
123
+ max-width: 600px;
124
+ }
125
+
126
+ .panel--medium {
127
+ width: 80vw;
128
+ min-width: 600px;
129
+ max-width: 1200px;
130
+ }
131
+
132
+ .panel--large {
133
+ width: 95vw;
134
+ min-width: 800px;
135
+ max-width: 1600px;
136
+ }
137
+
138
+ /* --- the sheet ------------------------------------------------------------ */
139
+
140
+ .panel--sheet {
141
+ display: flex;
142
+ flex-direction: column;
143
+ max-width: 100vw;
144
+ height: 100vh;
145
+ height: 100dvh;
146
+ border-radius: 0;
147
+ box-shadow: -12px 0 40px -12px rgba(0, 0, 0, 0.28);
148
+ /* Honours prefers-reduced-motion through the keyframes the component
149
+ injects; the transform is the entry, and it is deliberately short. */
150
+ animation: visnsSheetIn 180ms cubic-bezier(0.32, 0.72, 0, 1);
151
+ }
152
+
153
+ /* A sheet's width is FIXED, not a share of the window: a form wants one
154
+ comfortable measure, and `80vw` gave it a different one on every monitor. */
155
+ .panel--sheet.panel--small {
156
+ width: min(420px, 96vw);
157
+ min-width: 0;
158
+ max-width: 100vw;
159
+ }
160
+
161
+ .panel--sheet.panel--medium {
162
+ width: min(560px, 96vw);
163
+ min-width: 0;
164
+ max-width: 100vw;
165
+ }
166
+
167
+ .panel--sheet.panel--large {
168
+ width: min(800px, 96vw);
169
+ min-width: 0;
170
+ max-width: 100vw;
171
+ }
172
+
173
+ /* ============================================================================
174
+ Tablet — 641px to 1024px
175
+ ==========================================================================*/
176
+ @media (max-width: 1024px) {
177
+ .panel--modal {
178
+ max-height: 85vh;
179
+ max-height: 85dvh;
180
+ }
181
+
182
+ /* THE MINIMUMS COME OFF HERE, not at the phone breakpoint.
183
+ `.panel--large` is `min-width: 800px` and the overlay spends 20px a
184
+ side, so a `large` dialog needed 840px of glass — it overflowed every
185
+ window between 641 and 840px, which is a portrait tablet and a
186
+ half-screen browser on a laptop. The old code papered over part of that
187
+ band with a touch sniff and left the rest of it broken. A width cap is
188
+ all that is wanted: the dialog takes what there is, and a `small`
189
+ confirm still refuses to stretch across an iPad. */
190
+ .panel--small,
191
+ .panel--medium,
192
+ .panel--large {
193
+ min-width: 0;
194
+ max-width: 100%;
195
+ }
196
+
197
+ .panel--small {
198
+ width: min(400px, 100%);
199
+ }
200
+
201
+ .panel--medium {
202
+ width: min(80vw, 100%);
203
+ }
204
+
205
+ .panel--large {
206
+ width: 100%;
207
+ }
208
+ }
209
+
210
+ /* ============================================================================
211
+ Phone — 640px and below
212
+ ----------------------------------------------------------------------------
213
+ Every variant becomes the same thing: a panel that owns the glass. A centred
214
+ dialog on a phone is a 95vw box with a 20px scrim around it and a shadow
215
+ nobody can see, and at `min-width: 600px` it was not even that — it was a
216
+ box wider than the screen with the page scrolling sideways under it.
217
+
218
+ The centred variant lands as a BOTTOM SHEET: full width, pinned to the
219
+ bottom edge, rounded at the top only. That is where a thumb is, and it is
220
+ the shape every phone OS uses for exactly this.
221
+ ==========================================================================*/
222
+ @media (max-width: 640px) {
223
+ .overlay--modal,
224
+ .overlay--top {
225
+ align-items: flex-end;
226
+ justify-content: stretch;
227
+ padding: 0;
228
+ }
229
+
230
+ .panel--modal,
231
+ .panel--small,
232
+ .panel--medium,
233
+ .panel--large {
234
+ width: 100%;
235
+ min-width: 0;
236
+ max-width: 100vw;
237
+ border-radius: 12px 12px 0 0;
238
+ box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.28);
239
+ /* Room for the OS chrome at the top; the rest of the glass is the
240
+ dialog's. `dvh` is the whole point of the pair — `vh` on iOS Safari
241
+ measures the viewport as if the address bar were hidden. */
242
+ max-height: 92vh;
243
+ max-height: 92dvh;
244
+ }
245
+
246
+ .panel--sheet {
247
+ width: 100%;
248
+ min-width: 0;
249
+ max-width: 100vw;
250
+ border-radius: 0;
251
+ /* A sheet arrives from the right on a desktop. On a phone it IS the
252
+ screen, so it has no edge to arrive from. */
253
+ animation: none;
254
+ }
255
+
256
+ /* Nothing sits under a full-width panel to be seen, and the scrim costs a
257
+ repaint on every scroll frame on a phone GPU. */
258
+ .overlay--sheet {
259
+ background-color: rgba(15, 23, 32, 0.45);
260
+ }
261
+ }
262
+
263
+ /* The safe area under a bottom sheet — the home indicator on a modern iPhone
264
+ overlaps the last 34px of the glass, which on a bottom-anchored panel is the
265
+ Save button. */
266
+ @supports (padding: env(safe-area-inset-bottom)) {
267
+ @media (max-width: 640px) {
268
+ .panel--modal,
269
+ .panel--sheet {
270
+ padding-bottom: env(safe-area-inset-bottom, 0px);
271
+ }
272
+ }
273
+ }
@@ -531,9 +531,17 @@ input[type='time']:not(.visns-native-date-filter__input) {
531
531
  z-index: 999 !important;
532
532
  }
533
533
 
534
- /* DatePicker proper z-index hierarchy */
534
+ /* DatePicker proper z-index hierarchy.
535
+
536
+ 1060 was chosen to clear "Bootstrap modals (1050)", and it cleared this
537
+ library's own dialog too for as long as that dialog was 1000. The dialog is
538
+ 1200 now (it has to clear the 1050 sidebar rail — see the ladder at the top
539
+ of StandardModal.module.scss), so 1060 would open every date picker BEHIND
540
+ the form it was opened from. `--datepicker-z` keeps the two ends adjustable
541
+ together; the default sits above the dialog and below the react-select menus
542
+ portalled to document.body at 9999. */
535
543
  .react-datepicker-popper {
536
- z-index: 1060 !important; /* Above Bootstrap modals (1050) */
544
+ z-index: var(--datepicker-z, 1300) !important;
537
545
  position: absolute !important; /* Use absolute positioning for proper context */
538
546
  }
539
547
 
@@ -543,13 +551,24 @@ input[type='time']:not(.visns-native-date-filter__input) {
543
551
  top: 50% !important;
544
552
  left: 50% !important;
545
553
  transform: translate(-50%, -50%) !important;
546
- z-index: 1060 !important;
554
+ z-index: var(--datepicker-z, 1300) !important;
547
555
  }
548
556
 
549
557
  /* Portal mode for modal contexts */
550
558
  .react-datepicker-popper.portal-mode {
551
559
  position: fixed !important;
552
- z-index: 1060 !important;
560
+ z-index: var(--datepicker-z, 1300) !important;
561
+ }
562
+
563
+ /* The host DatePickerPortal appends to <body>. It had no rule at all, so it
564
+ was a `z-index: auto` box at the end of the document — which loses to the
565
+ dialog's 1200 however late it comes in the DOM, because a positioned
566
+ ancestor with a real z-index wins over an auto one regardless of order. The
567
+ popper inside it carries its own z-index, but only once this container
568
+ stops being the thing that flattens it. */
569
+ .datepicker-portal-container {
570
+ position: relative;
571
+ z-index: var(--datepicker-z, 1300);
553
572
  }
554
573
 
555
574
  .react-datepicker {
@@ -562,7 +581,7 @@ input[type='time']:not(.visns-native-date-filter__input) {
562
581
  }
563
582
 
564
583
  .react-datepicker-portal {
565
- z-index: 1060 !important;
584
+ z-index: var(--datepicker-z, 1300) !important;
566
585
  }
567
586
 
568
587
  /* Fix for specific modal header that's causing issues */
@@ -0,0 +1,201 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * WHETHER THE SIDEBAR RAIL IS COLLAPSED, and where that fact lives.
5
+ *
6
+ * The rail costs 216px of every page, permanently, to show six words. On a
7
+ * laptop beside a datagrid that is a column of the table; the person who reads
8
+ * the table all day wants the room back, and the person who navigates all day
9
+ * does not. So it collapses to an icon strip, and — because it is a preference
10
+ * about this browser rather than about this account — it is remembered in
11
+ * `localStorage` and nowhere else. Nothing is sent to a server, so nothing has
12
+ * to be migrated, permissioned or synced.
13
+ *
14
+ * THREE PLACES HOLD IT, in this order of authority:
15
+ *
16
+ * 1. `localStorage['visns.nav.collapsed']` — what this browser last chose.
17
+ * Read synchronously, in a lazy `useState` initialiser, so the FIRST
18
+ * render already has the right width. Read it in an effect instead and
19
+ * the rail paints wide and snaps narrow, which is the one thing a
20
+ * persisted layout preference must not do.
21
+ * 2. `<html data-nav-collapsed="true">` — the live state, written by
22
+ * Navigation. This is what CSS keys off (the rail's own rules, and a
23
+ * consuming app's if it wants to react), and what `useNavCollapsed`
24
+ * subscribes to.
25
+ * 3. React state inside Navigation, which drives 2.
26
+ *
27
+ * ABSENT MEANS EXPANDED, everywhere: no storage, no attribute, no document,
28
+ * unreadable storage, a value that is not 'true' or 'false'. Expanded is what
29
+ * every existing app renders today, so every way this can fail lands on the
30
+ * rendering that is already deployed.
31
+ */
32
+
33
+ /** The attribute Navigation writes on `<html>`. */
34
+ export const NAV_COLLAPSED_ATTRIBUTE = 'data-nav-collapsed';
35
+
36
+ /** The localStorage key the preference is remembered under. */
37
+ export const NAV_COLLAPSED_STORAGE_KEY = 'visns.nav.collapsed';
38
+
39
+ /**
40
+ * Only ever 'true' or 'false' is written, and only 'true' is read as
41
+ * collapsed — so a key some other tool has scribbled on, or a half-written
42
+ * value from a storage quota error, reads as expanded rather than as a
43
+ * mystery.
44
+ */
45
+ const parseCollapsed = (raw) => raw === 'true';
46
+
47
+ /**
48
+ * `localStorage`, when there is one that can be touched.
49
+ *
50
+ * Private-mode Safari throws on ACCESS, not just on write, and an embedded
51
+ * webview can have the property missing altogether — so even reading the
52
+ * global is inside the try.
53
+ *
54
+ * @returns {Storage|null}
55
+ */
56
+ const storage = () => {
57
+ try {
58
+ if (typeof window === 'undefined' || !window.localStorage) {
59
+ return null;
60
+ }
61
+
62
+ return window.localStorage;
63
+ } catch (unavailable) {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ /**
69
+ * The remembered preference, straight from storage.
70
+ *
71
+ * This is the one to use as a `useState` initialiser: it does not depend on
72
+ * the attribute having been written yet, which on the first render it has not.
73
+ *
74
+ * @returns {boolean}
75
+ */
76
+ export const getStoredNavCollapsed = () => {
77
+ const store = storage();
78
+
79
+ if (!store) {
80
+ return false;
81
+ }
82
+
83
+ try {
84
+ return parseCollapsed(store.getItem(NAV_COLLAPSED_STORAGE_KEY));
85
+ } catch (unreadable) {
86
+ return false;
87
+ }
88
+ };
89
+
90
+ /**
91
+ * Remember the preference. Failing to write is not an error worth surfacing —
92
+ * the rail still collapses, it just forgets by the next page load.
93
+ *
94
+ * @param {boolean} collapsed
95
+ */
96
+ export const storeNavCollapsed = (collapsed) => {
97
+ const store = storage();
98
+
99
+ if (!store) {
100
+ return;
101
+ }
102
+
103
+ try {
104
+ store.setItem(NAV_COLLAPSED_STORAGE_KEY, collapsed ? 'true' : 'false');
105
+ } catch (full) {
106
+ /* Quota, private mode, a locked-down webview — all the same answer. */
107
+ }
108
+ };
109
+
110
+ /**
111
+ * Write (or clear) the attribute on `<html>`.
112
+ *
113
+ * `false` REMOVES the attribute rather than writing `"false"`: an app that has
114
+ * never collapsed its rail should have nothing on its root element, the same
115
+ * way `data-density` is absent at the default density.
116
+ *
117
+ * @param {boolean} collapsed
118
+ */
119
+ export const applyNavCollapsed = (collapsed) => {
120
+ if (typeof document === 'undefined' || !document.documentElement) {
121
+ return;
122
+ }
123
+
124
+ if (collapsed) {
125
+ document.documentElement.setAttribute(NAV_COLLAPSED_ATTRIBUTE, 'true');
126
+ } else {
127
+ document.documentElement.removeAttribute(NAV_COLLAPSED_ATTRIBUTE);
128
+ }
129
+ };
130
+
131
+ /**
132
+ * The live state, read off `<html>`.
133
+ *
134
+ * Falls back to the stored preference while the attribute is absent, so a
135
+ * component that renders before Navigation's insertion effect has run — one
136
+ * mounted outside the shell, or the very first render of the page — sizes
137
+ * itself to the rail the user is about to see rather than to the one they
138
+ * are not.
139
+ *
140
+ * @returns {boolean}
141
+ */
142
+ export const getNavCollapsed = () => {
143
+ if (typeof document === 'undefined' || !document.documentElement) {
144
+ return false;
145
+ }
146
+
147
+ if (!document.documentElement.hasAttribute(NAV_COLLAPSED_ATTRIBUTE)) {
148
+ return getStoredNavCollapsed();
149
+ }
150
+
151
+ return parseCollapsed(
152
+ document.documentElement.getAttribute(NAV_COLLAPSED_ATTRIBUTE)
153
+ );
154
+ };
155
+
156
+ /**
157
+ * Collapse or expand the rail from outside Navigation — a keyboard shortcut in
158
+ * a consuming app, a "focus mode" button on a report page.
159
+ *
160
+ * Writes both places at once. Navigation's own effect keys off the attribute's
161
+ * MutationObserver like everybody else, so its toggle button and its rows stay
162
+ * in step without a shared React context.
163
+ *
164
+ * @param {boolean} collapsed
165
+ */
166
+ export const setNavCollapsed = (collapsed) => {
167
+ const next = Boolean(collapsed);
168
+
169
+ storeNavCollapsed(next);
170
+ applyNavCollapsed(next);
171
+ };
172
+
173
+ /** Notify on the attribute changing. Same shape as `useShellLayout`'s. */
174
+ const subscribeToNavCollapsed = (onChange) => {
175
+ if (
176
+ typeof document === 'undefined' ||
177
+ typeof MutationObserver === 'undefined' ||
178
+ !document.documentElement
179
+ ) {
180
+ return () => {};
181
+ }
182
+
183
+ const observer = new MutationObserver(onChange);
184
+
185
+ observer.observe(document.documentElement, {
186
+ attributes: true,
187
+ attributeFilter: [NAV_COLLAPSED_ATTRIBUTE],
188
+ });
189
+
190
+ return () => observer.disconnect();
191
+ };
192
+
193
+ /**
194
+ * Whether the rail is collapsed, as a subscription.
195
+ *
196
+ * @returns {boolean}
197
+ */
198
+ export const useNavCollapsed = () =>
199
+ useSyncExternalStore(subscribeToNavCollapsed, getNavCollapsed, () => false);
200
+
201
+ export default useNavCollapsed;
@@ -115,3 +115,79 @@ export const filterPickerOptions = (options, search) => {
115
115
  String(option.label).toLowerCase().includes(term)
116
116
  );
117
117
  };
118
+
119
+ /**
120
+ * IS THIS ROW ACTION DISABLED, AND WHY?
121
+ *
122
+ * A settings entry could already be taken away per row with
123
+ * `condition: (row) => boolean` — the icon is simply not rendered. That is the
124
+ * right answer when the action does not apply to the row at all, and the wrong
125
+ * one when it applies but is not permitted: an icon that is present on nine
126
+ * rows and absent on the tenth reads as a rendering bug, and it answers none of
127
+ * the question the user actually has, which is "why not this one".
128
+ *
129
+ * `disabled: (row) => boolean | string` keeps the icon in the row and makes it
130
+ * inert. Returning a STRING both disables the action and says why, and the
131
+ * string replaces the action's ordinary tooltip — which is the only place the
132
+ * reason can go, since the cell is one 18px glyph:
133
+ *
134
+ * {
135
+ * id: 'update',
136
+ * disabled: (row) =>
137
+ * row.name === 'Administrator'
138
+ * ? 'The Administrator role cannot be changed.'
139
+ * : false,
140
+ * }
141
+ *
142
+ * `condition` and `disabled` are independent and may both be set; `condition`
143
+ * still hides, and a hidden action is never asked whether it is disabled.
144
+ *
145
+ * A THROWING PREDICATE IS NOT DISABLED. This is deliberately the opposite of
146
+ * `condition`, which hides on a throw: there, failing closed costs a verb the
147
+ * user might not have been allowed anyway; here, failing closed would disable a
148
+ * legitimate action across every row of the grid on one bad field access, with
149
+ * a tooltip that cannot explain itself. Failing open leaves the action exactly
150
+ * as it was before the predicate existed.
151
+ *
152
+ * A literal is accepted as well as a function, because `disabled: true` on a
153
+ * whole settings entry is a reasonable thing to write and there is no reason to
154
+ * make a host wrap it in an arrow.
155
+ *
156
+ * @param {object} setting A root `settings` entry.
157
+ * @param {object} row The row the action would act on.
158
+ * @returns {{disabled: boolean, reason: string}} `reason` is '' unless the
159
+ * predicate returned a non-empty string.
160
+ */
161
+ export const resolveRowActionDisabled = (setting, row) => {
162
+ const NOT_DISABLED = { disabled: false, reason: '' };
163
+ const rule = setting?.disabled;
164
+
165
+ if (rule === undefined || rule === null || rule === false) {
166
+ return NOT_DISABLED;
167
+ }
168
+
169
+ if (typeof rule !== 'function') {
170
+ return asDisabledResult(rule);
171
+ }
172
+
173
+ try {
174
+ return asDisabledResult(rule(row));
175
+ } catch (error) {
176
+ return NOT_DISABLED;
177
+ }
178
+ };
179
+
180
+ /** One shape for a predicate's answer, whatever it returned. */
181
+ const asDisabledResult = (value) => {
182
+ if (typeof value === 'string') {
183
+ // An empty string is not a reason, and it is not a yes either — it is
184
+ // what a host's `row.locked_reason` returns when the row is not locked.
185
+ return value.trim()
186
+ ? { disabled: true, reason: value }
187
+ : { disabled: false, reason: '' };
188
+ }
189
+
190
+ return value
191
+ ? { disabled: true, reason: '' }
192
+ : { disabled: false, reason: '' };
193
+ };
@@ -0,0 +1,84 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * WHETHER THIS APPLICATION OFFERS PASSKEYS, readable from anywhere.
5
+ *
6
+ * Exactly the arrangement `data-layout` uses (utils/useShellLayout.js), for
7
+ * exactly the same reason. `GenericAuth` takes the `passkeys` prop and can
8
+ * hand it to the screens it mounts itself — the login screen, the enrolment
9
+ * prompt — but the profile screen is NOT one of those: it is mounted by the
10
+ * consuming app's own `routeConfig`, several components below the route table,
11
+ * and the only props it is given are `userProfile` and `setUserProfile` (see
12
+ * GenericMain's `renderRoutes`).
13
+ *
14
+ * So the switch mirrors itself onto `<html>` as `data-passkeys="on"`, written
15
+ * once by GenericAuth, and the profile screen reads it. The alternative was to
16
+ * make every consuming app pass `passkeys` a second time when it builds its
17
+ * route config, which is a step every app would have to be edited for and
18
+ * would silently be skipped by the ones that were not.
19
+ *
20
+ * ABSENT MEANS OFF, which is what every app that never passed the prop gets.
21
+ */
22
+
23
+ /** The attribute GenericAuth writes on `<html>`. */
24
+ export const PASSKEYS_ATTRIBUTE = 'data-passkeys';
25
+
26
+ /** The value it is written with. */
27
+ export const PASSKEYS_ENABLED_VALUE = 'on';
28
+
29
+ /**
30
+ * Is the attribute set, right now?
31
+ *
32
+ * SSR-safe: no document means off — the same answer the first client render
33
+ * produces before GenericAuth's insertion effect has run, so the markup
34
+ * matches on hydration.
35
+ *
36
+ * @returns {boolean}
37
+ */
38
+ export const getPasskeysEnabled = () => {
39
+ if (typeof document === 'undefined' || !document.documentElement) {
40
+ return false;
41
+ }
42
+
43
+ return (
44
+ document.documentElement.getAttribute(PASSKEYS_ATTRIBUTE) ===
45
+ PASSKEYS_ENABLED_VALUE
46
+ );
47
+ };
48
+
49
+ /**
50
+ * Notify on the attribute changing.
51
+ *
52
+ * It changes once per page in a normal app, but that once can land AFTER the
53
+ * first render of a screen mounted in the same commit — a refresh straight
54
+ * onto `/profile` is the ordinary case — and a screen that read `false` then
55
+ * and never heard again would hide the tab for the life of the page.
56
+ */
57
+ const subscribe = (onChange) => {
58
+ if (
59
+ typeof document === 'undefined' ||
60
+ typeof MutationObserver === 'undefined' ||
61
+ !document.documentElement
62
+ ) {
63
+ return () => {};
64
+ }
65
+
66
+ const observer = new MutationObserver(onChange);
67
+
68
+ observer.observe(document.documentElement, {
69
+ attributes: true,
70
+ attributeFilter: [PASSKEYS_ATTRIBUTE],
71
+ });
72
+
73
+ return () => observer.disconnect();
74
+ };
75
+
76
+ /**
77
+ * Whether passkeys are on, as a subscription.
78
+ *
79
+ * @returns {boolean}
80
+ */
81
+ export const usePasskeysEnabled = () =>
82
+ useSyncExternalStore(subscribe, getPasskeysEnabled, () => false);
83
+
84
+ export default usePasskeysEnabled;