@visns-studio/visns-components 6.34.0 → 6.35.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.
- package/README.md +37 -1
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -7
- package/src/components/auth/Login.jsx +2 -2
- 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/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/sms/SmsCampaigns.jsx +34 -4
- package/src/components/sms/SmsInbox.jsx +2 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Sms.module.scss +33 -4
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +14 -2
- package/src/index.js +4 -0
|
@@ -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;
|