@visns-studio/visns-components 6.32.3 → 6.34.3
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.
- package/README.md +483 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +274 -15
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +11 -4
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2517 -0
- package/src/components/sms/SmsInbox.jsx +20 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +322 -5
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +484 -4
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +38 -7
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +107 -1
- 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
|
+
}
|
|
@@ -1746,10 +1746,12 @@ input.checkControl {
|
|
|
1746
1746
|
|
|
1747
1747
|
.pageTitle {
|
|
1748
1748
|
margin: 0;
|
|
1749
|
-
|
|
1750
|
-
|
|
1749
|
+
/* One title size across a host CRM — see `.pageTitle` in Sms.module.scss. */
|
|
1750
|
+
color: var(--heading-color, var(--v-paragraph));
|
|
1751
|
+
font-size: var(--page-header-title-size, 1.25rem);
|
|
1751
1752
|
font-weight: 700;
|
|
1752
|
-
line-height: 1.
|
|
1753
|
+
line-height: 1.25;
|
|
1754
|
+
letter-spacing: -0.02em;
|
|
1753
1755
|
}
|
|
1754
1756
|
|
|
1755
1757
|
.pageIntro {
|
|
@@ -280,7 +280,9 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
|
|
|
280
280
|
.react-datepicker__year-select
|
|
281
281
|
):not(.visns-native-date-filter__input):not([type='date']):not(
|
|
282
282
|
[type='datetime-local']
|
|
283
|
-
):not([type='time']):not([class*='searchInput']):not([class*='filterInput']):not([class*='rowActionPickerSearch'])
|
|
283
|
+
):not([type='time']):not([class*='searchInput']):not([class*='filterInput']):not([class*='rowActionPickerSearch']):not(
|
|
284
|
+
.visns-auth-input
|
|
285
|
+
) {
|
|
284
286
|
background: white;
|
|
285
287
|
border-radius: var(--br);
|
|
286
288
|
border: 1px solid rgba(var(--paragraph-color-rgb, 43, 43, 43), 0.15);
|
|
@@ -310,6 +312,14 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
|
|
|
310
312
|
font-size: 0.85rem;
|
|
311
313
|
}
|
|
312
314
|
|
|
315
|
+
/* `.visns-auth-input` is the floating-label field on the auth screens (login,
|
|
316
|
+
reset, verify, 2FA). Its module gives it a 3.35rem box with a top-heavy
|
|
317
|
+
padding so the value clears the label sitting inside it, and that rule is
|
|
318
|
+
`.field input` — (0,1,1), which this chain outranks twenty times over. So
|
|
319
|
+
the chain used to win: the box shrank to `--field-height`, the padding went
|
|
320
|
+
horizontal-only, and the value was drawn straight through the label. The
|
|
321
|
+
auth inputs carry the class for exactly this opt-out. */
|
|
322
|
+
|
|
313
323
|
/* The focus border, as its own rule and NOT nested under the list above.
|
|
314
324
|
|
|
315
325
|
Nested `&:focus` is `:is(<the whole list>):focus`, and `:is()` takes the
|
|
@@ -333,7 +343,9 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
|
|
|
333
343
|
.react-datepicker__year-select
|
|
334
344
|
):not(.visns-native-date-filter__input):not([type='date']):not(
|
|
335
345
|
[type='datetime-local']
|
|
336
|
-
):not([type='time']):not([class*='searchInput']):not([class*='filterInput']):not([class*='rowActionPickerSearch']):
|
|
346
|
+
):not([type='time']):not([class*='searchInput']):not([class*='filterInput']):not([class*='rowActionPickerSearch']):not(
|
|
347
|
+
.visns-auth-input
|
|
348
|
+
):focus {
|
|
337
349
|
border: 1px solid var(--primary-color);
|
|
338
350
|
}
|
|
339
351
|
|
|
@@ -531,9 +543,17 @@ input[type='time']:not(.visns-native-date-filter__input) {
|
|
|
531
543
|
z-index: 999 !important;
|
|
532
544
|
}
|
|
533
545
|
|
|
534
|
-
/* DatePicker proper z-index hierarchy
|
|
546
|
+
/* DatePicker proper z-index hierarchy.
|
|
547
|
+
|
|
548
|
+
1060 was chosen to clear "Bootstrap modals (1050)", and it cleared this
|
|
549
|
+
library's own dialog too for as long as that dialog was 1000. The dialog is
|
|
550
|
+
1200 now (it has to clear the 1050 sidebar rail — see the ladder at the top
|
|
551
|
+
of StandardModal.module.scss), so 1060 would open every date picker BEHIND
|
|
552
|
+
the form it was opened from. `--datepicker-z` keeps the two ends adjustable
|
|
553
|
+
together; the default sits above the dialog and below the react-select menus
|
|
554
|
+
portalled to document.body at 9999. */
|
|
535
555
|
.react-datepicker-popper {
|
|
536
|
-
z-index:
|
|
556
|
+
z-index: var(--datepicker-z, 1300) !important;
|
|
537
557
|
position: absolute !important; /* Use absolute positioning for proper context */
|
|
538
558
|
}
|
|
539
559
|
|
|
@@ -543,13 +563,24 @@ input[type='time']:not(.visns-native-date-filter__input) {
|
|
|
543
563
|
top: 50% !important;
|
|
544
564
|
left: 50% !important;
|
|
545
565
|
transform: translate(-50%, -50%) !important;
|
|
546
|
-
z-index:
|
|
566
|
+
z-index: var(--datepicker-z, 1300) !important;
|
|
547
567
|
}
|
|
548
568
|
|
|
549
569
|
/* Portal mode for modal contexts */
|
|
550
570
|
.react-datepicker-popper.portal-mode {
|
|
551
571
|
position: fixed !important;
|
|
552
|
-
z-index:
|
|
572
|
+
z-index: var(--datepicker-z, 1300) !important;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* The host DatePickerPortal appends to <body>. It had no rule at all, so it
|
|
576
|
+
was a `z-index: auto` box at the end of the document — which loses to the
|
|
577
|
+
dialog's 1200 however late it comes in the DOM, because a positioned
|
|
578
|
+
ancestor with a real z-index wins over an auto one regardless of order. The
|
|
579
|
+
popper inside it carries its own z-index, but only once this container
|
|
580
|
+
stops being the thing that flattens it. */
|
|
581
|
+
.datepicker-portal-container {
|
|
582
|
+
position: relative;
|
|
583
|
+
z-index: var(--datepicker-z, 1300);
|
|
553
584
|
}
|
|
554
585
|
|
|
555
586
|
.react-datepicker {
|
|
@@ -562,7 +593,7 @@ input[type='time']:not(.visns-native-date-filter__input) {
|
|
|
562
593
|
}
|
|
563
594
|
|
|
564
595
|
.react-datepicker-portal {
|
|
565
|
-
z-index:
|
|
596
|
+
z-index: var(--datepicker-z, 1300) !important;
|
|
566
597
|
}
|
|
567
598
|
|
|
568
599
|
/* 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
|
+
};
|