@siratik/wp-component-library 0.1.1 → 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 +43 -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 +1 -1
- package/editor.js +16 -0
- package/index.js +127 -0
- package/package.json +21 -13
- 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 -1242
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { BaseControl, Spinner } from "@wordpress/components";
|
|
5
|
+
import {
|
|
6
|
+
forwardRef,
|
|
7
|
+
memo,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from "@wordpress/element";
|
|
15
|
+
import { __ } from "@wordpress/i18n";
|
|
16
|
+
import { BACKSPACE, DOWN, ENTER, ESCAPE, UP } from "@wordpress/keycodes";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* External dependencies
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
closestCenter,
|
|
23
|
+
DndContext,
|
|
24
|
+
KeyboardSensor,
|
|
25
|
+
PointerSensor,
|
|
26
|
+
useSensor,
|
|
27
|
+
useSensors,
|
|
28
|
+
} from "@dnd-kit/core";
|
|
29
|
+
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
|
30
|
+
import {
|
|
31
|
+
arrayMove,
|
|
32
|
+
rectSortingStrategy,
|
|
33
|
+
SortableContext,
|
|
34
|
+
sortableKeyboardCoordinates,
|
|
35
|
+
} from "@dnd-kit/sortable";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Internal dependencies
|
|
39
|
+
*/
|
|
40
|
+
import CustomSortableToken from "./custom-sortable-token";
|
|
41
|
+
import styled from '@emotion/styled';
|
|
42
|
+
|
|
43
|
+
// Styled components
|
|
44
|
+
const StyledWrapper = styled.div`
|
|
45
|
+
position: relative;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// WordPress' 40px control height (__next40pxDefaultSize), as core's inputs.
|
|
49
|
+
const StyledInputWrapper = styled.div`
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-wrap: wrap;
|
|
53
|
+
gap: 4px;
|
|
54
|
+
align-items: center;
|
|
55
|
+
min-height: 40px;
|
|
56
|
+
padding: 4px 8px;
|
|
57
|
+
border: 1px solid #949494;
|
|
58
|
+
border-radius: 2px;
|
|
59
|
+
background: #fff;
|
|
60
|
+
|
|
61
|
+
&:focus-within {
|
|
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
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const StyledInput = styled.input`
|
|
69
|
+
flex: 1;
|
|
70
|
+
min-width: 60px;
|
|
71
|
+
border: none;
|
|
72
|
+
outline: none;
|
|
73
|
+
box-shadow: none !important;
|
|
74
|
+
background: transparent;
|
|
75
|
+
margin: 0;
|
|
76
|
+
font-size: 13px;
|
|
77
|
+
font-family: inherit;
|
|
78
|
+
|
|
79
|
+
// wp-admin's input[type="text"] (forms.css) outranks one class: the box
|
|
80
|
+
// is the wrapper's, so the field itself has none.
|
|
81
|
+
&& {
|
|
82
|
+
border: none;
|
|
83
|
+
padding: 0;
|
|
84
|
+
min-height: 0;
|
|
85
|
+
background: transparent;
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
const StyledTextarea = styled.textarea`
|
|
90
|
+
flex: 1;
|
|
91
|
+
min-width: 60px;
|
|
92
|
+
border: none;
|
|
93
|
+
outline: none;
|
|
94
|
+
box-shadow: none !important;
|
|
95
|
+
background: transparent;
|
|
96
|
+
margin: 0;
|
|
97
|
+
font-size: 13px;
|
|
98
|
+
font-family: inherit;
|
|
99
|
+
resize: vertical;
|
|
100
|
+
min-height: 80px;
|
|
101
|
+
max-height: 140px;
|
|
102
|
+
line-height: 1.4;
|
|
103
|
+
padding-top: 2px;
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const StyledSuggestionsContainer = styled.div`
|
|
107
|
+
position: absolute;
|
|
108
|
+
top: 100%;
|
|
109
|
+
left: 0;
|
|
110
|
+
right: 0;
|
|
111
|
+
z-index: 1000;
|
|
112
|
+
margin-top: 4px;
|
|
113
|
+
background: #fff;
|
|
114
|
+
border: 1px solid #ccc;
|
|
115
|
+
border-radius: 2px;
|
|
116
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const StyledSuggestions = styled.div`
|
|
120
|
+
max-height: 200px;
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
padding: 4px 0;
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const StyledSuggestion = styled.button`
|
|
126
|
+
display: block;
|
|
127
|
+
width: 100%;
|
|
128
|
+
padding: 6px 12px;
|
|
129
|
+
text-align: left;
|
|
130
|
+
border: none;
|
|
131
|
+
background: none;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
|
|
135
|
+
&:hover,
|
|
136
|
+
&.is-selected {
|
|
137
|
+
background: var(--wp-admin-theme-color, #007cba);
|
|
138
|
+
color: #fff;
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const StyledLoading = styled.div`
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
justify-content: center;
|
|
146
|
+
gap: 8px;
|
|
147
|
+
padding: 12px;
|
|
148
|
+
font-size: 13px;
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convert array of tokens to a properly spaced string
|
|
153
|
+
* @param {Array} tokens - Array of token objects
|
|
154
|
+
* @param {Function} getOptionLabel - Function to get label from token
|
|
155
|
+
* @param {Function} getOptionValue - Function to get value from token
|
|
156
|
+
* @returns {string} Properly spaced string
|
|
157
|
+
*/
|
|
158
|
+
function tokensToString(tokens, getOptionLabel, getOptionValue) {
|
|
159
|
+
return tokens.map((token, index) => {
|
|
160
|
+
const value = getOptionValue(token);
|
|
161
|
+
const label = getOptionLabel(token);
|
|
162
|
+
const displayValue = label || value;
|
|
163
|
+
const prevToken = tokens[index - 1];
|
|
164
|
+
|
|
165
|
+
// Special punctuation that shouldn't have spaces
|
|
166
|
+
const isPunctuation = /^[\-–—.,;:!?]$/.test(displayValue.trim());
|
|
167
|
+
|
|
168
|
+
let result = displayValue;
|
|
169
|
+
|
|
170
|
+
// Add space before this token if:
|
|
171
|
+
// - There's a previous token
|
|
172
|
+
// - This token doesn't start with space or punctuation
|
|
173
|
+
// - This token isn't punctuation itself
|
|
174
|
+
if (prevToken && !isPunctuation && !/^[\s\-–—.,;:!?]/.test(displayValue)) {
|
|
175
|
+
// Also check if previous token ends with punctuation
|
|
176
|
+
const prevValue = getOptionLabel(prevToken) || getOptionValue(prevToken);
|
|
177
|
+
if (!/[\s\-–—.,;:!?]$/.test(prevValue)) {
|
|
178
|
+
result = ' ' + result;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}).join('');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Memoized input component to prevent re-renders during drag
|
|
188
|
+
*/
|
|
189
|
+
// eslint-disable-next-line no-shadow -- named for React DevTools.
|
|
190
|
+
const SearchInput = memo(function SearchInput({
|
|
191
|
+
inputRef,
|
|
192
|
+
inputValue,
|
|
193
|
+
setInputValue,
|
|
194
|
+
setShowSuggestions,
|
|
195
|
+
suggestions,
|
|
196
|
+
handleKeyDown,
|
|
197
|
+
handleBlur,
|
|
198
|
+
onFocusOpen,
|
|
199
|
+
onBlurClose,
|
|
200
|
+
placeholder,
|
|
201
|
+
isDisabled,
|
|
202
|
+
isDragging,
|
|
203
|
+
inputType = 'text',
|
|
204
|
+
}) {
|
|
205
|
+
const InputComponent = inputType === 'textarea' ? StyledTextarea : StyledInput;
|
|
206
|
+
const inputProps = {
|
|
207
|
+
ref: inputRef,
|
|
208
|
+
value: inputValue,
|
|
209
|
+
onChange: (e) => setInputValue(e.target.value),
|
|
210
|
+
onFocus: () => {
|
|
211
|
+
onFocusOpen();
|
|
212
|
+
setShowSuggestions(suggestions.length > 0);
|
|
213
|
+
},
|
|
214
|
+
onBlur: (e) => {
|
|
215
|
+
// Prevent blur if we're dragging
|
|
216
|
+
if (isDragging) {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
e.target.focus();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Call the blur handler
|
|
222
|
+
handleBlur();
|
|
223
|
+
// Hide suggestions after a small delay to allow clicks
|
|
224
|
+
onBlurClose();
|
|
225
|
+
},
|
|
226
|
+
onKeyDown: handleKeyDown,
|
|
227
|
+
placeholder,
|
|
228
|
+
disabled: isDisabled,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (inputType !== 'textarea') {
|
|
232
|
+
inputProps.type = 'text';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return <InputComponent {...inputProps} />;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Inner component that handles the actual select functionality
|
|
240
|
+
* Separated to prevent BaseControl from re-rendering
|
|
241
|
+
*/
|
|
242
|
+
// eslint-disable-next-line no-shadow -- named for React DevTools.
|
|
243
|
+
const SortableSelectInner = memo(forwardRef(function SortableSelectInner({
|
|
244
|
+
value,
|
|
245
|
+
onChange,
|
|
246
|
+
options,
|
|
247
|
+
loadOptions,
|
|
248
|
+
getOptionLabel,
|
|
249
|
+
getOptionValue,
|
|
250
|
+
placeholder,
|
|
251
|
+
isDisabled,
|
|
252
|
+
maxItems,
|
|
253
|
+
requirePrefix,
|
|
254
|
+
inputType,
|
|
255
|
+
}, ref) {
|
|
256
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
257
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
258
|
+
const [inputValue, setInputValue] = useState("");
|
|
259
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
260
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
261
|
+
const [, setLoadedOptions] = useState([]);
|
|
262
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
263
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
264
|
+
|
|
265
|
+
const inputRef = useRef();
|
|
266
|
+
const wrapperRef = useRef();
|
|
267
|
+
const blurTimer = useRef();
|
|
268
|
+
|
|
269
|
+
// Focus opens the list (before typing); leaving the field closes it after
|
|
270
|
+
// a moment, so a click on a suggestion still lands. Picking one focuses
|
|
271
|
+
// the field again, which cancels the close.
|
|
272
|
+
const handleFocusOpen = useCallback(() => {
|
|
273
|
+
clearTimeout(blurTimer.current);
|
|
274
|
+
setIsFocused(true);
|
|
275
|
+
}, []);
|
|
276
|
+
const handleBlurClose = useCallback(() => {
|
|
277
|
+
clearTimeout(blurTimer.current);
|
|
278
|
+
blurTimer.current = setTimeout(() => {
|
|
279
|
+
setShowSuggestions(false);
|
|
280
|
+
setIsFocused(false);
|
|
281
|
+
}, 200);
|
|
282
|
+
}, []);
|
|
283
|
+
useEffect(() => () => clearTimeout(blurTimer.current), []);
|
|
284
|
+
|
|
285
|
+
// Expose focus method to parent
|
|
286
|
+
useImperativeHandle(ref, () => ({
|
|
287
|
+
focus: () => {
|
|
288
|
+
inputRef.current?.focus();
|
|
289
|
+
}
|
|
290
|
+
}), []);
|
|
291
|
+
|
|
292
|
+
const sensors = useSensors(
|
|
293
|
+
useSensor(PointerSensor, {
|
|
294
|
+
activationConstraint: {
|
|
295
|
+
distance: 8,
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
useSensor(KeyboardSensor, {
|
|
299
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
// Load suggestions when input changes, or on focus before typing (all
|
|
305
|
+
// options; not with requirePrefix, where "@" opens the list).
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!inputValue && (requirePrefix || !isFocused)) {
|
|
308
|
+
setSuggestions([]);
|
|
309
|
+
setShowSuggestions(false);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// If requirePrefix is set, only search when input starts with @
|
|
314
|
+
const shouldSearch = requirePrefix ? inputValue.startsWith('@') : true;
|
|
315
|
+
const searchQuery = requirePrefix && inputValue.startsWith('@')
|
|
316
|
+
? inputValue.slice(1).trim()
|
|
317
|
+
: inputValue;
|
|
318
|
+
|
|
319
|
+
// Special case: if requirePrefix and user just typed "@", show all options
|
|
320
|
+
if (requirePrefix && inputValue === '@' && !loadOptions) {
|
|
321
|
+
setSuggestions(options);
|
|
322
|
+
setShowSuggestions(options.length > 0);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!shouldSearch) {
|
|
327
|
+
setSuggestions([]);
|
|
328
|
+
setShowSuggestions(false);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!loadOptions) {
|
|
333
|
+
// Use static options
|
|
334
|
+
const filtered = searchQuery
|
|
335
|
+
? options.filter((option) => {
|
|
336
|
+
const label = getOptionLabel(option);
|
|
337
|
+
return label.toLowerCase().includes(searchQuery.toLowerCase());
|
|
338
|
+
})
|
|
339
|
+
: options; // Show all options if searchQuery is empty (just @ typed)
|
|
340
|
+
|
|
341
|
+
// Filter out already selected options
|
|
342
|
+
const availableOptions = filtered.filter((option) => {
|
|
343
|
+
const optionValue = getOptionValue(option);
|
|
344
|
+
return !value.some(v => getOptionValue(v) === optionValue);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
setSuggestions(availableOptions);
|
|
348
|
+
setShowSuggestions(availableOptions.length > 0);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Load async options
|
|
353
|
+
const loadSuggestions = async () => {
|
|
354
|
+
setIsLoading(true);
|
|
355
|
+
try {
|
|
356
|
+
const results = await loadOptions(searchQuery);
|
|
357
|
+
// Filter out already selected
|
|
358
|
+
const filtered = results.filter((option) => {
|
|
359
|
+
const optionValue = getOptionValue(option);
|
|
360
|
+
return !value.some(
|
|
361
|
+
(v) => getOptionValue(v) === optionValue,
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
setLoadedOptions((prev) => {
|
|
366
|
+
// Avoid duplicates
|
|
367
|
+
const newOptions = [...prev];
|
|
368
|
+
results.forEach((option) => {
|
|
369
|
+
if (
|
|
370
|
+
!newOptions.some(
|
|
371
|
+
(o) =>
|
|
372
|
+
getOptionValue(o) ===
|
|
373
|
+
getOptionValue(option),
|
|
374
|
+
)
|
|
375
|
+
) {
|
|
376
|
+
newOptions.push(option);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return newOptions;
|
|
380
|
+
});
|
|
381
|
+
setSuggestions(filtered);
|
|
382
|
+
setShowSuggestions(filtered.length > 0);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
// eslint-disable-next-line no-console -- surface load failures to developers.
|
|
385
|
+
console.error("Error loading options:", error);
|
|
386
|
+
setSuggestions([]);
|
|
387
|
+
setShowSuggestions(false);
|
|
388
|
+
} finally {
|
|
389
|
+
setIsLoading(false);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Typing waits for a pause; opening loads at once.
|
|
394
|
+
const timeoutId = setTimeout(loadSuggestions, inputValue ? 300 : 0);
|
|
395
|
+
return () => clearTimeout(timeoutId);
|
|
396
|
+
}, [inputValue, isFocused, loadOptions]); // Removed dependencies causing infinite loop
|
|
397
|
+
|
|
398
|
+
// Handle adding a token
|
|
399
|
+
const handleAddToken = useCallback(
|
|
400
|
+
(option) => {
|
|
401
|
+
if (maxItems && value.length >= maxItems) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
onChange([...value, option]);
|
|
406
|
+
setInputValue("");
|
|
407
|
+
// The list stays open to pick more (the picked one leaves it).
|
|
408
|
+
inputRef.current?.focus();
|
|
409
|
+
},
|
|
410
|
+
[onChange, value, maxItems],
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Handle removing a token
|
|
414
|
+
const handleRemoveToken = useCallback(
|
|
415
|
+
(index) => {
|
|
416
|
+
const newValue = value.filter((_, i) => i !== index);
|
|
417
|
+
onChange(newValue);
|
|
418
|
+
},
|
|
419
|
+
[onChange, value],
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Handle keyboard navigation
|
|
423
|
+
const handleKeyDown = useCallback(
|
|
424
|
+
(event) => {
|
|
425
|
+
switch (event.keyCode) {
|
|
426
|
+
case BACKSPACE:
|
|
427
|
+
// Remove last token if input is empty
|
|
428
|
+
if (inputValue === '' && value.length > 0) {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
const newValue = value.slice(0, -1);
|
|
431
|
+
onChange(newValue);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
case ENTER:
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
|
|
437
|
+
// If requirePrefix is set and input doesn't start with @, add as plain text
|
|
438
|
+
if (requirePrefix && inputValue && !inputValue.startsWith('@')) {
|
|
439
|
+
const plainTextOption = {
|
|
440
|
+
label: inputValue,
|
|
441
|
+
value: inputValue,
|
|
442
|
+
isPlainText: true
|
|
443
|
+
};
|
|
444
|
+
handleAddToken(plainTextOption);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Otherwise, add selected suggestion if available
|
|
449
|
+
if (showSuggestions && suggestions.length > 0 && suggestions[selectedIndex]) {
|
|
450
|
+
handleAddToken(suggestions[selectedIndex]);
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
case UP:
|
|
454
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
case DOWN:
|
|
460
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
setSelectedIndex((prev) =>
|
|
463
|
+
Math.min(suggestions.length - 1, prev + 1),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
467
|
+
case ESCAPE:
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
setShowSuggestions(false);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
[showSuggestions, suggestions, selectedIndex, handleAddToken, requirePrefix, inputValue, getOptionLabel, getOptionValue, value, onChange],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Handle blur - save plain text if requirePrefix and has value
|
|
477
|
+
const handleBlur = useCallback(() => {
|
|
478
|
+
if (requirePrefix && inputValue && !inputValue.startsWith('@')) {
|
|
479
|
+
const plainTextOption = {
|
|
480
|
+
label: inputValue,
|
|
481
|
+
value: inputValue,
|
|
482
|
+
isPlainText: true
|
|
483
|
+
};
|
|
484
|
+
handleAddToken(plainTextOption);
|
|
485
|
+
}
|
|
486
|
+
}, [requirePrefix, inputValue, handleAddToken]);
|
|
487
|
+
|
|
488
|
+
// Handle drag start
|
|
489
|
+
const handleDragStart = useCallback(() => {
|
|
490
|
+
setIsDragging(true);
|
|
491
|
+
// Keep focus on input
|
|
492
|
+
if (inputRef.current) {
|
|
493
|
+
inputRef.current.focus();
|
|
494
|
+
}
|
|
495
|
+
}, []);
|
|
496
|
+
|
|
497
|
+
// Handle drag end
|
|
498
|
+
const handleDragEnd = useCallback(
|
|
499
|
+
(event) => {
|
|
500
|
+
setIsDragging(false);
|
|
501
|
+
|
|
502
|
+
const { active, over } = event;
|
|
503
|
+
|
|
504
|
+
if (!over || active.id === over.id) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const oldIndex = value.findIndex(
|
|
509
|
+
(item) => String(getOptionValue(item)) === active.id,
|
|
510
|
+
);
|
|
511
|
+
const newIndex = value.findIndex(
|
|
512
|
+
(item) => String(getOptionValue(item)) === over.id,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
if (oldIndex !== -1 && newIndex !== -1) {
|
|
516
|
+
const newValue = arrayMove(value, oldIndex, newIndex);
|
|
517
|
+
onChange(newValue);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
[value, onChange, getOptionValue],
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// Keep focus on input after drag
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (
|
|
526
|
+
!isDragging &&
|
|
527
|
+
inputRef.current &&
|
|
528
|
+
inputRef.current.ownerDocument.activeElement !== inputRef.current
|
|
529
|
+
) {
|
|
530
|
+
const hadFocus = inputRef.current.ownerDocument.activeElement?.closest(
|
|
531
|
+
".sortable-select__input-wrapper",
|
|
532
|
+
);
|
|
533
|
+
if (hadFocus) {
|
|
534
|
+
inputRef.current.focus();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}, [isDragging]);
|
|
538
|
+
|
|
539
|
+
const ids = useMemo(
|
|
540
|
+
() => value.map((item) => String(getOptionValue(item))),
|
|
541
|
+
[value, getOptionValue],
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<StyledWrapper
|
|
546
|
+
ref={wrapperRef}
|
|
547
|
+
onClick={(e) => {
|
|
548
|
+
// Focus input when clicking on wrapper (but not if clicking on input itself)
|
|
549
|
+
if (e.target !== inputRef.current) {
|
|
550
|
+
inputRef.current?.focus();
|
|
551
|
+
}
|
|
552
|
+
}}
|
|
553
|
+
>
|
|
554
|
+
<DndContext
|
|
555
|
+
sensors={sensors}
|
|
556
|
+
collisionDetection={closestCenter}
|
|
557
|
+
onDragStart={handleDragStart}
|
|
558
|
+
onDragEnd={handleDragEnd}
|
|
559
|
+
modifiers={[restrictToParentElement]}
|
|
560
|
+
>
|
|
561
|
+
<StyledInputWrapper>
|
|
562
|
+
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
|
563
|
+
{value.map((item, index) => (
|
|
564
|
+
<CustomSortableToken
|
|
565
|
+
key={getOptionValue(item)}
|
|
566
|
+
id={String(getOptionValue(item))}
|
|
567
|
+
value={getOptionLabel(item)}
|
|
568
|
+
onRemove={() => handleRemoveToken(index)}
|
|
569
|
+
disabled={isDisabled}
|
|
570
|
+
isPlainText={item.isPlainText}
|
|
571
|
+
isVariable={item.isVariable}
|
|
572
|
+
/>
|
|
573
|
+
))}
|
|
574
|
+
</SortableContext>
|
|
575
|
+
<SearchInput
|
|
576
|
+
inputRef={inputRef}
|
|
577
|
+
inputValue={inputValue}
|
|
578
|
+
setInputValue={setInputValue}
|
|
579
|
+
setShowSuggestions={setShowSuggestions}
|
|
580
|
+
suggestions={suggestions}
|
|
581
|
+
handleKeyDown={handleKeyDown}
|
|
582
|
+
handleBlur={handleBlur}
|
|
583
|
+
onFocusOpen={handleFocusOpen}
|
|
584
|
+
onBlurClose={handleBlurClose}
|
|
585
|
+
placeholder={value.length === 0 ? placeholder : ""}
|
|
586
|
+
isDisabled={isDisabled}
|
|
587
|
+
isDragging={isDragging}
|
|
588
|
+
inputType={inputType}
|
|
589
|
+
/>
|
|
590
|
+
</StyledInputWrapper>
|
|
591
|
+
</DndContext>
|
|
592
|
+
|
|
593
|
+
{showSuggestions && (
|
|
594
|
+
<StyledSuggestionsContainer>
|
|
595
|
+
<StyledSuggestions>
|
|
596
|
+
{isLoading ? (
|
|
597
|
+
<StyledLoading>
|
|
598
|
+
<Spinner />
|
|
599
|
+
<span>
|
|
600
|
+
{__("Loading…", "wp-component-library")}
|
|
601
|
+
</span>
|
|
602
|
+
</StyledLoading>
|
|
603
|
+
) : (
|
|
604
|
+
suggestions
|
|
605
|
+
.filter((option) => {
|
|
606
|
+
// Don't show already selected items
|
|
607
|
+
const optionValue = getOptionValue(option);
|
|
608
|
+
return !value.some(
|
|
609
|
+
(v) =>
|
|
610
|
+
getOptionValue(v) === optionValue,
|
|
611
|
+
);
|
|
612
|
+
})
|
|
613
|
+
.map((option, index) => (
|
|
614
|
+
<StyledSuggestion
|
|
615
|
+
key={getOptionValue(option)}
|
|
616
|
+
className={
|
|
617
|
+
index === selectedIndex
|
|
618
|
+
? "is-selected"
|
|
619
|
+
: ""
|
|
620
|
+
}
|
|
621
|
+
onClick={() => handleAddToken(option)}
|
|
622
|
+
onMouseEnter={() =>
|
|
623
|
+
setSelectedIndex(index)
|
|
624
|
+
}
|
|
625
|
+
>
|
|
626
|
+
{getOptionLabel(option)}
|
|
627
|
+
</StyledSuggestion>
|
|
628
|
+
))
|
|
629
|
+
)}
|
|
630
|
+
</StyledSuggestions>
|
|
631
|
+
</StyledSuggestionsContainer>
|
|
632
|
+
)}
|
|
633
|
+
</StyledWrapper>
|
|
634
|
+
);
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Custom SortableSelect Component
|
|
639
|
+
*
|
|
640
|
+
* A custom implementation that mimics FormTokenField but supports drag-and-drop.
|
|
641
|
+
* No react-select, no @emotion!
|
|
642
|
+
*/
|
|
643
|
+
export default function SortableSelect({
|
|
644
|
+
value = [],
|
|
645
|
+
onChange,
|
|
646
|
+
options = [],
|
|
647
|
+
loadOptions,
|
|
648
|
+
getOptionLabel = (option) => option.label || option.name || option.title,
|
|
649
|
+
getOptionValue = (option) => option.value || option.id,
|
|
650
|
+
placeholder = __("Search and select…", "wp-component-library"),
|
|
651
|
+
label,
|
|
652
|
+
help,
|
|
653
|
+
isDisabled = false,
|
|
654
|
+
className = "",
|
|
655
|
+
maxItems,
|
|
656
|
+
requirePrefix = false,
|
|
657
|
+
inputType = 'text',
|
|
658
|
+
}) {
|
|
659
|
+
const innerRef = useRef();
|
|
660
|
+
|
|
661
|
+
return (
|
|
662
|
+
// eslint-disable-next-line @wordpress/no-base-control-with-label-without-id -- custom token input; see lint report.
|
|
663
|
+
<BaseControl
|
|
664
|
+
__nextHasNoMarginBottom
|
|
665
|
+
__next40pxDefaultMarginBottom
|
|
666
|
+
label={label}
|
|
667
|
+
help={help}
|
|
668
|
+
className={`sortable-select ${className}`}
|
|
669
|
+
onClick={() => {
|
|
670
|
+
// Focus input when clicking anywhere on the BaseControl
|
|
671
|
+
innerRef.current?.focus();
|
|
672
|
+
}}
|
|
673
|
+
>
|
|
674
|
+
<SortableSelectInner
|
|
675
|
+
ref={innerRef}
|
|
676
|
+
value={value}
|
|
677
|
+
onChange={onChange}
|
|
678
|
+
options={options}
|
|
679
|
+
loadOptions={loadOptions}
|
|
680
|
+
getOptionLabel={getOptionLabel}
|
|
681
|
+
getOptionValue={getOptionValue}
|
|
682
|
+
placeholder={placeholder}
|
|
683
|
+
isDisabled={isDisabled}
|
|
684
|
+
maxItems={maxItems}
|
|
685
|
+
requirePrefix={requirePrefix}
|
|
686
|
+
inputType={inputType}
|
|
687
|
+
/>
|
|
688
|
+
</BaseControl>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Export the utility function for external use
|
|
693
|
+
export { tokensToString };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# StylePicker
|
|
2
|
+
|
|
3
|
+
One option out of a few, shown like core's block **Styles** panel: a two-column grid of
|
|
4
|
+
buttons with the chosen one filled. Use it for a block setting that reads like a style
|
|
5
|
+
but is an attribute, such as a button's variant, where a toggle group would be too
|
|
6
|
+
cramped.
|
|
7
|
+
|
|
8
|
+
It renders core's own markup and classes (`block-editor-block-styles__*`), so it matches
|
|
9
|
+
the Styles panel exactly and needs no styles of its own. It works anywhere the block
|
|
10
|
+
editor's styles load (block inspector controls).
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { StylePicker } from "@siratik/wp-component-library";
|
|
14
|
+
|
|
15
|
+
<StylePicker
|
|
16
|
+
label={__("Button Variant", "my-plugin")}
|
|
17
|
+
value={variant}
|
|
18
|
+
options={[
|
|
19
|
+
{ value: "primary", label: __("Primary", "my-plugin") },
|
|
20
|
+
{ value: "secondary", label: __("Secondary", "my-plugin") },
|
|
21
|
+
{ value: "tertiary", label: __("Tertiary", "my-plugin") },
|
|
22
|
+
{ value: "text-link", label: __("Text Link", "my-plugin") },
|
|
23
|
+
]}
|
|
24
|
+
onChange={(next) => setAttributes({ variant: next })}
|
|
25
|
+
/>;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Prop | Type | Notes |
|
|
29
|
+
|------|------|-------|
|
|
30
|
+
| `label` | string | The group's label (shown, or screen-reader only with `hideLabelFromVision`). |
|
|
31
|
+
| `help` | string | Optional help text. |
|
|
32
|
+
| `value` | string | The chosen option's value. |
|
|
33
|
+
| `options` | `{ value, label }[]` | Options, in order. |
|
|
34
|
+
| `onChange` | function | Receives the chosen value. |
|
|
35
|
+
| `hideLabelFromVision` | boolean | Keep the label for screen readers only. |
|
|
36
|
+
| `className` | string | Optional class on the control. |
|