@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.
Files changed (53) hide show
  1. package/README.md +483 -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 +11 -4
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/Reset.jsx +1 -1
  13. package/src/components/auth/TwoFactorAuth.jsx +1 -1
  14. package/src/components/auth/Verify.jsx +2 -2
  15. package/src/components/auth/authEndpoints.js +43 -0
  16. package/src/components/auth/passkeyClient.js +102 -0
  17. package/src/components/auth/passkeyPrompt.js +408 -0
  18. package/src/components/auth/profileLayout.js +45 -0
  19. package/src/components/controls/DataGridSearch.jsx +16 -2
  20. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  21. package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
  22. package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
  23. package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
  24. package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
  25. package/src/components/emailCampaigns/EmailLists.jsx +505 -0
  26. package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
  27. package/src/components/generic/ActionButtons.jsx +1 -1
  28. package/src/components/generic/GenericAuth.jsx +197 -21
  29. package/src/components/generic/GenericDetail.jsx +95 -3
  30. package/src/components/generic/GenericMain.jsx +7 -0
  31. package/src/components/generic/StandardModal.jsx +71 -158
  32. package/src/components/sms/SmsCampaigns.jsx +2517 -0
  33. package/src/components/sms/SmsInbox.jsx +20 -5
  34. package/src/components/sms/SmsLineSettings.jsx +2 -5
  35. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  36. package/src/components/sms/smsEndpoints.js +23 -0
  37. package/src/components/styles/DataGrid.module.scss +22 -0
  38. package/src/components/styles/EmailCampaigns.module.scss +922 -0
  39. package/src/components/styles/Form.module.scss +100 -2
  40. package/src/components/styles/Navigation.module.scss +322 -5
  41. package/src/components/styles/Notification.module.scss +140 -8
  42. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  43. package/src/components/styles/Profile.module.scss +157 -0
  44. package/src/components/styles/Sms.module.scss +484 -4
  45. package/src/components/styles/StandardModal.module.scss +273 -0
  46. package/src/components/styles/Vault.module.scss +5 -3
  47. package/src/components/styles/global.css +38 -7
  48. package/src/components/utils/navCollapsed.js +201 -0
  49. package/src/components/utils/rowActionSettings.js +76 -0
  50. package/src/components/utils/usePasskeysEnabled.js +84 -0
  51. package/src/components/utils/useShellLayout.js +131 -0
  52. package/src/index.js +107 -1
  53. package/src/utils/rememberTab.js +70 -0
@@ -1,6 +1,10 @@
1
- import React, { useState, useEffect, useCallback, useId, useRef } from 'react';
1
+ import React, { useEffect, useCallback, useId, useRef } from 'react';
2
2
  import { X } from 'lucide-react';
3
3
  import formStyles from '../styles/Form.module.scss';
4
+ // The overlay's and the panel's own geometry. It was an inline style object
5
+ // built from a user-agent sniff; that file says why that had to stop, and it
6
+ // is also where the z-index order is written down.
7
+ import styles from '../styles/StandardModal.module.scss';
4
8
 
