@siratik/wp-component-library 0.1.0 → 0.1.2

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 (96) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +51 -5
  3. package/components/attachment-image/README.md +118 -0
  4. package/components/attachment-image/index.js +105 -0
  5. package/components/attributes-panel/README.md +92 -0
  6. package/components/attributes-panel/index.js +324 -0
  7. package/components/badge/README.md +61 -0
  8. package/components/badge/index.js +105 -0
  9. package/components/block-appender/README.md +158 -0
  10. package/components/block-appender/index.js +135 -0
  11. package/components/block-extension/index.js +214 -0
  12. package/components/business-hours/SpecialHoursControl.js +211 -0
  13. package/components/business-hours/WeeklyHoursControl.js +419 -0
  14. package/components/business-hours/index.js +8 -0
  15. package/components/button/README.md +172 -0
  16. package/components/button/index.js +84 -0
  17. package/components/captcha-placeholder/CloudflareTurnstile.js +111 -0
  18. package/components/captcha-placeholder/GoogleRecaptcha.js +132 -0
  19. package/components/captcha-placeholder/HCaptcha.js +99 -0
  20. package/components/captcha-placeholder/README.md +228 -0
  21. package/components/captcha-placeholder/index.js +39 -0
  22. package/components/captcha-placeholder/styles.js +33 -0
  23. package/components/geocoder/AddressEntryControl.js +390 -0
  24. package/components/geocoder/GeocoderSearchControl.js +353 -0
  25. package/components/geocoder/countryOptions.js +66 -0
  26. package/components/geocoder/geocoder.js +400 -0
  27. package/components/geocoder/index.js +16 -0
  28. package/components/icon-picker/Icon.js +53 -0
  29. package/components/icon-picker/IconPicker.js +70 -0
  30. package/components/icon-picker/IconPickerModal.js +474 -0
  31. package/components/icon-picker/IconPickerToolbarButton.js +60 -0
  32. package/components/icon-picker/InlineIconPicker.js +92 -0
  33. package/components/icon-picker/api.js +181 -0
  34. package/components/icon-picker/config-bootstrap.js +108 -0
  35. package/components/icon-picker/hooks.js +157 -0
  36. package/components/icon-picker/index.js +39 -0
  37. package/components/icon-picker/load-script.js +33 -0
  38. package/components/icon-picker/loaders.js +79 -0
  39. package/components/icon-picker/store.js +223 -0
  40. package/components/icon-picker/styles.js +232 -0
  41. package/components/icon-picker/utils.js +138 -0
  42. package/components/layout/README.md +268 -0
  43. package/components/link-tools-panel/README.md +49 -0
  44. package/components/link-tools-panel/index.js +198 -0
  45. package/components/link-tools-panel/styles.js +90 -0
  46. package/components/media/README.md +288 -0
  47. package/components/media/SettingsMediaUpload.js +187 -0
  48. package/components/media/editor-media-upload.js +65 -0
  49. package/components/media/image-controls.js +196 -0
  50. package/components/media/index.js +13 -0
  51. package/components/media/inspector-media-upload.js +290 -0
  52. package/components/media/toolbar-media-upload.js +53 -0
  53. package/components/media/utils/aspect-ratios.js +118 -0
  54. package/components/meta/MetaPanel.js +75 -0
  55. package/components/meta/README.md +46 -0
  56. package/components/meta/index.js +6 -0
  57. package/components/meta/meta-gated-editor.js +282 -0
  58. package/components/query/README.md +340 -0
  59. package/components/query/cardTemplatePartPanel.js +69 -0
  60. package/components/query/cardTemplatePartSelect.js +165 -0
  61. package/components/query/cardTemplatePartUtils.js +119 -0
  62. package/components/query/columnCountControl.js +63 -0
  63. package/components/query/displayTypeSelect.js +52 -0
  64. package/components/query/examples.js +166 -0
  65. package/components/query/hideCurrentPostControl.js +47 -0
  66. package/components/query/index.js +20 -0
  67. package/components/query/manualPostSelector.js +125 -0
  68. package/components/query/manualTermSelector.js +141 -0
  69. package/components/query/orderBySelect.js +95 -0
  70. package/components/query/postTypeSelect.js +67 -0
  71. package/components/query/postsPerPageControl.js +51 -0
  72. package/components/query/selectionModeControl.js +61 -0
  73. package/components/query/taxonomySelect.js +139 -0
  74. package/components/query/utils.js +75 -0
  75. package/components/repeater/README.md +159 -0
  76. package/components/repeater/index.js +829 -0
  77. package/components/segmented-bar/README.md +30 -0
  78. package/components/segmented-bar/index.js +68 -0
  79. package/components/sortable-select/README.md +153 -0
  80. package/components/sortable-select/custom-sortable-token.js +127 -0
  81. package/components/sortable-select/index.js +693 -0
  82. package/components/style-picker/README.md +36 -0
  83. package/components/style-picker/index.js +92 -0
  84. package/components/variable-field/README.md +299 -0
  85. package/components/variable-field/index.js +592 -0
  86. package/components/variable-field/value-to-chip-html.js +38 -0
  87. package/components/variable-inserter/README.md +68 -0
  88. package/components/variable-inserter/index.js +168 -0
  89. package/constants/statusColors.js +16 -6
  90. package/editor.js +16 -0
  91. package/index.js +127 -0
  92. package/package.json +27 -16
  93. package/utils/kitaris-localize.js +128 -0
  94. package/utils/slug-aware-color-setter.js +27 -0
  95. package/build/index.asset.php +0 -20
  96. package/build/index.js +0 -1233
