@wordpress-gcb/fields 0.2.1 → 0.2.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 (91) hide show
  1. package/dist/conditional-logic.js +83 -0
  2. package/{src → dist}/control-context.js +3 -2
  3. package/{src → dist}/controls/MediaCapabilityGate.js +12 -8
  4. package/dist/controls/MediaPicker.js +149 -0
  5. package/dist/controls/MediaTriggerBadges.js +35 -0
  6. package/{src → dist}/controls/PopoverOrModal.js +49 -43
  7. package/dist/controls/SortableItem.js +126 -0
  8. package/dist/controls/button-group.js +46 -0
  9. package/dist/controls/checkbox-group.js +65 -0
  10. package/dist/controls/checkbox.js +15 -0
  11. package/dist/controls/code.js +24 -0
  12. package/dist/controls/color.js +241 -0
  13. package/dist/controls/date.js +55 -0
  14. package/dist/controls/datetime.js +61 -0
  15. package/dist/controls/email.js +17 -0
  16. package/dist/controls/file.js +163 -0
  17. package/dist/controls/gallery.js +371 -0
  18. package/dist/controls/google-map.js +143 -0
  19. package/dist/controls/heading-level.js +93 -0
  20. package/dist/controls/icon.js +292 -0
  21. package/dist/controls/image.js +360 -0
  22. package/dist/controls/index.js +88 -0
  23. package/dist/controls/message.js +86 -0
  24. package/dist/controls/number.js +19 -0
  25. package/dist/controls/oembed.js +42 -0
  26. package/{src → dist}/controls/page-link.js +1 -2
  27. package/dist/controls/post-object.js +913 -0
  28. package/dist/controls/radio.js +19 -0
  29. package/dist/controls/range.js +108 -0
  30. package/{src → dist}/controls/relationship.js +12 -7
  31. package/dist/controls/repeater.js +277 -0
  32. package/dist/controls/richtext.js +494 -0
  33. package/dist/controls/select.js +144 -0
  34. package/dist/controls/size.js +59 -0
  35. package/dist/controls/spacing.js +141 -0
  36. package/dist/controls/taxonomy.js +569 -0
  37. package/dist/controls/text.js +16 -0
  38. package/dist/controls/textarea.js +17 -0
  39. package/dist/controls/toggle-group.js +28 -0
  40. package/dist/controls/toggle.js +15 -0
  41. package/dist/controls/url.js +235 -0
  42. package/dist/controls/user.js +383 -0
  43. package/{src → dist}/controls/wysiwyg.js +1 -1
  44. package/{src → dist}/hooks/useTokens.js +25 -21
  45. package/{src → dist}/index.js +2 -8
  46. package/dist/inspector.js +163 -0
  47. package/{src → dist}/provider.js +18 -17
  48. package/dist/utils/map-utils.js +54 -0
  49. package/dist/utils/token-helper.js +396 -0
  50. package/{src → dist}/validation-context.js +4 -4
  51. package/package.json +20 -13
  52. package/src/conditional-logic.js +0 -77
  53. package/src/controls/MediaPicker.js +0 -139
  54. package/src/controls/MediaTriggerBadges.js +0 -31
  55. package/src/controls/SortableItem.js +0 -110
  56. package/src/controls/button-group.js +0 -49
  57. package/src/controls/checkbox-group.js +0 -55
  58. package/src/controls/checkbox.js +0 -13
  59. package/src/controls/code.js +0 -21
  60. package/src/controls/color.js +0 -235
  61. package/src/controls/date.js +0 -37
  62. package/src/controls/datetime.js +0 -54
  63. package/src/controls/email.js +0 -15
  64. package/src/controls/file.js +0 -134
  65. package/src/controls/gallery.js +0 -338
  66. package/src/controls/google-map.js +0 -117
  67. package/src/controls/heading-level.js +0 -99
  68. package/src/controls/icon.js +0 -301
  69. package/src/controls/image.js +0 -334
  70. package/src/controls/index.js +0 -95
  71. package/src/controls/message.js +0 -56
  72. package/src/controls/number.js +0 -17
  73. package/src/controls/oembed.js +0 -32
  74. package/src/controls/post-object.js +0 -788
  75. package/src/controls/radio.js +0 -18
  76. package/src/controls/range.js +0 -110
  77. package/src/controls/repeater.js +0 -290
  78. package/src/controls/richtext.js +0 -505
  79. package/src/controls/select.js +0 -141
  80. package/src/controls/size.js +0 -49
  81. package/src/controls/spacing.js +0 -141
  82. package/src/controls/taxonomy.js +0 -488
  83. package/src/controls/text.js +0 -14
  84. package/src/controls/textarea.js +0 -15
  85. package/src/controls/toggle-group.js +0 -34
  86. package/src/controls/toggle.js +0 -13
  87. package/src/controls/url.js +0 -164
  88. package/src/controls/user.js +0 -343
  89. package/src/inspector.js +0 -174
  90. package/src/utils/map-utils.js +0 -51
  91. package/src/utils/token-helper.js +0 -243
