@visns-studio/visns-components 6.34.0 → 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 CHANGED
@@ -9,7 +9,20 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
- ## Recent Updates (v6.34.0)
12
+ ## Recent Updates (v6.34.3)
13
+
14
+ ### The auth fields' values no longer sit on top of their labels
15
+
16
+ The sign-in, reset, verify and 2FA screens draw a floating label inside the
17
+ input, and `_authShell.scss` sizes those inputs for it — a 3.35rem box with a
18
+ top-heavy padding so the value clears the label. That rule is `.field input`,
19
+ (0,1,1), and `global.css` sizes every text input through a twenty-clause
20
+ `input[type]:not(...)` chain that outranks it. So the chain won: the box shrank
21
+ to `--field-height`, the padding went horizontal-only, and the value was drawn
22
+ straight through the label. The six auth inputs now carry `visns-auth-input`,
23
+ and both global chains (sizing and focus) exclude it, the same opt-out
24
+ `searchInput` and `filterInput` already have. Apps that keep a global input
25
+ rule of their own can exclude the same class.
13
26
 
14
27
  ### The shell layout is on `<html>`, where anything can read it
15
28
 
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.34.0",
96
+ "version": "6.34.3",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -524,7 +524,7 @@ const Login = ({
524
524
  value={auth.email}
525
525
  onChange={handleChange}
526
526
  tabIndex="1"
527
- className={authClass.username}
527
+ className={`visns-auth-input ${authClass.username}`.trim()}
528
528
  autoComplete="username"
529
529
  placeholder=" "
530
530
  disabled={isBusy}
@@ -540,7 +540,7 @@ const Login = ({
540
540
  value={auth.password}
541
541
  onChange={handleChange}
542
542
  tabIndex="2"
543
- className={authClass.password}
543
+ className={`visns-auth-input ${authClass.password}`.trim()}
544
544
  autoComplete="current-password"
545
545
  placeholder=" "
546
546
  disabled={isBusy}
@@ -136,7 +136,7 @@ const Reset = ({
136
136
  setEmailClass('');
137
137
  }}
138
138
  tabIndex="1"
139
- className={emailClass}
139
+ className={`visns-auth-input ${emailClass}`.trim()}
140
140
  autoComplete="username"
141
141
  placeholder=" "
142
142
  autoFocus
@@ -455,7 +455,7 @@ const TwoFactorAuth = ({
455
455
  value={verificationCode}
456
456
  onChange={handleChange}
457
457
  tabIndex="1"
458
- className={codeClass}
458
+ className={`visns-auth-input ${codeClass}`.trim()}
459
459
  placeholder=" "
460
460
  autoComplete="one-time-code"
461
461
  inputMode="numeric"
@@ -201,7 +201,7 @@ const Verify = ({
201
201
  value={password}
202
202
  onChange={handleInputChange}
203
203
  tabIndex="1"
204
- className={passwordClass}
204
+ className={`visns-auth-input ${passwordClass}`.trim()}
205
205
  autoComplete="new-password"
206
206
  placeholder=" "
207
207
  autoFocus
@@ -237,7 +237,7 @@ const Verify = ({
237
237
  value={passwordRepeat}
238
238
  onChange={handleInputChange}
239
239
  tabIndex="2"
240
- className={passwordRepeatClass}
240
+ className={`visns-auth-input ${passwordRepeatClass}`.trim()}
241
241
  autoComplete="new-password"
242
242
  placeholder=" "
243
243
  disabled={isLoading}
@@ -0,0 +1,486 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ AlignCenter,
4
+ AlignLeft,
5
+ AlignRight,
6
+ ArrowDown,
7
+ ArrowUp,
8
+ Bold,
9
+ ImageUp,
10
+ Italic,
11
+ Link2,
12
+ List,
13
+ Trash2,
14
+ } from 'lucide-react';
15
+
16
+ import styles from '../styles/EmailCampaigns.module.scss';
17
+ import { api, BLOCK_TYPES, newBlock } from './emailCampaignApi';
18
+
19
+ /**
20
+ * The campaign's content, as an ordered list of blocks.
21
+ *
22
+ * Each block is a card with its own fields and the same three controls: move
23
+ * up, move down, remove. The server turns the blocks into the email
24
+ * (EmailCampaignRenderer) and cleans the rich text again on the way, so nothing
25
+ * here is trusted for safety; it is only an editor.
26
+ *
27
+ * Merge tags are typed as `{first_name}` and friends. The "Insert" menu puts one
28
+ * at the cursor so nobody has to remember the spelling.
29
+ */
30
+ const TAG_LABELS = {
31
+ first_name: 'First name',
32
+ last_name: 'Last name',
33
+ name: 'Full name',
34
+ company: 'Company',
35
+ email: 'Email',
36
+ };
37
+
38
+ const BlockEditor = ({ blocks, onChange, routes, disabled, tags = Object.keys(TAG_LABELS) }) => {
39
+ const update = (index, patch) =>
40
+ onChange(blocks.map((block, at) => (at === index ? { ...block, ...patch } : block)));
41
+
42
+ const move = (index, by) => {
43
+ const target = index + by;
44
+
45
+ if (target < 0 || target >= blocks.length) return;
46
+
47
+ const next = [...blocks];
48
+ [next[index], next[target]] = [next[target], next[index]];
49
+ onChange(next);
50
+ };
51
+
52
+ const remove = (index) => onChange(blocks.filter((block, at) => at !== index));
53
+
54
+ return (
55
+ <div className={styles.blocks}>
56
+ {blocks.length === 0 && (
57
+ <p className={styles.quiet}>No content yet. Add a heading or some text below.</p>
58
+ )}
59
+
60
+ {blocks.map((block, index) => (
61
+ <div className={styles.block} key={index}>
62
+ <div className={styles.blockHead}>
63
+ <span className={styles.blockType}>
64
+ {BLOCK_TYPES.find((entry) => entry.type === block.type)?.label || block.type}
65
+ </span>
66
+ <span className={styles.blockTools}>
67
+ <IconButton
68
+ title="Move this block up"
69
+ disabled={disabled || index === 0}
70
+ onClick={() => move(index, -1)}
71
+ >
72
+ <ArrowUp size={14} aria-hidden="true" />
73
+ </IconButton>
74
+ <IconButton
75
+ title="Move this block down"
76
+ disabled={disabled || index === blocks.length - 1}
77
+ onClick={() => move(index, 1)}
78
+ >
79
+ <ArrowDown size={14} aria-hidden="true" />
80
+ </IconButton>
81
+ <IconButton
82
+ title="Remove this block"
83
+ danger
84
+ disabled={disabled}
85
+ onClick={() => remove(index)}
86
+ >
87
+ <Trash2 size={14} aria-hidden="true" />
88
+ </IconButton>
89
+ </span>
90
+ </div>
91
+
92
+ <BlockFields
93
+ block={block}
94
+ disabled={disabled}
95
+ tags={tags}
96
+ routes={routes}
97
+ onChange={(patch) => update(index, patch)}
98
+ />
99
+ </div>
100
+ ))}
101
+
102
+ {!disabled && (
103
+ <div className={styles.addRow}>
104
+ <span className={styles.addLabel}>Add</span>
105
+ {BLOCK_TYPES.map((entry) => (
106
+ <button
107
+ key={entry.type}
108
+ type="button"
109
+ className={styles.addButton}
110
+ title={`Add a ${entry.label.toLowerCase()} block at the end`}
111
+ onClick={() => onChange([...blocks, newBlock(entry.type)])}
112
+ >
113
+ {entry.label}
114
+ </button>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ };
121
+
122
+ const BlockFields = ({ block, onChange, disabled, tags, routes }) => {
123
+ switch (block.type) {
124
+ case 'heading':
125
+ return (
126
+ <div className={styles.fieldRow}>
127
+ <TextWithTags
128
+ value={block.text || ''}
129
+ placeholder="Heading"
130
+ disabled={disabled}
131
+ tags={tags}
132
+ onChange={(text) => onChange({ text })}
133
+ />
134
+ <select
135
+ className={styles.select}
136
+ value={block.level === 2 ? 2 : 1}
137
+ disabled={disabled}
138
+ title="Heading size"
139
+ onChange={(event) => onChange({ level: Number(event.target.value) })}
140
+ >
141
+ <option value={1}>Large</option>
142
+ <option value={2}>Small</option>
143
+ </select>
144
+ <AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
145
+ </div>
146
+ );
147
+
148
+ case 'text':
149
+ return (
150
+ <RichText
151
+ value={block.html || ''}
152
+ disabled={disabled}
153
+ tags={tags}
154
+ onChange={(html) => onChange({ html })}
155
+ />
156
+ );
157
+
158
+ case 'columns':
159
+ return (
160
+ <div className={styles.columnsEdit}>
161
+ <RichText value={block.left || ''} disabled={disabled} tags={tags} onChange={(left) => onChange({ left })} />
162
+ <RichText value={block.right || ''} disabled={disabled} tags={tags} onChange={(right) => onChange({ right })} />
163
+ </div>
164
+ );
165
+
166
+ case 'button':
167
+ return (
168
+ <div className={styles.fieldRow}>
169
+ <TextWithTags
170
+ value={block.label || ''}
171
+ placeholder="Button label"
172
+ disabled={disabled}
173
+ tags={tags}
174
+ onChange={(label) => onChange({ label })}
175
+ />
176
+ <input
177
+ type="url"
178
+ className={styles.input}
179
+ value={block.url || ''}
180
+ placeholder="https://"
181
+ disabled={disabled}
182
+ title="Where the button goes"
183
+ onChange={(event) => onChange({ url: event.target.value })}
184
+ />
185
+ <AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
186
+ </div>
187
+ );
188
+
189
+ case 'image':
190
+ return <ImageFields block={block} disabled={disabled} routes={routes} onChange={onChange} />;
191
+
192
+ case 'spacer':
193
+ return (
194
+ <label className={styles.inline}>
195
+ Height
196
+ <input
197
+ type="range"
198
+ min={8}
199
+ max={80}
200
+ step={4}
201
+ value={block.height || 24}
202
+ disabled={disabled}
203
+ onChange={(event) => onChange({ height: Number(event.target.value) })}
204
+ />
205
+ <span className={styles.quiet}>{block.height || 24}px</span>
206
+ </label>
207
+ );
208
+
209
+ case 'divider':
210
+ return <p className={styles.quiet}>A thin line across the email.</p>;
211
+
212
+ default:
213
+ return null;
214
+ }
215
+ };
216
+
217
+ const ImageFields = ({ block, onChange, disabled, routes }) => {
218
+ const [uploading, setUploading] = useState(false);
219
+ const [error, setError] = useState('');
220
+ const file = useRef(null);
221
+
222
+ const upload = async (picked) => {
223
+ if (!picked) return;
224
+
225
+ const body = new FormData();
226
+ body.append('image', picked);
227
+ setUploading(true);
228
+ setError('');
229
+
230
+ const result = await api(routes.images, 'POST', body);
231
+
232
+ setUploading(false);
233
+
234
+ if (result.ok && result.data?.url) {
235
+ onChange({ url: result.data.url, alt: block.alt || picked.name.replace(/\.[a-z0-9]+$/i, '') });
236
+ } else {
237
+ setError(result.message || 'The image could not be uploaded.');
238
+ }
239
+ };
240
+
241
+ return (
242
+ <div className={styles.imageEdit}>
243
+ {block.url ? (
244
+ <img src={block.url} alt="" className={styles.imageThumb} />
245
+ ) : (
246
+ <div className={styles.imageEmpty}>No image yet</div>
247
+ )}
248
+
249
+ <div className={styles.imageFields}>
250
+ <div className={styles.fieldRow}>
251
+ <button
252
+ type="button"
253
+ className={styles.button}
254
+ disabled={disabled || uploading}
255
+ title="Upload a JPEG, PNG, GIF or WebP from this computer"
256
+ onClick={() => file.current?.click()}
257
+ >
258
+ <ImageUp size={14} aria-hidden="true" />
259
+ {uploading ? 'Uploading…' : 'Upload image'}
260
+ </button>
261
+ <input
262
+ ref={file}
263
+ type="file"
264
+ accept="image/jpeg,image/png,image/gif,image/webp"
265
+ hidden
266
+ onChange={(event) => upload(event.target.files?.[0])}
267
+ />
268
+ <input
269
+ type="url"
270
+ className={styles.input}
271
+ value={block.url || ''}
272
+ placeholder="or paste an image address (https://…)"
273
+ disabled={disabled}
274
+ onChange={(event) => onChange({ url: event.target.value })}
275
+ />
276
+ </div>
277
+ <div className={styles.fieldRow}>
278
+ <input
279
+ type="text"
280
+ className={styles.input}
281
+ value={block.alt || ''}
282
+ placeholder="Describe the image (shown if it does not load)"
283
+ disabled={disabled}
284
+ onChange={(event) => onChange({ alt: event.target.value })}
285
+ />
286
+ <input
287
+ type="url"
288
+ className={styles.input}
289
+ value={block.href || ''}
290
+ placeholder="Link when clicked (optional)"
291
+ disabled={disabled}
292
+ onChange={(event) => onChange({ href: event.target.value })}
293
+ />
294
+ </div>
295
+ <label className={styles.inline}>
296
+ Width
297
+ <input
298
+ type="range"
299
+ min={20}
300
+ max={100}
301
+ step={5}
302
+ value={block.width || 100}
303
+ disabled={disabled}
304
+ onChange={(event) => onChange({ width: Number(event.target.value) })}
305
+ />
306
+ <span className={styles.quiet}>{block.width || 100}%</span>
307
+ <AlignPicker value={block.align || 'center'} disabled={disabled} onChange={(align) => onChange({ align })} />
308
+ </label>
309
+ {error && <p className={styles.fieldError}>{error}</p>}
310
+ </div>
311
+ </div>
312
+ );
313
+ };
314
+
315
+ /** A single-line field with a merge-tag menu beside it. */
316
+ const TextWithTags = ({ value, onChange, placeholder, disabled, tags }) => {
317
+ const input = useRef(null);
318
+
319
+ const insert = (tag) => {
320
+ const element = input.current;
321
+ const token = `{${tag}}`;
322
+
323
+ if (!element) {
324
+ onChange(`${value}${token}`);
325
+
326
+ return;
327
+ }
328
+
329
+ const start = element.selectionStart ?? value.length;
330
+ const end = element.selectionEnd ?? value.length;
331
+ onChange(value.slice(0, start) + token + value.slice(end));
332
+ };
333
+
334
+ return (
335
+ <span className={styles.withTags}>
336
+ <input
337
+ ref={input}
338
+ type="text"
339
+ className={styles.input}
340
+ value={value}
341
+ placeholder={placeholder}
342
+ disabled={disabled}
343
+ onChange={(event) => onChange(event.target.value)}
344
+ />
345
+ <TagMenu tags={tags} disabled={disabled} onPick={insert} />
346
+ </span>
347
+ );
348
+ };
349
+
350
+ const TagMenu = ({ tags, onPick, disabled }) => (
351
+ <select
352
+ className={styles.tagSelect}
353
+ value=""
354
+ disabled={disabled}
355
+ title="Insert a personal detail, filled in for each recipient"
356
+ aria-label="Insert a personal detail"
357
+ onChange={(event) => {
358
+ if (event.target.value) onPick(event.target.value);
359
+ }}
360
+ >
361
+ <option value="">Insert…</option>
362
+ {tags.map((tag) => (
363
+ <option key={tag} value={tag}>
364
+ {TAG_LABELS[tag] || tag}
365
+ </option>
366
+ ))}
367
+ </select>
368
+ );
369
+
370
+ /**
371
+ * A small rich-text box: bold, italic, link, bullet list, and a merge tag.
372
+ *
373
+ * `contentEditable` with `execCommand`: deprecated on paper and supported by
374
+ * every browser in practice, and a whole editor library for five buttons would
375
+ * be a bundle every reader of every page pays for. The HTML it produces is
376
+ * cleaned on the server before it goes anywhere.
377
+ *
378
+ * The box is written to only when the value changes from OUTSIDE (a template
379
+ * loaded, a reset) — writing on every keystroke would throw the cursor to the
380
+ * start of the box.
381
+ */
382
+ const RichText = ({ value, onChange, disabled, tags }) => {
383
+ const box = useRef(null);
384
+ const last = useRef(value);
385
+
386
+ useEffect(() => {
387
+ if (box.current && value !== last.current) {
388
+ box.current.innerHTML = value;
389
+ last.current = value;
390
+ }
391
+ }, [value]);
392
+
393
+ useEffect(() => {
394
+ if (box.current) box.current.innerHTML = value;
395
+ // First paint only; later outside changes go through the effect above.
396
+ // eslint-disable-next-line react-hooks/exhaustive-deps
397
+ }, []);
398
+
399
+ const emit = () => {
400
+ const html = box.current?.innerHTML ?? '';
401
+ last.current = html;
402
+ onChange(html);
403
+ };
404
+
405
+ const run = (command, argument = null) => {
406
+ box.current?.focus();
407
+ document.execCommand(command, false, argument);
408
+ emit();
409
+ };
410
+
411
+ const link = () => {
412
+ const url = window.prompt('Link address (https://…)');
413
+
414
+ if (url && /^(https?:\/\/|mailto:)/i.test(url.trim())) {
415
+ run('createLink', url.trim());
416
+ }
417
+ };
418
+
419
+ return (
420
+ <div className={styles.rich}>
421
+ <div className={styles.richBar}>
422
+ <IconButton title="Bold" disabled={disabled} onClick={() => run('bold')}>
423
+ <Bold size={14} aria-hidden="true" />
424
+ </IconButton>
425
+ <IconButton title="Italic" disabled={disabled} onClick={() => run('italic')}>
426
+ <Italic size={14} aria-hidden="true" />
427
+ </IconButton>
428
+ <IconButton title="Link the selected words" disabled={disabled} onClick={link}>
429
+ <Link2 size={14} aria-hidden="true" />
430
+ </IconButton>
431
+ <IconButton title="Bulleted list" disabled={disabled} onClick={() => run('insertUnorderedList')}>
432
+ <List size={14} aria-hidden="true" />
433
+ </IconButton>
434
+ <TagMenu tags={tags} disabled={disabled} onPick={(tag) => run('insertText', `{${tag}}`)} />
435
+ </div>
436
+ <div
437
+ ref={box}
438
+ className={styles.richBox}
439
+ contentEditable={!disabled}
440
+ suppressContentEditableWarning
441
+ role="textbox"
442
+ aria-multiline="true"
443
+ onInput={emit}
444
+ onBlur={emit}
445
+ />
446
+ </div>
447
+ );
448
+ };
449
+
450
+ const AlignPicker = ({ value = 'left', onChange, disabled }) => (
451
+ <span className={styles.alignPicker} role="group" aria-label="Alignment">
452
+ {[
453
+ ['left', AlignLeft],
454
+ ['center', AlignCenter],
455
+ ['right', AlignRight],
456
+ ].map(([key, Icon]) => (
457
+ <IconButton
458
+ key={key}
459
+ title={`Align ${key}`}
460
+ pressed={value === key}
461
+ disabled={disabled}
462
+ onClick={() => onChange(key)}
463
+ >
464
+ <Icon size={14} aria-hidden="true" />
465
+ </IconButton>
466
+ ))}
467
+ </span>
468
+ );
469
+
470
+ const IconButton = ({ title, onClick, disabled, danger, pressed, children }) => (
471
+ <button
472
+ type="button"
473
+ className={[styles.iconButton, danger ? styles.iconDanger : '', pressed ? styles.iconOn : '']
474
+ .filter(Boolean)
475
+ .join(' ')}
476
+ title={title}
477
+ aria-label={title}
478
+ aria-pressed={pressed === undefined ? undefined : pressed}
479
+ disabled={disabled}
480
+ onClick={onClick}
481
+ >
482
+ {children}
483
+ </button>
484
+ );
485
+
486
+ export default BlockEditor;