@@ -0,0 +1,592 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { BaseControl } from "@wordpress/components";
5
+ import {
6
+ forwardRef,
7
+ useCallback,
8
+ useEffect,
9
+ useImperativeHandle,
10
+ useRef,
11
+ useState,
12
+ } from "@wordpress/element";
13
+ import { __ } from "@wordpress/i18n";
14
+ import { DOWN, ENTER, ESCAPE, UP } from "@wordpress/keycodes";
15
+
16
+ /**
17
+ * Internal dependencies
18
+ */
19
+ import styled from "@emotion/styled";
20
+ import { formatVariableToken, valueToChipHTML } from "./value-to-chip-html";
21
+
22
+ // Styled components
23
+ const StyledWrapper = styled.div`
24
+ position: relative;
25
+ `;
26
+
27
+ const StyledFieldContainer = styled.div`
28
+ width: 100%;
29
+ min-height: ${props => props.rows > 1 ? `${(props.rows * 28) + 16}px` : '36px'};
30
+ padding: 6px 8px;
31
+ border: 1px solid #757575;
32
+ border-radius: 2px;
33
+ background: #fff;
34
+ font-size: 13px;
35
+ font-family: inherit;
36
+ margin: 0;
37
+ line-height: 24px;
38
+ cursor: text;
39
+ display: inline-block;
40
+ word-wrap: break-word;
41
+ white-space: pre-wrap;
42
+ overflow-wrap: break-word;
43
+ position: relative;
44
+ box-sizing: border-box;
45
+
46
+ &:empty:before {
47
+ content: attr(data-placeholder);
48
+ color: #757575;
49
+ pointer-events: none;
50
+ display: block;
51
+ position: absolute;
52
+ top: 0;
53
+ left: 0;
54
+ right: 0;
55
+ padding: inherit;
56
+ word-wrap: break-word;
57
+ white-space: normal;
58
+ line-height: 24px;
59
+ }
60
+
61
+ &:focus {
62
+ border-color: var(--wp-admin-theme-color, #007cba);
63
+ box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #007cba);
64
+ outline: 2px solid transparent;
65
+ caret-color: var(--wp-admin-theme-color, #007cba);
66
+ }
67
+
68
+ &.is-disabled {
69
+ background: #f0f0f0;
70
+ cursor: not-allowed;
71
+ }
72
+
73
+ .variable-chip {
74
+ display: inline-block;
75
+ background: #e0e0e0;
76
+ color: #1e1e1e;
77
+ border-radius: 4px;
78
+ padding: 2px 6px;
79
+ margin: 0 2px;
80
+ font-size: 12px;
81
+ white-space: nowrap;
82
+ user-select: none;
83
+ position: relative;
84
+ padding-right: 20px;
85
+ vertical-align: middle;
86
+ line-height: 20px;
87
+ transition: background-color 0.15s ease;
88
+
89
+ &:hover {
90
+ background: #d0d0d0;
91
+ }
92
+
93
+ &::before,
94
+ &::after {
95
+ content: '\\200B';
96
+ display: inline;
97
+ width: 0;
98
+ }
99
+ }
100
+
101
+ .chip-remove {
102
+ position: absolute;
103
+ right: 4px;
104
+ top: 50%;
105
+ transform: translateY(-50%);
106
+ cursor: pointer;
107
+ font-size: 14px;
108
+ line-height: 1;
109
+ opacity: 0.6;
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 14px;
114
+ height: 14px;
115
+ }
116
+
117
+ .chip-remove:hover {
118
+ opacity: 1;
119
+ background: rgba(0, 0, 0, 0.1);
120
+ border-radius: 2px;
121
+ }
122
+ `;
123
+
124
+ const StyledSuggestionsContainer = styled.div`
125
+ position: absolute;
126
+ top: 100%;
127
+ left: 0;
128
+ right: 0;
129
+ z-index: 100100; /* Higher than WordPress panels (100000) */
130
+ margin-top: 4px;
131
+ background: #fff;
132
+ border: 1px solid #ccc;
133
+ border-radius: 2px;
134
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
135
+ animation: fadeIn 0.15s ease-in-out;
136
+
137
+ @keyframes fadeIn {
138
+ from {
139
+ opacity: 0;
140
+ transform: translateY(-4px);
141
+ }
142
+ to {
143
+ opacity: 1;
144
+ transform: translateY(0);
145
+ }
146
+ }
147
+ `;
148
+
149
+ const StyledSuggestions = styled.div`
150
+ max-height: 200px;
151
+ overflow-y: auto;
152
+ padding: 4px 0;
153
+ `;
154
+
155
+ const StyledSuggestion = styled.button`
156
+ display: block;
157
+ width: 100%;
158
+ padding: 6px 12px;
159
+ text-align: left;
160
+ border: none;
161
+ background: none;
162
+ cursor: pointer;
163
+ font-size: 13px;
164
+
165
+ &:hover,
166
+ &.is-selected {
167
+ background: var(--wp-admin-theme-color, #007cba);
168
+ color: #fff;
169
+ }
170
+ `;
171
+
172
+ // Hidden input for form submission
173
+ const StyledHiddenInput = styled.input`
174
+ position: absolute;
175
+ left: -9999px;
176
+ opacity: 0;
177
+ pointer-events: none;
178
+ `;
179
+
180
+ /**
181
+ * The selection in the node's own window, so it also works inside the editor iframe.
182
+ *
183
+ * @param {Node|null} node
184
+ * @returns {Selection}
185
+ */
186
+ const getSelectionFor = (node) =>
187
+ (node?.ownerDocument?.defaultView ?? window).getSelection();
188
+
189
+ /**
190
+ * VariableField Component
191
+ */
192
+ // eslint-disable-next-line no-shadow -- named for React DevTools.
193
+ const VariableField = forwardRef(function VariableField(
194
+ {
195
+ value = "",
196
+ onChange,
197
+ options = [],
198
+ getOptionLabel = (option) =>
199
+ option.label || option.name || option.title,
200
+ getOptionValue = (option) => option.value || option.id,
201
+ placeholder = __(
202
+ "Type @ to view variable suggestions",
203
+ "wp-component-library",
204
+ ),
205
+ label,
206
+ help,
207
+ isDisabled = false,
208
+ className = "",
209
+ rows = 1,
210
+ ...props
211
+ },
212
+ ref,
213
+ ) {
214
+ const [suggestions, setSuggestions] = useState([]);
215
+ const [showSuggestions, setShowSuggestions] = useState(false);
216
+ const [selectedIndex, setSelectedIndex] = useState(0);
217
+
218
+ const containerRef = useRef();
219
+ const wrapperRef = useRef();
220
+ const hiddenInputRef = useRef();
221
+ const resolveChipLabel = useCallback(
222
+ (varName) => {
223
+ const token = formatVariableToken(varName);
224
+ const match = options.find((option) => {
225
+ const raw = getOptionValue(option);
226
+ const normalized = raw
227
+ .replace(/^\{\{?/, "")
228
+ .replace(/\}\}?$/g, "");
229
+
230
+ return raw === token || normalized === varName;
231
+ });
232
+
233
+ return match ? getOptionLabel(match) : varName;
234
+ },
235
+ [options, getOptionLabel, getOptionValue],
236
+ );
237
+
238
+ // Convert value to HTML with chips
239
+ const valueToHTML = useCallback(
240
+ (val) => valueToChipHTML(val, resolveChipLabel),
241
+ [resolveChipLabel]
242
+ );
243
+
244
+ // Get plain text from container
245
+ const getPlainText = useCallback(() => {
246
+ if (!containerRef.current) { return ""; }
247
+
248
+ let text = "";
249
+ const nodes = containerRef.current.childNodes;
250
+
251
+ nodes.forEach(node => {
252
+ if (node.nodeType === window.Node.TEXT_NODE) {
253
+ // Remove zero-width spaces from the text
254
+ text += node.textContent.replace(/\u200B/g, '');
255
+ } else if (node.classList && node.classList.contains("variable-chip")) {
256
+ const varName = node.getAttribute('data-variable') || node.firstChild.textContent;
257
+ text += formatVariableToken(varName.replace(/^\{\{?|\}\}?$/g, ""));
258
+ }
259
+ });
260
+
261
+ return text.trim();
262
+ }, []);
263
+
264
+ // Initialize content
265
+ useEffect(() => {
266
+ if (containerRef.current && containerRef.current.ownerDocument.activeElement !== containerRef.current) {
267
+ const currentHTML = containerRef.current.innerHTML;
268
+ const newHTML = valueToHTML(value);
269
+ // Only update if content actually changed
270
+ if (currentHTML !== newHTML) {
271
+ containerRef.current.innerHTML = newHTML;
272
+ }
273
+ }
274
+ }, [value, valueToHTML]);
275
+
276
+ // Handle input changes
277
+ const handleInput = useCallback(() => {
278
+ const selection = getSelectionFor(containerRef.current);
279
+
280
+ const plainValue = getPlainText();
281
+
282
+ // Check for @ symbol - reuse existing selection
283
+ if (selection.rangeCount > 0) {
284
+ const range = selection.getRangeAt(0);
285
+ const container = range.startContainer;
286
+
287
+ // Only check for @ in text nodes, not in chips
288
+ if (container.nodeType === window.Node.TEXT_NODE) {
289
+ const textContent = container.textContent;
290
+ const offset = range.startOffset;
291
+ const textBeforeCursor = textContent.substring(0, offset);
292
+ const lastAt = textBeforeCursor.lastIndexOf("@");
293
+
294
+ if (lastAt !== -1) {
295
+ const searchTerm = textBeforeCursor.substring(lastAt + 1).toLowerCase();
296
+
297
+ if (searchTerm === "") {
298
+ // Just typed @
299
+ setSuggestions(options);
300
+ setShowSuggestions(true);
301
+ setSelectedIndex(0);
302
+ } else {
303
+ // Typing after @
304
+ const filtered = options.filter((option) => {
305
+ const optionLabel = getOptionLabel(option).toLowerCase();
306
+ return optionLabel.includes(searchTerm);
307
+ });
308
+ setSuggestions(filtered);
309
+ setShowSuggestions(filtered.length > 0);
310
+ setSelectedIndex(0);
311
+ }
312
+ } else {
313
+ setShowSuggestions(false);
314
+ }
315
+ } else {
316
+ setShowSuggestions(false);
317
+ }
318
+ }
319
+
320
+ onChange(plainValue);
321
+ if (hiddenInputRef.current) {
322
+ hiddenInputRef.current.value = plainValue;
323
+ }
324
+ }, [getPlainText, onChange, options, getOptionLabel]);
325
+
326
+ // Handle selecting a suggestion
327
+ const selectSuggestion = useCallback((option) => {
328
+ const selection = getSelectionFor(containerRef.current);
329
+
330
+ if (selection.rangeCount > 0) {
331
+ const range = selection.getRangeAt(0);
332
+ const container = range.startContainer;
333
+
334
+ if (container.nodeType === window.Node.TEXT_NODE) {
335
+ const textContent = container.textContent;
336
+ const offset = range.startOffset;
337
+ const textBeforeCursor = textContent.substring(0, offset);
338
+ const lastAt = textBeforeCursor.lastIndexOf("@");
339
+
340
+ if (lastAt !== -1) {
341
+ // Replace @text with variable in the current text node
342
+ const beforeAt = textContent.substring(0, lastAt);
343
+ const afterCursor = textContent.substring(offset);
344
+
345
+ // Build new value with variable
346
+ const optionValue = getOptionValue(option);
347
+ const cleanValue = optionValue
348
+ .replace(/^\{\{?/, "")
349
+ .replace(/\}\}?$/g, "");
350
+ const varValue = formatVariableToken(cleanValue);
351
+
352
+ // Replace text node content
353
+ container.textContent = beforeAt + varValue + " " + afterCursor;
354
+
355
+ // Trigger change
356
+ const newValue = getPlainText();
357
+ onChange(newValue);
358
+
359
+ // Re-render with chips and restore focus
360
+ containerRef.current.innerHTML = valueToHTML(newValue);
361
+
362
+ // Place cursor after the inserted variable
363
+ const allNodes = Array.from(containerRef.current.childNodes);
364
+ let targetNode = null;
365
+ let targetOffset = 0;
366
+
367
+ // Find the last text node or create one if needed
368
+ for (let i = allNodes.length - 1; i >= 0; i--) {
369
+ if (allNodes[i].nodeType === window.Node.TEXT_NODE) {
370
+ targetNode = allNodes[i];
371
+ targetOffset = targetNode.textContent.length;
372
+ break;
373
+ }
374
+ }
375
+
376
+ if (targetNode) {
377
+ const sel = getSelectionFor(containerRef.current);
378
+ const newRange = document.createRange();
379
+ newRange.setStart(targetNode, targetOffset);
380
+ newRange.collapse(true);
381
+ sel.removeAllRanges();
382
+ sel.addRange(newRange);
383
+ }
384
+
385
+ // Keep focus on the field
386
+ containerRef.current.focus();
387
+ }
388
+ }
389
+ }
390
+
391
+ setShowSuggestions(false);
392
+ }, [getOptionValue, onChange, valueToHTML, getPlainText]);
393
+
394
+ // Handle keyboard navigation
395
+ const handleKeyDown = useCallback((e) => {
396
+ if (showSuggestions && suggestions.length > 0) {
397
+ switch (e.keyCode) {
398
+ case UP:
399
+ e.preventDefault();
400
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
401
+ return;
402
+ case DOWN:
403
+ e.preventDefault();
404
+ setSelectedIndex((prev) =>
405
+ Math.min(suggestions.length - 1, prev + 1),
406
+ );
407
+ return;
408
+ case ENTER:
409
+ e.preventDefault();
410
+ if (suggestions[selectedIndex]) {
411
+ selectSuggestion(suggestions[selectedIndex]);
412
+ }
413
+ return;
414
+ case ESCAPE:
415
+ e.preventDefault();
416
+ setShowSuggestions(false);
417
+ return;
418
+ }
419
+ }
420
+
421
+ // Handle backspace on chip boundaries
422
+ if (e.key === 'Backspace') {
423
+ const selection = getSelectionFor(containerRef.current);
424
+ if (selection.rangeCount > 0) {
425
+ const range = selection.getRangeAt(0);
426
+ if (range.collapsed && range.startOffset === 0) {
427
+ const node = range.startContainer;
428
+ if (node.previousSibling && node.previousSibling.classList?.contains('variable-chip')) {
429
+ e.preventDefault();
430
+ node.previousSibling.remove();
431
+ const newValue = getPlainText();
432
+ onChange(newValue);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }, [showSuggestions, suggestions, selectedIndex, selectSuggestion, getPlainText, onChange]);
438
+
439
+ // Handle paste — strip HTML and insert plain text only
440
+ const handlePaste = useCallback((e) => {
441
+ e.preventDefault();
442
+ const text = e.clipboardData.getData("text/plain");
443
+ if (!text) { return; }
444
+
445
+ const selection = getSelectionFor(containerRef.current);
446
+ if (!selection.rangeCount) { return; }
447
+
448
+ const range = selection.getRangeAt(0);
449
+ range.deleteContents();
450
+ range.insertNode(document.createTextNode(text));
451
+
452
+ // Move cursor to end of inserted text
453
+ range.collapse(false);
454
+ selection.removeAllRanges();
455
+ selection.addRange(range);
456
+
457
+ // Trigger change
458
+ const newValue = getPlainText();
459
+ onChange(newValue);
460
+ if (hiddenInputRef.current) {
461
+ hiddenInputRef.current.value = newValue;
462
+ }
463
+ }, [getPlainText, onChange]);
464
+
465
+ // Handle chip removal
466
+ useEffect(() => {
467
+ const handleClick = (e) => {
468
+ if (e.target.classList.contains('chip-remove')) {
469
+ e.preventDefault();
470
+ e.stopPropagation();
471
+ const chip = e.target.parentElement;
472
+
473
+ // Save cursor position before removal
474
+ const selection = getSelectionFor(containerRef.current);
475
+ const focusNode = selection.focusNode;
476
+ const focusOffset = selection.focusOffset;
477
+
478
+ chip.remove();
479
+ const newValue = getPlainText();
480
+ onChange(newValue);
481
+ if (hiddenInputRef.current) {
482
+ hiddenInputRef.current.value = newValue;
483
+ }
484
+
485
+ // Restore focus to the field
486
+ containerRef.current.focus();
487
+
488
+ // Try to restore cursor position
489
+ if (focusNode && containerRef.current.contains(focusNode.parentNode)) {
490
+ const newRange = document.createRange();
491
+ newRange.setStart(focusNode, Math.min(focusOffset, focusNode.textContent.length));
492
+ newRange.collapse(true);
493
+ selection.removeAllRanges();
494
+ selection.addRange(newRange);
495
+ }
496
+ }
497
+ };
498
+
499
+ const container = containerRef.current;
500
+ if (container) {
501
+ container.addEventListener('click', handleClick);
502
+ return () => container.removeEventListener('click', handleClick);
503
+ }
504
+ }, [getPlainText, onChange]);
505
+
506
+ // Close suggestions when clicking outside
507
+ useEffect(() => {
508
+ const handleClickOutside = (event) => {
509
+ if (
510
+ wrapperRef.current &&
511
+ !wrapperRef.current.contains(event.target)
512
+ ) {
513
+ setShowSuggestions(false);
514
+ }
515
+ };
516
+
517
+ document.addEventListener("mousedown", handleClickOutside);
518
+ return () =>
519
+ document.removeEventListener("mousedown", handleClickOutside);
520
+ }, []);
521
+
522
+ // Expose focus method to parent
523
+ useImperativeHandle(
524
+ ref,
525
+ () => ({
526
+ focus: () => {
527
+ containerRef.current?.focus();
528
+ },
529
+ }),
530
+ [],
531
+ );
532
+
533
+ return (
534
+ // eslint-disable-next-line @wordpress/no-base-control-with-label-without-id -- contenteditable field; see lint report.
535
+ <BaseControl
536
+ __nextHasNoMarginBottom
537
+ __next40pxDefaultSize
538
+ label={label}
539
+ help={help}
540
+ className={`variable-field ${className}`}
541
+ >
542
+ <StyledWrapper ref={wrapperRef}>
543
+ <StyledHiddenInput
544
+ ref={hiddenInputRef}
545
+ type="hidden"
546
+ value={value}
547
+ name={props.name}
548
+ />
549
+
550
+ <StyledFieldContainer
551
+ ref={containerRef}
552
+ contentEditable={!isDisabled}
553
+ suppressContentEditableWarning
554
+ className={isDisabled ? "is-disabled" : ""}
555
+ onInput={handleInput}
556
+ onKeyDown={handleKeyDown}
557
+ onPaste={handlePaste}
558
+ spellCheck={false}
559
+ data-placeholder={!value ? placeholder : ""}
560
+ rows={rows}
561
+ />
562
+
563
+ {showSuggestions && suggestions.length > 0 && (
564
+ <StyledSuggestionsContainer>
565
+ <StyledSuggestions>
566
+ {suggestions.map((option, index) => (
567
+ <StyledSuggestion
568
+ key={getOptionValue(option)}
569
+ className={
570
+ index === selectedIndex
571
+ ? "is-selected"
572
+ : ""
573
+ }
574
+ onMouseDown={(e) => {
575
+ e.preventDefault();
576
+ e.stopPropagation();
577
+ selectSuggestion(option);
578
+ }}
579
+ onMouseEnter={() => setSelectedIndex(index)}
580
+ >
581
+ {getOptionLabel(option)}
582
+ </StyledSuggestion>
583
+ ))}
584
+ </StyledSuggestions>
585
+ </StyledSuggestionsContainer>
586
+ )}
587
+ </StyledWrapper>
588
+ </BaseControl>
589
+ );
590
+ });
591
+
592
+ export { VariableField };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Variable tokens and the editor markup built from them. Kept free of imports
3
+ * so it can be unit-tested without the component's WordPress dependencies.
4
+ */
5
+ const VARIABLE_TOKEN_PATTERN =
6
+ /\{\{([a-zA-Z0-9_.-]+)\}\}|\{([a-zA-Z0-9_.-]+)\}/g;
7
+
8
+ export const formatVariableToken = (name) => `{{${name}}}`;
9
+
10
+ // Text and labels are user data; only the chip markup itself is HTML.
11
+ const escapeText = (text) =>
12
+ String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+
14
+ /**
15
+ * Editor markup for a value: its text escaped, each variable token as a chip.
16
+ *
17
+ * @param {string} val Stored value, e.g. "{{title}} | {{site_name}}".
18
+ * @param {Function} resolveChipLabel Variable name → chip label.
19
+ * @return {string} HTML for the contenteditable container.
20
+ */
21
+ export const valueToChipHTML = (val, resolveChipLabel) => {
22
+ if (!val) { return ""; }
23
+ // Add zero-width spaces between elements for better cursor positioning
24
+ let html = escapeText(val).replace(
25
+ VARIABLE_TOKEN_PATTERN,
26
+ (match, doubleName, singleName) => {
27
+ const varName = doubleName || singleName;
28
+ const chipLabel = escapeText(resolveChipLabel(varName));
29
+
30
+ return `<span class="variable-chip" contenteditable="false" data-variable="${varName}">${chipLabel}<span class="chip-remove">×</span></span>`;
31
+ },
32
+ );
33
+ // Ensure there's always an editable space at the end
34
+ if (!html.endsWith('\u200B')) {
35
+ html += '\u200B';
36
+ }
37
+ return html;
38
+ };
@@ -0,0 +1,68 @@
1
+ # VariableInserter Component
2
+
3
+ A reusable component for inserting variable tags into text fields. Similar to Yoast's variable inserter but more flexible and customizable.
4
+
5
+ ## Usage
6
+
7
+ ```jsx
8
+ import VariableInserter from "@siratik/wp-component-library/components/variable-inserter";
9
+
10
+ const variables = [
11
+ { label: "Site Name", value: "{site_name}" },
12
+ { label: "Post Title", value: "{title}" },
13
+ { label: "Post Excerpt", value: "{excerpt}" },
14
+ ];
15
+
16
+ <VariableInserter
17
+ variables={variables}
18
+ currentValue={currentValue}
19
+ onChange={setValue}
20
+ placeholder="Insert Variable"
21
+ />;
22
+ ```
23
+
24
+ ## Props
25
+
26
+ | Prop | Type | Default | Description |
27
+ | -------------- | -------- | ------------------- | ------------------------------------------------------------- |
28
+ | `variables` | Array | `[]` | Array of variable objects with `label` and `value` properties |
29
+ | `currentValue` | string | `""` | Current value of the text field |
30
+ | `onChange` | Function | - | Function to call when a variable is inserted |
31
+ | `buttonProps` | Object | `{}` | Props to pass to the button component |
32
+ | `placeholder` | string | `"Insert Variable"` | Placeholder text for the button |
33
+ | `className` | string | `""` | Additional CSS classes |
34
+ | `size` | string | `"small"` | Button size (`small`, `medium`, `large`) |
35
+ | `variant` | string | `"tertiary"` | Button variant (`primary`, `secondary`, `tertiary`) |
36
+
37
+ ## Example with TextControl
38
+
39
+ ```jsx
40
+ import { TextControl } from "@wordpress/components";
41
+ import VariableInserter from "@siratik/wp-component-library/components/variable-inserter";
42
+
43
+ const [value, setValue] = useState("");
44
+
45
+ <Flex gap={2} align="flex-end">
46
+ <TextControl label="SEO Title" value={value} onChange={setValue} />
47
+ <VariableInserter
48
+ variables={seoVariables}
49
+ currentValue={value}
50
+ onChange={setValue}
51
+ />
52
+ </Flex>;
53
+ ```
54
+
55
+ ## Variable Object Structure
56
+
57
+ Each variable in the `variables` array should have:
58
+
59
+ ```jsx
60
+ {
61
+ label: "Human readable name",
62
+ value: "{variable_tag}"
63
+ }
64
+ ```
65
+
66
+ ## Styling
67
+
68
+ The component includes built-in styles that work with WordPress admin theme. It supports both light and dark modes.