@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.
- package/LICENSE +5 -0
- package/README.md +51 -5
- package/components/attachment-image/README.md +118 -0
- package/components/attachment-image/index.js +105 -0
- package/components/attributes-panel/README.md +92 -0
- package/components/attributes-panel/index.js +324 -0
- package/components/badge/README.md +61 -0
- package/components/badge/index.js +105 -0
- package/components/block-appender/README.md +158 -0
- package/components/block-appender/index.js +135 -0
- package/components/block-extension/index.js +214 -0
- package/components/business-hours/SpecialHoursControl.js +211 -0
- package/components/business-hours/WeeklyHoursControl.js +419 -0
- package/components/business-hours/index.js +8 -0
- package/components/button/README.md +172 -0
- package/components/button/index.js +84 -0
- package/components/captcha-placeholder/CloudflareTurnstile.js +111 -0
- package/components/captcha-placeholder/GoogleRecaptcha.js +132 -0
- package/components/captcha-placeholder/HCaptcha.js +99 -0
- package/components/captcha-placeholder/README.md +228 -0
- package/components/captcha-placeholder/index.js +39 -0
- package/components/captcha-placeholder/styles.js +33 -0
- package/components/geocoder/AddressEntryControl.js +390 -0
- package/components/geocoder/GeocoderSearchControl.js +353 -0
- package/components/geocoder/countryOptions.js +66 -0
- package/components/geocoder/geocoder.js +400 -0
- package/components/geocoder/index.js +16 -0
- package/components/icon-picker/Icon.js +53 -0
- package/components/icon-picker/IconPicker.js +70 -0
- package/components/icon-picker/IconPickerModal.js +474 -0
- package/components/icon-picker/IconPickerToolbarButton.js +60 -0
- package/components/icon-picker/InlineIconPicker.js +92 -0
- package/components/icon-picker/api.js +181 -0
- package/components/icon-picker/config-bootstrap.js +108 -0
- package/components/icon-picker/hooks.js +157 -0
- package/components/icon-picker/index.js +39 -0
- package/components/icon-picker/load-script.js +33 -0
- package/components/icon-picker/loaders.js +79 -0
- package/components/icon-picker/store.js +223 -0
- package/components/icon-picker/styles.js +232 -0
- package/components/icon-picker/utils.js +138 -0
- package/components/layout/README.md +268 -0
- package/components/link-tools-panel/README.md +49 -0
- package/components/link-tools-panel/index.js +198 -0
- package/components/link-tools-panel/styles.js +90 -0
- package/components/media/README.md +288 -0
- package/components/media/SettingsMediaUpload.js +187 -0
- package/components/media/editor-media-upload.js +65 -0
- package/components/media/image-controls.js +196 -0
- package/components/media/index.js +13 -0
- package/components/media/inspector-media-upload.js +290 -0
- package/components/media/toolbar-media-upload.js +53 -0
- package/components/media/utils/aspect-ratios.js +118 -0
- package/components/meta/MetaPanel.js +75 -0
- package/components/meta/README.md +46 -0
- package/components/meta/index.js +6 -0
- package/components/meta/meta-gated-editor.js +282 -0
- package/components/query/README.md +340 -0
- package/components/query/cardTemplatePartPanel.js +69 -0
- package/components/query/cardTemplatePartSelect.js +165 -0
- package/components/query/cardTemplatePartUtils.js +119 -0
- package/components/query/columnCountControl.js +63 -0
- package/components/query/displayTypeSelect.js +52 -0
- package/components/query/examples.js +166 -0
- package/components/query/hideCurrentPostControl.js +47 -0
- package/components/query/index.js +20 -0
- package/components/query/manualPostSelector.js +125 -0
- package/components/query/manualTermSelector.js +141 -0
- package/components/query/orderBySelect.js +95 -0
- package/components/query/postTypeSelect.js +67 -0
- package/components/query/postsPerPageControl.js +51 -0
- package/components/query/selectionModeControl.js +61 -0
- package/components/query/taxonomySelect.js +139 -0
- package/components/query/utils.js +75 -0
- package/components/repeater/README.md +159 -0
- package/components/repeater/index.js +829 -0
- package/components/segmented-bar/README.md +30 -0
- package/components/segmented-bar/index.js +68 -0
- package/components/sortable-select/README.md +153 -0
- package/components/sortable-select/custom-sortable-token.js +127 -0
- package/components/sortable-select/index.js +693 -0
- package/components/style-picker/README.md +36 -0
- package/components/style-picker/index.js +92 -0
- package/components/variable-field/README.md +299 -0
- package/components/variable-field/index.js +592 -0
- package/components/variable-field/value-to-chip-html.js +38 -0
- package/components/variable-inserter/README.md +68 -0
- package/components/variable-inserter/index.js +168 -0
- package/constants/statusColors.js +16 -6
- package/editor.js +16 -0
- package/index.js +127 -0
- package/package.json +27 -16
- package/utils/kitaris-localize.js +128 -0
- package/utils/slug-aware-color-setter.js +27 -0
- package/build/index.asset.php +0 -20
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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.
|