@@ -1,141 +0,0 @@
1
- /**
2
- * SpacingField — preset spacing (None / S / M / L) with a custom-value escape hatch.
3
- *
4
- * Stored value is either one of the preset keys (`'small'`, etc.) or a CSS
5
- * length string (e.g. `'2rem'`). When set to a custom value, the toggle group
6
- * is disabled and a "custom value applied" hint shows.
7
- */
8
-
9
- import { __ } from '@wordpress/i18n';
10
- import { Button, TextControl, Notice } from '@wordpress/components';
11
- import { useState } from '@wordpress/element';
12
- import {
13
- __experimentalToggleGroupControl as ToggleGroupControl,
14
- __experimentalToggleGroupControlOption as ToggleGroupControlOption,
15
- __experimentalHStack as HStack,
16
- } from '@wordpress/components';
17
-
18
- const DEFAULT_PRESETS = [
19
- { label: 'None', value: 'none' },
20
- { label: 'S', value: 'small' },
21
- { label: 'M', value: 'medium' },
22
- { label: 'L', value: 'large' },
23
- ];
24
-
25
- const PRESET_KEYS = new Set(['none', 'small', 'medium', 'large']);
26
-
27
- function isValidCSSValue(input) {
28
- if (!input) return true;
29
- return /^(\d*\.?\d+)(px|rem|em|%|vw|vh|vmin|vmax)?$/.test(String(input).trim());
30
- }
31
-
32
- export default function SpacingField({ control, value, onChange }) {
33
- // Decide if value is a preset or a custom string.
34
- const isCustom = typeof value === 'string' && value !== '' && !PRESET_KEYS.has(value);
35
- const presetValue = isCustom ? 'medium' : (value || 'medium');
36
-
37
- const [showCustom, setShowCustom] = useState(isCustom);
38
- const [customInput, setCustomInput] = useState(isCustom ? value : '');
39
- const [error, setError] = useState(null);
40
-
41
- const presets = control.presets || DEFAULT_PRESETS;
42
-
43
- const handlePreset = (next) => {
44
- setShowCustom(false);
45
- setCustomInput('');
46
- setError(null);
47
- onChange(next);
48
- };
49
-
50
- const handleCustom = (next) => {
51
- setCustomInput(next);
52
- if (next && !isValidCSSValue(next)) {
53
- setError(__('Invalid spacing value. Use a number with a unit (e.g. 2rem, 20px).', 'gcblite'));
54
- return;
55
- }
56
- setError(null);
57
- onChange(next || 'medium');
58
- };
59
-
60
- const handleReset = () => {
61
- setShowCustom(false);
62
- setCustomInput('');
63
- setError(null);
64
- onChange('medium');
65
- };
66
-
67
- const displayHint = showCustom
68
- ? 'Custom'
69
- : (presetValue.charAt(0).toUpperCase() + presetValue.slice(1));
70
-
71
- return (
72
- <div className={`gcb-spacing-field components-base-control ${control.className || ''}`.trim()}>
73
- <div className="components-base-control__field">
74
- <HStack>
75
- <span className="components-base-control__label">
76
- {control.label}
77
- <span className="components-font-size-picker__header__hint">{displayHint}</span>
78
- </span>
79
- <Button
80
- size="small"
81
- label={__('Set custom spacing', 'gcblite')}
82
- onClick={() => setShowCustom(true)}
83
- isPressed={showCustom}
84
- icon={(
85
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden focusable="false">
86
- <path d="m19 7.5h-7.628c-.3089-.87389-1.1423-1.5-2.122-1.5-.97966 0-1.81309.62611-2.12197 1.5h-2.12803v1.5h2.12803c.30888.87389 1.14231 1.5 2.12197 1.5.9797 0 1.8131-.62611 2.122-1.5h7.628z" />
87
- <path d="m19 15h-2.128c-.3089-.8739-1.1423-1.5-2.122-1.5s-1.8131.6261-2.122 1.5h-7.628v1.5h7.628c.3089.8739 1.1423 1.5 2.122 1.5s1.8131-.6261 2.122-1.5h2.128z" />
88
- </svg>
89
- )}
90
- />
91
- </HStack>
92
-
93
- {error && (
94
- <Notice status="warning" isDismissible onRemove={() => setError(null)}>
95
- {error}
96
- </Notice>
97
- )}
98
-
99
- {showCustom && (
100
- <div style={{ marginBottom: 16, fontSize: 13 }}>
101
- <span>{__('Custom value applied', 'gcblite')}</span>
102
- <Button variant="link" onClick={handleReset} style={{ marginLeft: 8, fontSize: 13 }}>
103
- {__('Reset', 'gcblite')}
104
- </Button>
105
- </div>
106
- )}
107
-
108
- <ToggleGroupControl
109
- label={control.label}
110
- value={presetValue}
111
- onChange={handlePreset}
112
- isBlock
113
- hideLabelFromVision
114
- disabled={showCustom}
115
- className={showCustom ? 'is-disabled' : ''}
116
- __nextHasNoMarginBottom
117
- __next40pxDefaultSize
118
- >
119
- {presets.map((preset) => (
120
- <ToggleGroupControlOption
121
- key={preset.value}
122
- value={preset.value}
123
- label={preset.label}
124
- />
125
- ))}
126
- </ToggleGroupControl>
127
-
128
- {showCustom && (
129
- <TextControl
130
- label={__('Custom Spacing', 'gcblite')}
131
- value={customInput}
132
- onChange={handleCustom}
133
- placeholder="e.g. 2rem or 20px"
134
- help={__('Enter a value with unit (e.g. 2rem, 20px, 5%) or leave empty for 0.', 'gcblite')}
135
- __nextHasNoMarginBottom
136
- />
137
- )}
138
- </div>
139
- </div>
140
- );
141
- }
@@ -1,488 +0,0 @@
1
- /**
2
- * TaxonomyField — ported verbatim from the original GCB.
3
- *
4
- * Single OR multiple term selection (control.multiple, default true).
5
- * Stored as ID(s) or full term object(s) (control.returnFormat).
6
- * Optional "create new term" UI (control.allowCreateTerms).
7
- * Drag-and-drop reordering of selected terms (multi-select only).
8
- */
9
-
10
- import { __ } from '@wordpress/i18n';
11
- import {
12
- Button,
13
- TextControl,
14
- } from '@wordpress/components';
15
- import PopoverOrModal from './PopoverOrModal';
16
- import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
17
- import apiFetch from '@wordpress/api-fetch';
18
- import {
19
- DndContext,
20
- closestCenter,
21
- PointerSensor,
22
- useSensor,
23
- useSensors,
24
- DragOverlay,
25
- } from '@dnd-kit/core';
26
- import {
27
- SortableContext,
28
- verticalListSortingStrategy,
29
- useSortable,
30
- arrayMove,
31
- } from '@dnd-kit/sortable';
32
- import { CSS } from '@dnd-kit/utilities';
33
-
34
- const TOGGLE_BUTTON_STYLE = {
35
- width: '100%',
36
- height: 'auto',
37
- padding: '12px',
38
- justifyContent: 'flex-start',
39
- border: '1px solid #ddd',
40
- borderRadius: '2px',
41
- backgroundColor: '#fff',
42
- };
43
-
44
- function TermIcon() {
45
- return (
46
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" style={{ marginRight: 8, flexShrink: 0, opacity: 0.6 }}>
47
- <path d="M8 12c0 1.1.9 2 2 2s2-.9 2-2-.9-2-2-2-2 .9-2 2zm8-2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
48
- <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
49
- </svg>
50
- );
51
- }
52
-
53
- function SortableTermItem({ term, onRemove }) {
54
- const {
55
- attributes: dndAttributes,
56
- listeners,
57
- setNodeRef,
58
- transform,
59
- transition,
60
- isDragging,
61
- } = useSortable({ id: term.id });
62
-
63
- const style = {
64
- transform: CSS.Transform.toString(transform),
65
- transition,
66
- opacity: isDragging ? 0.5 : 1,
67
- };
68
-
69
- return (
70
- <div
71
- ref={setNodeRef}
72
- style={style}
73
- className="gcb-post-object-selected-item"
74
- {...dndAttributes}
75
- {...listeners}
76
- >
77
- <div className="gcb-post-object-drag-handle" aria-hidden>
78
- <svg viewBox="0 0 20 20" width="12">
79
- <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
80
- </svg>
81
- </div>
82
- <TermIcon />
83
- <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'none' }}>
84
- {term.name || __('(no name)', 'gcblite')}
85
- </span>
86
- <button
87
- type="button"
88
- onClick={(e) => {
89
- e.stopPropagation();
90
- onRemove(term.id);
91
- }}
92
- onPointerDown={(e) => e.stopPropagation()}
93
- className="gcb-sortable-remove"
94
- aria-label={__('Remove', 'gcblite')}
95
- >
96
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
97
- <path d="M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z" />
98
- </svg>
99
- </button>
100
- </div>
101
- );
102
- }
103
-
104
- // Normalise whatever the value used to look like into the canonical
105
- // { taxonomy, ids[] } shape. Handles:
106
- // - bare ID (legacy single) → { taxonomy: schemaDefault, ids: [3] }
107
- // - array of IDs (legacy multi) → { taxonomy: schemaDefault, ids: [3, 5] }
108
- // - { id, name, taxonomy } (returnFormat=object, single)
109
- // - array of those objects (returnFormat=object, multi)
110
- // - new canonical { taxonomy, ids[] } → passes through
111
- function normaliseTaxonomyValue(value, schemaDefault) {
112
- if (!value) return { taxonomy: schemaDefault, ids: [] };
113
- // Already canonical.
114
- if (typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.ids)) {
115
- return { taxonomy: value.taxonomy || schemaDefault, ids: value.ids };
116
- }
117
- // Bare scalar (legacy single).
118
- if (typeof value === 'number' || typeof value === 'string') {
119
- return { taxonomy: schemaDefault, ids: [Number(value)] };
120
- }
121
- // Single object (returnFormat=object).
122
- if (typeof value === 'object' && !Array.isArray(value) && value.id != null) {
123
- return { taxonomy: value.taxonomy || schemaDefault, ids: [Number(value.id)] };
124
- }
125
- // Array shapes.
126
- if (Array.isArray(value)) {
127
- const ids = value
128
- .map((entry) => typeof entry === 'object' ? Number(entry.id) : Number(entry))
129
- .filter(Boolean);
130
- const tx = value.find((entry) => typeof entry === 'object' && entry?.taxonomy)?.taxonomy;
131
- return { taxonomy: tx || schemaDefault, ids };
132
- }
133
- return { taxonomy: schemaDefault, ids: [] };
134
- }
135
-
136
- const REST_BASE_OVERRIDES = { category: 'categories', post_tag: 'tags' };
137
- function resolveRestBase(taxonomy, override) {
138
- if (override) return override;
139
- return REST_BASE_OVERRIDES[taxonomy] || taxonomy;
140
- }
141
-
142
- export default function TaxonomyField({ control, value, onChange }) {
143
- const [allTerms, setAllTerms] = useState([]);
144
- const [searchResults, setSearchResults] = useState([]);
145
- const [loading, setLoading] = useState(false);
146
- const [search, setSearch] = useState('');
147
- const [creatingTerm, setCreatingTerm] = useState(false);
148
- const [newTermName, setNewTermName] = useState('');
149
- const [activeId, setActiveId] = useState(null);
150
- const [availableTaxonomies, setAvailableTaxonomies] = useState([]);
151
-
152
- const isMultiple = control.multiple !== false;
153
- // Schema-locked vs author-picks-at-edit-time.
154
- // When the schema omits `taxonomy`, the editor user picks via a
155
- // dropdown. The picked value is stored alongside the IDs so the
156
- // renderer can still resolve them at read-time.
157
- const dynamic = !control.taxonomy;
158
- const schemaDefault = control.taxonomy || 'category';
159
-
160
- // Canonical { taxonomy, ids[] } shape.
161
- const normalised = useMemo(
162
- () => normaliseTaxonomyValue(value, schemaDefault),
163
- [value, schemaDefault]
164
- );
165
- const taxonomy = normalised.taxonomy || schemaDefault;
166
- const selectedIds = normalised.ids;
167
-
168
- const restBase = resolveRestBase(taxonomy, control.restBase);
169
-
170
- const selectedTerms = useMemo(
171
- () => selectedIds.map((id) => allTerms.find((t) => t.id === id)).filter(Boolean),
172
- [selectedIds, allTerms]
173
- );
174
-
175
- // Fetch the list of registered taxonomies once, only when the schema
176
- // didn't lock to one. Used to populate the taxonomy dropdown.
177
- useEffect(() => {
178
- if (!dynamic) return;
179
- let cancelled = false;
180
- apiFetch({ path: '/wp/v2/taxonomies?context=view' })
181
- .then((res) => {
182
- if (cancelled) return;
183
- // REST returns an object keyed by taxonomy slug.
184
- const list = Object.entries(res || {}).map(([slug, info]) => ({
185
- slug,
186
- name: info?.name || slug,
187
- restBase: info?.rest_base || slug,
188
- }));
189
- setAvailableTaxonomies(list);
190
- })
191
- .catch(() => {});
192
- return () => { cancelled = true; };
193
- }, [dynamic]);
194
-
195
- // Emit the canonical shape — always { taxonomy, ids[] }, regardless
196
- // of single/multi, so the renderer never has to guess.
197
- const emitChange = useCallback((ids) => {
198
- onChange({ taxonomy, ids });
199
- }, [onChange, taxonomy]);
200
-
201
- const handleTaxonomyChange = (newTax) => {
202
- // Switching taxonomy clears the selected terms — IDs from one
203
- // taxonomy don't translate to another.
204
- onChange({ taxonomy: newTax, ids: [] });
205
- setAllTerms([]);
206
- setSearchResults([]);
207
- };
208
-
209
- const mergeTermsIntoCache = useCallback((newTerms) => {
210
- setAllTerms((prev) => {
211
- const merged = [...prev];
212
- newTerms.forEach((nt) => {
213
- if (!merged.find((t) => t.id === nt.id)) merged.push(nt);
214
- });
215
- return merged;
216
- });
217
- }, []);
218
-
219
- const loadTerms = useCallback(async (term = '') => {
220
- setLoading(true);
221
- try {
222
- const response = await apiFetch({
223
- path: `/wp/v2/${restBase}?search=${encodeURIComponent(term)}&per_page=100&_fields=id,name`,
224
- });
225
- setSearchResults(response);
226
- mergeTermsIntoCache(response);
227
- } catch {
228
- // ignore
229
- }
230
- setLoading(false);
231
- }, [restBase, mergeTermsIntoCache]);
232
-
233
- useEffect(() => {
234
- loadTerms();
235
- }, [loadTerms]);
236
-
237
- const handleSelect = (termId) => {
238
- const newIds = isMultiple
239
- ? (selectedIds.includes(termId)
240
- ? selectedIds.filter((id) => id !== termId)
241
- : [...selectedIds, termId])
242
- : [termId];
243
- emitChange(newIds);
244
- };
245
-
246
- const handleRemove = (termId) => {
247
- emitChange(selectedIds.filter((id) => id !== termId));
248
- };
249
-
250
- const handleReorder = (newOrder) => {
251
- emitChange(newOrder);
252
- };
253
-
254
- const handleClear = () => emitChange([]);
255
-
256
- const handleCreateTerm = async () => {
257
- if (!newTermName.trim() || !control.allowCreateTerms) return;
258
- setCreatingTerm(true);
259
- try {
260
- const newTerm = await apiFetch({
261
- path: `/wp/v2/${restBase}`,
262
- method: 'POST',
263
- data: { name: newTermName.trim() },
264
- });
265
- mergeTermsIntoCache([newTerm]);
266
- setSearchResults((prev) => [...prev, newTerm]);
267
- handleSelect(newTerm.id);
268
- setNewTermName('');
269
- } catch {
270
- // ignore
271
- }
272
- setCreatingTerm(false);
273
- };
274
-
275
- const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
276
-
277
- const handleDragStart = (e) => setActiveId(e.active.id);
278
- const handleDragCancel = () => setActiveId(null);
279
- const handleDragEnd = (e) => {
280
- const { active, over } = e;
281
- if (over && active.id !== over.id) {
282
- const oldIndex = selectedIds.indexOf(active.id);
283
- const newIndex = selectedIds.indexOf(over.id);
284
- handleReorder(arrayMove(selectedIds, oldIndex, newIndex));
285
- }
286
- setActiveId(null);
287
- };
288
-
289
- const activeTerm = activeId ? selectedTerms.find((t) => t.id === activeId) : null;
290
- const availableTerms = searchResults.filter((t) => !selectedIds.includes(t.id));
291
-
292
- return (
293
- <div className="components-base-control gcb-taxonomy-control">
294
- <div className="components-base-control__field">
295
- <label className="components-base-control__label">{control.label}</label>
296
- </div>
297
- {control.helpText && (
298
- <p className="components-base-control__help">{control.helpText}</p>
299
- )}
300
-
301
- <div className="gcb-post-object-stacked">
302
- {dynamic && (
303
- <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
304
- <label style={{ fontSize: 12, fontWeight: 600, color: '#1e1e1e', minWidth: 70 }}>
305
- {__('Taxonomy', 'gcblite')}
306
- </label>
307
- <select
308
- value={taxonomy}
309
- onChange={(e) => handleTaxonomyChange(e.target.value)}
310
- style={{
311
- flex: 1,
312
- padding: '6px 8px',
313
- border: '1px solid #8c8f94',
314
- borderRadius: 4,
315
- fontSize: 13,
316
- background: '#fff',
317
- }}
318
- >
319
- {availableTaxonomies.length === 0 && (
320
- <option value={taxonomy}>{taxonomy}</option>
321
- )}
322
- {availableTaxonomies.map((tx) => (
323
- <option key={tx.slug} value={tx.slug}>
324
- {tx.name} ({tx.slug})
325
- </option>
326
- ))}
327
- </select>
328
- </div>
329
- )}
330
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
331
- <PopoverOrModal
332
- modalTitle={control.label || __('Select terms', 'gcblite')}
333
- dropdownProps={{ popoverProps: { placement: 'left-start' } }}
334
- renderToggle={({ isOpen, onToggle }) => (
335
- <Button
336
- onClick={onToggle}
337
- aria-expanded={isOpen}
338
- className="gcb-modal-toggle-button"
339
- style={{ ...TOGGLE_BUTTON_STYLE, flex: 1 }}
340
- >
341
- {selectedTerms.length > 0
342
- ? `${selectedTerms.length} ${selectedTerms.length === 1 ? __('term', 'gcblite') : __('terms', 'gcblite')} ${__('selected', 'gcblite')}`
343
- : __('Select Terms', 'gcblite')}
344
- </Button>
345
- )}
346
- renderContent={({ close: onClose, variant }) => (
347
- <div style={variant === 'modal' ? { width: '100%' } : { minWidth: 320, maxWidth: 400 }}>
348
- <div style={{ padding: '0 16px 8px 16px' }}>
349
- <TextControl
350
- value={search}
351
- onChange={(val) => {
352
- setSearch(val);
353
- loadTerms(val);
354
- }}
355
- placeholder={__('Search…', 'gcblite')}
356
- __nextHasNoMarginBottom
357
- />
358
-
359
- {control.allowCreateTerms && (
360
- <div style={{ marginTop: 12, padding: 12, background: '#f0f6fc', borderRadius: 4 }}>
361
- <TextControl
362
- label={__('Create New Term', 'gcblite')}
363
- value={newTermName}
364
- onChange={setNewTermName}
365
- placeholder={__('Enter term name…', 'gcblite')}
366
- disabled={creatingTerm}
367
- __nextHasNoMarginBottom
368
- />
369
- <Button
370
- variant="primary"
371
- size="small"
372
- onClick={handleCreateTerm}
373
- disabled={!newTermName.trim() || creatingTerm}
374
- style={{ marginTop: 8 }}
375
- >
376
- {creatingTerm ? __('Creating…', 'gcblite') : __('Create', 'gcblite')}
377
- </Button>
378
- </div>
379
- )}
380
- </div>
381
-
382
- <div className="block-editor-link-control__search-results-wrapper" style={{ maxHeight: 300, overflowY: 'auto' }}>
383
- {loading && (
384
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
385
- {__('Loading…', 'gcblite')}
386
- </p>
387
- )}
388
- {!loading && availableTerms.length === 0 && (
389
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
390
- {__('No terms found', 'gcblite')}
391
- </p>
392
- )}
393
- {!loading && availableTerms.length > 0 && (
394
- <div className="block-editor-link-control__search-results" role="listbox">
395
- <div className="components-menu-group">
396
- <div role="group">
397
- {availableTerms.map((term) => (
398
- <button
399
- key={term.id}
400
- type="button"
401
- role="option"
402
- className="components-button components-menu-item__button block-editor-link-control__search-item"
403
- onClick={() => handleSelect(term.id)}
404
- style={{
405
- display: 'flex',
406
- alignItems: 'center',
407
- width: '100%',
408
- padding: '8px 16px',
409
- textAlign: 'left',
410
- border: 'none',
411
- background: 'transparent',
412
- justifyContent: 'space-between',
413
- }}
414
- >
415
- <span style={{ display: 'flex', alignItems: 'center', flex: 1, overflow: 'hidden' }}>
416
- <TermIcon />
417
- <span className="components-menu-item__item" style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
418
- {term.name}
419
- </span>
420
- </span>
421
- </button>
422
- ))}
423
- </div>
424
- </div>
425
- </div>
426
- )}
427
- </div>
428
- </div>
429
- )}
430
- />
431
-
432
- {selectedTerms.length > 0 && (
433
- <Button
434
- onClick={handleClear}
435
- variant="secondary"
436
- isSmall
437
- className="components-range-control__reset"
438
- >
439
- {__('Reset', 'gcblite')}
440
- </Button>
441
- )}
442
- </div>
443
-
444
- {selectedTerms.length > 0 && isMultiple && (
445
- <div className="gcb-post-object-selected-list" style={{ marginTop: 8 }}>
446
- <DndContext
447
- sensors={sensors}
448
- collisionDetection={closestCenter}
449
- onDragStart={handleDragStart}
450
- onDragEnd={handleDragEnd}
451
- onDragCancel={handleDragCancel}
452
- >
453
- <SortableContext items={selectedIds} strategy={verticalListSortingStrategy}>
454
- {selectedTerms.map((term) => (
455
- <SortableTermItem key={term.id} term={term} onRemove={handleRemove} />
456
- ))}
457
- </SortableContext>
458
- <DragOverlay>
459
- {activeTerm ? (
460
- <div className="gcb-post-object-selected-item" style={{ opacity: 0.8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
461
- <div className="gcb-post-object-drag-handle" aria-hidden>
462
- <svg viewBox="0 0 20 20" width="12">
463
- <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
464
- </svg>
465
- </div>
466
- <TermIcon />
467
- <span style={{ flex: 1 }}>{activeTerm.name}</span>
468
- </div>
469
- ) : null}
470
- </DragOverlay>
471
- </DndContext>
472
- </div>
473
- )}
474
-
475
- {selectedTerms.length > 0 && !isMultiple && (
476
- <div className="gcb-post-object-selected-list" style={{ marginTop: 8 }}>
477
- <div className="gcb-post-object-selected-item">
478
- <TermIcon />
479
- <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
480
- {selectedTerms[0].name}
481
- </span>
482
- </div>
483
- </div>
484
- )}
485
- </div>
486
- </div>
487
- );
488
- }
@@ -1,14 +0,0 @@
1
- import { TextControl } from '@wordpress/components';
2
-
3
- export default function TextField({ control, value, onChange }) {
4
- return (
5
- <TextControl
6
- label={control.label}
7
- help={control.helpText}
8
- placeholder={control.placeholder}
9
- value={value ?? ''}
10
- onChange={onChange}
11
- __nextHasNoMarginBottom
12
- />
13
- );
14
- }
@@ -1,15 +0,0 @@
1
- import { TextareaControl } from '@wordpress/components';
2
-
3
- export default function TextareaField({ control, value, onChange }) {
4
- return (
5
- <TextareaControl
6
- label={control.label}
7
- help={control.helpText}
8
- placeholder={control.placeholder}
9
- value={value ?? ''}
10
- onChange={onChange}
11
- rows={4}
12
- __nextHasNoMarginBottom
13
- />
14
- );
15
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * ToggleGroup — radio-style segmented control. Stores a single value.
3
- */
4
- import {
5
- __experimentalToggleGroupControl as ToggleGroupControl,
6
- __experimentalToggleGroupControlOption as ToggleGroupControlOption,
7
- } from '@wordpress/components';
8
-
9
- export default function ToggleGroupField({ control, value, onChange }) {
10
- // ToggleGroupControl needs undefined (not '') when nothing is selected,
11
- // otherwise it shows every option as checked.
12
- const controlValue = value || undefined;
13
-
14
- return (
15
- <ToggleGroupControl
16
- label={control.label}
17
- value={controlValue}
18
- onChange={onChange}
19
- help={control.helpText}
20
- isBlock={control.isBlock !== false}
21
- className="gcb-toggle-group-control"
22
- __nextHasNoMarginBottom
23
- __next40pxDefaultSize
24
- >
25
- {(control.options || []).map((option, idx) => (
26
- <ToggleGroupControlOption
27
- key={option.value || idx}
28
- value={option.value}
29
- label={option.label}
30
- />
31
- ))}
32
- </ToggleGroupControl>
33
- );
34
- }