@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.
Files changed (96) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +43 -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 +1 -1
  90. package/editor.js +16 -0
  91. package/index.js +127 -0
  92. package/package.json +21 -13
  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 -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. |