@visns-studio/visns-components 6.6.3 → 6.15.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 (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -66,10 +66,10 @@ export const buildFieldSources = (model, entityId, addedRelations) => {
66
66
  * only offered where the contract allows them.
67
67
  */
68
68
  const SemanticFieldsStep = ({
69
- model,
70
- entityId,
71
- addedRelations,
72
- selections,
69
+ model = null,
70
+ entityId = '',
71
+ addedRelations = [],
72
+ selections = [],
73
73
  onToggleField,
74
74
  onChangeAggregate,
75
75
  onRemoveSelection,
@@ -327,11 +327,4 @@ SemanticFieldsStep.propTypes = {
327
327
  onMoveSelection: PropTypes.func.isRequired,
328
328
  };
329
329
 
330
- SemanticFieldsStep.defaultProps = {
331
- model: null,
332
- entityId: '',
333
- addedRelations: [],
334
- selections: [],
335
- };
336
-
337
330
  export default SemanticFieldsStep;
@@ -26,8 +26,8 @@ export const buildFilterFieldOptions = (model, entityId, addedRelations) =>
26
26
  }));
27
27
 
28
28
  const SemanticFilterCondition = ({
29
- model,
30
- entityId,
29
+ model = null,
30
+ entityId = '',
31
31
  condition,
32
32
  fieldGroups,
33
33
  onChange,
@@ -231,17 +231,12 @@ SemanticFilterCondition.propTypes = {
231
231
  onRemove: PropTypes.func.isRequired,
232
232
  };
233
233
 
234
- SemanticFilterCondition.defaultProps = {
235
- model: null,
236
- entityId: '',
237
- };
238
-
239
234
  const SemanticFilterGroup = ({
240
- model,
241
- entityId,
235
+ model = null,
236
+ entityId = '',
242
237
  group,
243
238
  fieldGroups,
244
- depth,
239
+ depth = 0,
245
240
  onChangeNode,
246
241
  onRemoveNode,
247
242
  onAddCondition,
@@ -355,12 +350,6 @@ SemanticFilterGroup.propTypes = {
355
350
  onAddGroup: PropTypes.func.isRequired,
356
351
  };
357
352
 
358
- SemanticFilterGroup.defaultProps = {
359
- model: null,
360
- entityId: '',
361
- depth: 0,
362
- };
363
-
364
353
  /**
365
354
  * Step 4 in semantic mode: build the filter tree.
366
355
  *
@@ -369,11 +358,11 @@ SemanticFilterGroup.defaultProps = {
369
358
  * become runtime parameters, whose labels can be edited below the tree.
370
359
  */
371
360
  const SemanticFiltersStep = ({
372
- model,
373
- entityId,
374
- addedRelations,
361
+ model = null,
362
+ entityId = '',
363
+ addedRelations = [],
375
364
  filterTree,
376
- parameters,
365
+ parameters = [],
377
366
  onChangeNode,
378
367
  onRemoveNode,
379
368
  onAddCondition,
@@ -473,11 +462,4 @@ SemanticFiltersStep.propTypes = {
473
462
  onChangeParameter: PropTypes.func.isRequired,
474
463
  };
475
464
 
476
- SemanticFiltersStep.defaultProps = {
477
- model: null,
478
- entityId: '',
479
- addedRelations: [],
480
- parameters: [],
481
- };
482
-
483
465
  export default SemanticFiltersStep;
@@ -12,11 +12,11 @@ import styles from '../../styles/ReportSemantic.module.scss';
12
12
  * excluded for the same reason: they are the summary, not the grouping key.
13
13
  */
14
14
  const SemanticGroupingStep = ({
15
- model,
16
- entityId,
17
- selections,
18
- groupBy,
19
- sort,
15
+ model = null,
16
+ entityId = '',
17
+ selections = [],
18
+ groupBy = [],
19
+ sort = [],
20
20
  onToggleGroupBy,
21
21
  onAddSort,
22
22
  onChangeSort,
@@ -191,12 +191,4 @@ SemanticGroupingStep.propTypes = {
191
191
  onRemoveSort: PropTypes.func.isRequired,
192
192
  };
193
193
 
194
- SemanticGroupingStep.defaultProps = {
195
- model: null,
196
- entityId: '',
197
- selections: [],
198
- groupBy: [],
199
- sort: [],
200
- };
201
-
202
194
  export default SemanticGroupingStep;
@@ -44,11 +44,11 @@ const slotType = (parameterType) => {
44
44
  * scalar.
45
45
  */
46
46
  const SemanticParameterPrompt = ({
47
- model,
48
- entityId,
49
- filterTree,
50
- parameters,
51
- values,
47
+ model = null,
48
+ entityId = '',
49
+ filterTree = null,
50
+ parameters = [],
51
+ values = {},
52
52
  onChange,
53
53
  }) => {
54
54
  if (!parameters || parameters.length === 0) return null;
@@ -146,12 +146,4 @@ SemanticParameterPrompt.propTypes = {
146
146
  onChange: PropTypes.func.isRequired,
147
147
  };
148
148
 
149
- SemanticParameterPrompt.defaultProps = {
150
- model: null,
151
- entityId: '',
152
- filterTree: null,
153
- parameters: [],
154
- values: {},
155
- };
156
-
157
149
  export default SemanticParameterPrompt;
@@ -69,26 +69,26 @@ const buildGroups = (rows, columns, groupKey) => {
69
69
  * review the results before saving or exporting.
70
70
  */
71
71
  const SemanticPreviewStep = ({
72
- model,
73
- entityId,
74
- selections,
75
- filterTree,
76
- parameters,
77
- parameterValues,
78
- groupBy,
79
- rows,
80
- total,
81
- page,
82
- pageSize,
83
- isLoading,
84
- error,
72
+ model = null,
73
+ entityId = '',
74
+ selections = [],
75
+ filterTree = null,
76
+ parameters = [],
77
+ parameterValues = {},
78
+ groupBy = [],
79
+ rows = [],
80
+ total = 0,
81
+ page = 1,
82
+ pageSize = 25,
83
+ isLoading = false,
84
+ error = '',
85
85
  onChangeParameterValue,
86
86
  onRun,
87
87
  onChangePage,
88
88
  onChangePageSize,
89
89
  onExport,
90
90
  onSave,
91
- canSave,
91
+ canSave = false,
92
92
  }) => {
93
93
  const columns = useMemo(
94
94
  () => buildPreviewColumns(model, entityId, selections),
@@ -332,21 +332,4 @@ SemanticPreviewStep.propTypes = {
332
332
  canSave: PropTypes.bool,
333
333
  };
334
334
 
335
- SemanticPreviewStep.defaultProps = {
336
- model: null,
337
- entityId: '',
338
- selections: [],
339
- filterTree: null,
340
- parameters: [],
341
- parameterValues: {},
342
- groupBy: [],
343
- rows: [],
344
- total: 0,
345
- page: 1,
346
- pageSize: 25,
347
- isLoading: false,
348
- error: '',
349
- canSave: false,
350
- };
351
-
352
335
  export default SemanticPreviewStep;
@@ -48,9 +48,9 @@ export const availableRelationPaths = (model, entityId, addedPaths) => {
48
48
  * whether they want it.
49
49
  */
50
50
  const SemanticRelationsStep = ({
51
- model,
52
- entityId,
53
- addedRelations,
51
+ model = null,
52
+ entityId = '',
53
+ addedRelations = [],
54
54
  onToggle,
55
55
  }) => {
56
56
  const options = availableRelationPaths(model, entityId, addedRelations);
@@ -145,10 +145,4 @@ SemanticRelationsStep.propTypes = {
145
145
  onToggle: PropTypes.func.isRequired,
146
146
  };
147
147
 
148
- SemanticRelationsStep.defaultProps = {
149
- model: null,
150
- entityId: '',
151
- addedRelations: [],
152
- };
153
-
154
148
  export default SemanticRelationsStep;
@@ -12,7 +12,7 @@ const NUMERIC_TYPES = ['number', 'money', 'percent'];
12
12
  * boolean -> yes/no select, money/number/percent -> numeric input,
13
13
  * everything else -> text.
14
14
  */
15
- const SemanticValueInput = ({ type, field, value, onChange, ariaLabel }) => {
15
+ const SemanticValueInput = ({ type = 'text', field = null, value = '', onChange, ariaLabel = 'Value' }) => {
16
16
  const commonProps = {
17
17
  className: styles.input,
18
18
  'aria-label': ariaLabel,
@@ -77,19 +77,12 @@ SemanticValueInput.propTypes = {
77
77
  ariaLabel: PropTypes.string,
78
78
  };
79
79
 
80
- SemanticValueInput.defaultProps = {
81
- type: 'text',
82
- field: null,
83
- value: '',
84
- ariaLabel: 'Value',
85
- };
86
-
87
80
  /**
88
81
  * Multi-value editor for the `in` / `not_in` operators. Enum fields get a
89
82
  * checkbox list of declared values; everything else gets a comma-separated
90
83
  * text box, which is the least surprising control for a free-text list.
91
84
  */
92
- export const SemanticValueListInput = ({ type, field, values, onChange }) => {
85
+ export const SemanticValueListInput = ({ type = 'text', field = null, values = [], onChange }) => {
93
86
  const selected = Array.isArray(values) ? values : [];
94
87
 
95
88
  if (type === 'enum') {
@@ -144,10 +137,4 @@ SemanticValueListInput.propTypes = {
144
137
  onChange: PropTypes.func.isRequired,
145
138
  };
146
139
 
147
- SemanticValueListInput.defaultProps = {
148
- type: 'text',
149
- field: null,
150
- values: [],
151
- };
152
-
153
140
  export default SemanticValueInput;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Which top-level nav item is the current page.
3
+ *
4
+ * Pulled out of Navigation's `updateNavItems` effect because it was two
5
+ * different answers depending on how many segments the URL happened to have,
6
+ * and neither of them was right:
7
+ *
8
+ * - Below three segments it asked `currentUrl.includes(navUrl)`, which is a
9
+ * substring test on a path. `/tasks` matched `/tasks-archive`, and every
10
+ * item whose url is a substring of another item's lit up together.
11
+ * - It only looked at an item's CHILDREN in the three-or-more-segment
12
+ * branch. So standing on `/reports` — one segment — the Admin item, whose
13
+ * own url is `/settings` and which owns `/reports` as a child, tested
14
+ * `'/reports'.includes('/settings')` and came back inactive. The bar had
15
+ * no current page at all on most of the pages it links to.
16
+ *
17
+ * One rule instead, at every depth: a path matches when it IS the item's path
18
+ * or sits under it as a whole segment, and an item is active when it matches
19
+ * itself or any of its children.
20
+ */
21
+
22
+ export const cleanUrl = (url) => {
23
+ if (!url) {
24
+ return '';
25
+ }
26
+ return url.startsWith('/') ? url : `/${url}`;
27
+ };
28
+
29
+ /**
30
+ * Segment-prefix match: `/tasks` covers `/tasks` and `/tasks/12`, and does
31
+ * NOT cover `/tasks-archive`. `/` is excluded deliberately — as a prefix it
32
+ * covers the entire application, so the dashboard is matched exactly instead.
33
+ */
34
+ const covers = (base, current) =>
35
+ !!base &&
36
+ base !== '/' &&
37
+ (current === base || current.startsWith(`${base}/`));
38
+
39
+ /**
40
+ * @param {string} navUrl the item's own url ('' for a pure parent).
41
+ * @param {string[]} childUrls the urls of its dropdown children, if any.
42
+ * @param {string} currentUrl the path being viewed.
43
+ */
44
+ export function isNavActive(navUrl, childUrls, currentUrl) {
45
+ const current = cleanUrl(currentUrl) || '/';
46
+ const base = cleanUrl(navUrl);
47
+
48
+ // The dashboard, which is reachable under both of its names.
49
+ if (base === '/') {
50
+ return current === '/' || current === '/dashboard';
51
+ }
52
+
53
+ if (covers(base, current)) {
54
+ return true;
55
+ }
56
+
57
+ return (childUrls || []).some((child) => covers(cleanUrl(child), current));
58
+ }
59
+
60
+ export default isNavActive;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Desktop alerts — the browser's own Notification API, nothing more.
3
+ *
4
+ * No service worker and no push subscription: this raises an OS-level toast
5
+ * only while a tab of the app is open, which is exactly the scope that was
6
+ * asked for. Staff who have closed the CRM get nothing, and that is the
7
+ * honest behaviour — promising more would need a push service, a VAPID key
8
+ * pair and a server that survives the tab being gone.
9
+ *
10
+ * Three gates, and all three must open before anything appears:
11
+ *
12
+ * supported the API exists (it does not on iOS Safari outside a
13
+ * home-screen app, and not in an insecure context);
14
+ * permission the user granted it, which only a click can ask for;
15
+ * enabled the user has not switched it off in the bell's dropdown.
16
+ *
17
+ * The fourth is subtler and matters more day to day: if the tab is already
18
+ * visible AND focused, nothing is raised. The in-app UI — the bell badge, the
19
+ * messages popover — is already showing the same thing, and an OS toast on
20
+ * top of it is noise. `force: true` overrides that for the rare caller that
21
+ * genuinely wants both.
22
+ *
23
+ * Everything here is a plain function over globals so it can be tested with a
24
+ * fake `Notification`, `document` and `localStorage` (tests/notify.test.mjs)
25
+ * — the same arrangement as the sms and vault helpers.
26
+ */
27
+
28
+ /** Where the user's on/off choice lives. Shared by the bell and the SMS badge. */
29
+ export const STORAGE_KEY = 'visns.desktopAlerts';
30
+
31
+ /** How long a toast lingers before it closes itself. */
32
+ export const AUTO_CLOSE_MS = 8000;
33
+
34
+ const ON = 'on';
35
+ const OFF = 'off';
36
+
37
+ /** The Notification constructor, or null. Read late — tests swap it in. */
38
+ const constructorRef = () => {
39
+ const ctor = globalThis?.Notification;
40
+
41
+ return typeof ctor === 'function' ? ctor : null;
42
+ };
43
+
44
+ const doc = () => (typeof globalThis?.document === 'undefined' ? null : globalThis.document);
45
+
46
+ const storage = () => {
47
+ try {
48
+ return globalThis?.localStorage ?? null;
49
+ } catch {
50
+ // Reading localStorage throws outright in some privacy modes.
51
+ return null;
52
+ }
53
+ };
54
+
55
+ /** Is the browser capable of this at all? */
56
+ export const isSupported = () => constructorRef() !== null;
57
+
58
+ /**
59
+ * 'granted' | 'denied' | 'default'.
60
+ *
61
+ * A browser that cannot do this is reported as 'denied' rather than some
62
+ * fourth value: every caller wants to know "can I show one?", and a fourth
63
+ * state only invites a missing branch. `isSupported()` distinguishes the two
64
+ * when the UI needs to explain itself.
65
+ */
66
+ export const getPermission = () => {
67
+ const ctor = constructorRef();
68
+
69
+ if (!ctor) return 'denied';
70
+
71
+ const permission = ctor.permission;
72
+
73
+ return permission === 'granted' || permission === 'denied' ? permission : 'default';
74
+ };
75
+
76
+ /**
77
+ * Ask. Must be called from a user gesture or browsers ignore it.
78
+ *
79
+ * Resolves rather than rejects, and copes with the old callback signature
80
+ * Safari shipped for years.
81
+ *
82
+ * @returns {Promise<'granted'|'denied'|'default'>}
83
+ */
84
+ export const requestPermission = () => {
85
+ const ctor = constructorRef();
86
+
87
+ if (!ctor) return Promise.resolve('denied');
88
+ if (ctor.permission === 'granted' || ctor.permission === 'denied') {
89
+ return Promise.resolve(ctor.permission);
90
+ }
91
+
92
+ return new Promise((resolve) => {
93
+ try {
94
+ const result = ctor.requestPermission((legacy) => resolve(legacy || 'default'));
95
+
96
+ if (result && typeof result.then === 'function') {
97
+ result.then((value) => resolve(value || 'default')).catch(() => resolve('denied'));
98
+ }
99
+ } catch {
100
+ resolve('denied');
101
+ }
102
+ });
103
+ };
104
+
105
+ /**
106
+ * The user's switch.
107
+ *
108
+ * Untouched means "on once permission is granted": someone who has just been
109
+ * through the browser's permission prompt has said yes twice already, and
110
+ * making them find a second toggle afterwards would be a trap.
111
+ */
112
+ export const isEnabled = () => {
113
+ const stored = (() => {
114
+ try {
115
+ return storage()?.getItem(STORAGE_KEY) ?? null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ })();
120
+
121
+ if (stored === ON) return true;
122
+ if (stored === OFF) return false;
123
+
124
+ return getPermission() === 'granted';
125
+ };
126
+
127
+ /** Persist the switch. Silent if storage is unavailable — it is a preference. */
128
+ export const setEnabled = (enabled) => {
129
+ try {
130
+ storage()?.setItem(STORAGE_KEY, enabled ? ON : OFF);
131
+ } catch {
132
+ /* private mode, quota, a locked-down iframe — none worth a toast */
133
+ }
134
+
135
+ return !!enabled;
136
+ };
137
+
138
+ /** Is the user already looking at us? */
139
+ const tabHasTheUser = () => {
140
+ const d = doc();
141
+
142
+ if (!d) return false;
143
+
144
+ const visible = d.visibilityState === 'visible';
145
+ const focused = typeof d.hasFocus === 'function' ? d.hasFocus() : false;
146
+
147
+ return visible && focused;
148
+ };
149
+
150
+ /**
151
+ * Raise one desktop alert.
152
+ *
153
+ * @param {object} options
154
+ * @param {string} options.title The bold line. Required.
155
+ * @param {string} [options.body] One or two lines under it.
156
+ * @param {string} [options.tag] Replaces an earlier toast with the same
157
+ * tag instead of stacking — pass a stable
158
+ * per-subject id ('sms-14', 'notification-…').
159
+ * @param {string} [options.icon] Icon URL.
160
+ * @param {string} [options.url] Where a click should land.
161
+ * @param {Function} [options.onClick] Overrides `url` entirely.
162
+ * @param {Function} [options.navigate] React Router's navigate, when there is one.
163
+ * @param {boolean} [options.force] Show even if the tab is focused.
164
+ *
165
+ * @returns {Notification|null} The toast, or null if any gate was closed.
166
+ */
167
+ export const notify = ({
168
+ title,
169
+ body,
170
+ tag,
171
+ icon,
172
+ url,
173
+ onClick,
174
+ navigate,
175
+ force = false,
176
+ } = {}) => {
177
+ const ctor = constructorRef();
178
+
179
+ if (!ctor || !title) return null;
180
+ if (getPermission() !== 'granted') return null;
181
+ if (!isEnabled()) return null;
182
+ if (!force && tabHasTheUser()) return null;
183
+
184
+ let toast;
185
+
186
+ try {
187
+ // Throws, rather than returning null, in an insecure context and in a
188
+ // few embedded webviews.
189
+ toast = new ctor(title, {
190
+ body,
191
+ tag,
192
+ icon,
193
+ // The tag already collapses duplicates; renotify would re-buzz for
194
+ // each one, which is how a busy inbox becomes a reason to turn the
195
+ // whole feature off.
196
+ renotify: false,
197
+ });
198
+ } catch {
199
+ return null;
200
+ }
201
+
202
+ toast.onclick = () => {
203
+ try {
204
+ globalThis?.focus?.();
205
+ } catch {
206
+ /* a background tab is not always allowed to raise itself */
207
+ }
208
+
209
+ try {
210
+ toast.close();
211
+ } catch {
212
+ /* already gone */
213
+ }
214
+
215
+ if (typeof onClick === 'function') {
216
+ onClick();
217
+
218
+ return;
219
+ }
220
+
221
+ if (!url) return;
222
+
223
+ if (typeof navigate === 'function') {
224
+ navigate(url);
225
+ } else {
226
+ globalThis?.location?.assign?.(url);
227
+ }
228
+ };
229
+
230
+ // Chrome on Windows keeps a toast in the tray until it is dismissed; eight
231
+ // seconds is long enough to read and short enough not to pile up.
232
+ try {
233
+ globalThis?.setTimeout?.(() => {
234
+ try {
235
+ toast.close();
236
+ } catch {
237
+ /* already gone */
238
+ }
239
+ }, AUTO_CLOSE_MS);
240
+ } catch {
241
+ /* no timer host: the toast simply stays until dismissed */
242
+ }
243
+
244
+ return toast;
245
+ };
246
+
247
+ export default {
248
+ AUTO_CLOSE_MS,
249
+ STORAGE_KEY,
250
+ getPermission,
251
+ isEnabled,
252
+ isSupported,
253
+ notify,
254
+ requestPermission,
255
+ setEnabled,
256
+ };
@@ -380,10 +380,20 @@ class SketchField extends PureComponent {
380
380
  return (
381
381
  <div
382
382
  className={className}
383
- ref={(c) => (this._container = c)}
383
+ // Block bodies, not expression bodies. React 19 treats a
384
+ // callback ref's return value as a cleanup function, so
385
+ // `ref={c => (this._x = c)}` — which returns the assigned
386
+ // element — hands React a non-function to call on unmount.
387
+ ref={(c) => {
388
+ this._container = c;
389
+ }}
384
390
  style={canvasDivStyle}
385
391
  >
386
- <canvas ref={(c) => (this._canvas = c)}>
392
+ <canvas
393
+ ref={(c) => {
394
+ this._canvas = c;
395
+ }}
396
+ >
387
397
  Sorry, Canvas HTML5 element is not supported by your
388
398
  browser.
389
399
  </canvas>