5
9
  /**
6
10
  * The sheet's entry animation.
@@ -24,6 +28,9 @@ const SHEET_KEYFRAMES = `
24
28
  }
25
29
  `;
26
30
 
31
+ /** The three panel widths the stylesheet defines. Anything else is 'medium'. */
32
+ const PANEL_SIZES = ['small', 'medium', 'large'];
33
+
27
34
  /**
28
35
  * StandardModal Component
29
36
  *
@@ -71,29 +78,6 @@ const StandardModal = ({
71
78
  showHeader = false,
72
79
  customStyles = {}
73
80
  }) => {
74
- // Device detection state
75
- const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
76
-
77
- // Device detection utility
78
- const getDeviceType = () => {
79
- const hasTouch = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0);
80
- const userAgent = typeof window !== 'undefined' ? navigator.userAgent : '';
81
-
82
- // Mobile detection: width + user agent + touch
83
- const isMobileUA = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
84
- const isMobileWidth = windowWidth <= 768;
85
- const isMobile = isMobileUA || (isMobileWidth && hasTouch);
86
-
87
- // Tablet detection: iPad or Android tablets or medium width with touch
88
- const isTabletUA = /iPad|Android(?!.*Mobile)/i.test(userAgent);
89
- const isTabletWidth = windowWidth > 768 && windowWidth <= 1024;
90
- const isTablet = isTabletUA || (isTabletWidth && hasTouch && !isMobile);
91
-
92
- return { isMobile, isTablet, isDesktop: !isMobile && !isTablet };
93
- };
94
-
95
- const { isMobile, isTablet } = getDeviceType();
96
-
97
81
  const titleId = useId();
98
82
  const panelRef = useRef(null);
99
83
  const returnFocusRef = useRef(null);
@@ -157,18 +141,6 @@ const StandardModal = ({
157
141
  }
158
142
  }, []);
159
143
 
160
- // Handle window resize
161
- useEffect(() => {
162
- if (typeof window === 'undefined') return;
163
-
164
- const handleResize = () => {
165
- setWindowWidth(window.innerWidth);
166
- };
167
-
168
- window.addEventListener('resize', handleResize);
169
- return () => window.removeEventListener('resize', handleResize);
170
- }, []);
171
-
172
144
  // Handle keyboard events
173
145
  const handleKeyDown = useCallback((e) => {
174
146
  if (!isOpen) return;
@@ -178,17 +150,36 @@ const StandardModal = ({
178
150
  }
179
151
  }, [isOpen, closeOnEscape, onClose]);
180
152
 
153
+ /*
154
+ * Escape, and the page-scroll lock.
155
+ *
156
+ * The lock used to put `overflow: unset` back rather than what it found,
157
+ * which is not the same thing whenever something else is already holding
158
+ * the page still — the navigation drawer's own lock, most obviously, since
159
+ * a record form can be opened from inside it on a phone. Closing the
160
+ * dialog released the drawer's lock too. The previous VALUE is captured
161
+ * and restored.
162
+ */
181
163
  useEffect(() => {
182
- if (isOpen) {
183
- document.addEventListener('keydown', handleKeyDown);
184
- document.body.style.overflow = 'hidden';
185
- } else {
186
- document.body.style.overflow = 'unset';
164
+ if (!isOpen) {
165
+ return undefined;
187
166
  }
188
167
 
168
+ document.addEventListener('keydown', handleKeyDown);
169
+
170
+ const previousOverflow = document.body.style.overflow;
171
+ const previousOverscroll = document.body.style.overscrollBehavior;
172
+
173
+ document.body.style.overflow = 'hidden';
174
+ // iOS keeps scrolling the document once a scroll inside the panel
175
+ // reaches its end. `overscroll-behavior: contain` on the panel stops
176
+ // the chain going up; this stops the document passing it on.
177
+ document.body.style.overscrollBehavior = 'none';
178
+
189
179
  return () => {
190
180
  document.removeEventListener('keydown', handleKeyDown);
191
- document.body.style.overflow = 'unset';
181
+ document.body.style.overflow = previousOverflow;
182
+ document.body.style.overscrollBehavior = previousOverscroll;
192
183
  };
193
184
  }, [isOpen, handleKeyDown]);
194
185
 
@@ -210,136 +201,57 @@ const StandardModal = ({
210
201
 
211
202
  const isSheet = variant === 'sheet';
212
203
 
213
- // Get overlay styles
214
- const getOverlayStyle = () => {
215
- const baseStyle = {
216
- position: 'fixed',
217
- top: 0,
218
- left: 0,
219
- right: 0,
220
- bottom: 0,
221
- // Was a flat 50% black. A scrim that heavy on a sheet buries the
222
- // list the sheet exists to keep visible, so the sheet dims less.
223
- backgroundColor: isSheet
224
- ? 'rgba(15, 23, 32, 0.32)'
225
- : 'rgba(0, 0, 0, 0.5)',
226
- display: 'flex',
227
- alignItems: isSheet
228
- ? 'stretch'
229
- : verticalAlign === 'top'
230
- ? 'flex-start'
231
- : 'center',
232
- // A sheet is pinned to the right edge; a modal is centred.
233
- justifyContent: isSheet ? 'flex-end' : 'center',
234
- zIndex: 1000,
235
- padding: isSheet ? 0 : '20px',
236
- ...customStyles.overlay
237
- };
238
-
239
- if (!isSheet && verticalAlign === 'top') {
240
- baseStyle.paddingTop = '5%';
241
- }
242
-
243
- return baseStyle;
244
- };
245
-
246
- /**
247
- * A sheet's width is FIXED, not a share of the window.
204
+ /*
205
+ * The overlay and the panel are CLASSED, not styled inline.
206
+ *
207
+ * Everything that was in `getOverlayStyle` / `getContentStyle` /
208
+ * `getSheetStyle` lives in StandardModal.module.scss now, which is what
209
+ * lets a phone get `100dvh` (with `100vh` behind it), a real bottom-sheet
210
+ * shape, and the right geometry on the FIRST paint rather than one resize
211
+ * listener later. That file carries the full reasoning, the phone and
212
+ * tablet breakpoints, and the z-index order.
248
213
  *
249
- * A form wants one comfortable measure. `80vw` gave it a different measure
250
- * on every monitor and stretched two-column rows into something unreadable
251
- * on a wide screen. Below the tablet breakpoint the sheet becomes
252
- * full-width, which is the only sensible shape on a phone.
214
+ * `customStyles` is still spread inline and therefore still wins over all
215
+ * of it, which is the contract the SMS composer and the gallery depend on.
253
216
  */
254
- const getSheetStyle = () => {
255
- const width = { small: 420, medium: 560, large: 800 }[size] ?? 560;
217
+ const overlayClasses = [
218
+ styles.overlay,
219
+ isSheet ? styles['overlay--sheet'] : styles['overlay--modal'],
220
+ !isSheet && verticalAlign === 'top' ? styles['overlay--top'] : '',
221
+ ]
222
+ .filter(Boolean)
223
+ .join(' ');
256
224
 
257
- return {
258
- position: 'relative',
259
- backgroundColor: 'white',
260
- width: isMobile ? '100vw' : `min(${width}px, 96vw)`,
261
- maxWidth: '100vw',
262
- height: '100vh',
263
- display: 'flex',
264
- flexDirection: 'column',
265
- borderRadius: 0,
266
- boxShadow: '-12px 0 40px -12px rgba(0, 0, 0, 0.28)',
267
- overflowY: 'auto',
268
- // Honours prefers-reduced-motion via the media query below; the
269
- // transform is the entry, and it is deliberately short.
270
- animation: 'visnsSheetIn 180ms cubic-bezier(0.32, 0.72, 0, 1)',
271
- ...customStyles.modal,
272
- };
273
- };
225
+ /* A size that is not one of the three is 'medium', the documented default.
226
+ Checked against the list rather than trusting `styles[...]` to be
227
+ undefined for an unknown key: a config typo would otherwise produce a
228
+ class name that exists in the markup and in no stylesheet, which is an
229
+ unsized panel — full-bleed on a desktop — and nothing to grep for. */
230
+ const sizeKey = PANEL_SIZES.includes(size) ? size : 'medium';
274
231
 
275
- // Get size-based styles for the modal content with device-aware positioning
276
- const getContentStyle = () => {
277
- if (isSheet) {
278
- return getSheetStyle();
279
- }
280
-
281
- const baseStyle = {
282
- position: 'relative',
283
- backgroundColor: 'white',
284
- borderRadius: '8px',
285
- boxShadow: '0 10px 30px rgba(0, 0, 0, 0.3)',
286
- maxHeight: isMobile || isTablet ? '85vh' : '90vh',
287
- overflowY: 'auto'
288
- };
289
-
290
- /*
291
- * customStyles.modal is spread LAST, after the size preset, because a
292
- * caller that bothers to pass one is overriding the default rather
293
- * than asking to be overridden by it. Spread before the preset (as it
294
- * once was) the width/minWidth/maxWidth a size sets could never be
295
- * touched - which is what the SMS compose modal needs: a form-shaped
296
- * width, and a visible overflow so its templates menu can open past
297
- * the modal's own edge.
298
- */
299
-
300
- // Apply size-based styles with device considerations
301
- switch (size) {
302
- case 'small':
303
- return {
304
- ...baseStyle,
305
- width: isMobile ? '95vw' : '400px',
306
- minWidth: isMobile ? '300px' : '400px',
307
- maxWidth: isMobile ? '95vw' : '600px',
308
- ...customStyles.modal
309
- };
310
- case 'large':
311
- return {
312
- ...baseStyle,
313
- width: '95vw',
314
- minWidth: isMobile ? '300px' : '800px',
315
- maxWidth: isMobile ? '95vw' : '1600px',
316
- ...customStyles.modal
317
- };
318
- case 'medium':
319
- default:
320
- return {
321
- ...baseStyle,
322
- width: isMobile ? '95vw' : '80vw',
323
- minWidth: isMobile ? '300px' : '600px',
324
- maxWidth: isMobile ? '95vw' : '1200px',
325
- ...customStyles.modal
326
- };
327
- }
328
- };
232
+ const panelClasses = [
233
+ styles.panel,
234
+ isSheet ? styles['panel--sheet'] : styles['panel--modal'],
235
+ styles[`panel--${sizeKey}`],
236
+ ]
237
+ .filter(Boolean)
238
+ .join(' ');
329
239
 
330
240
  if (!isOpen) {
331
241
  return null;
332
242
  }
333
243
 
334
244
  return (
335
- <div
336
- style={getOverlayStyle()}
245
+ <div
246
+ className={overlayClasses}
247
+ style={customStyles.overlay}
337
248
  onClick={handleBackdropClick}
338
249
  >
339
250
  <style>{SHEET_KEYFRAMES}</style>
340
- <div
251
+ <div
341
252
  ref={panelRef}
342
- style={getContentStyle()}
253
+ className={panelClasses}
254
+ style={customStyles.modal}
343
255
  onClick={(e) => e.stopPropagation()}
344
256
  /* This had none of these. Without `role="dialog"` and
345
257
  `aria-modal` a screen reader treats the panel as ordinary
@@ -363,6 +275,7 @@ const StandardModal = ({
363
275
  onClick={onClose}
364
276
  style={customStyles.closeButton}
365
277
  type="button"
278
+ title="Close this dialog without saving anything"
366
279
  aria-label="Close modal"
367
280
  >
368
281
  <X strokeWidth={1} size={24} />