@wordpress-gcb/fields 0.2.0

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 (52) hide show
  1. package/README.md +49 -0
  2. package/package.json +39 -0
  3. package/src/conditional-logic.js +77 -0
  4. package/src/control-context.js +17 -0
  5. package/src/controls/MediaCapabilityGate.js +33 -0
  6. package/src/controls/MediaPicker.js +139 -0
  7. package/src/controls/MediaTriggerBadges.js +31 -0
  8. package/src/controls/PopoverOrModal.js +83 -0
  9. package/src/controls/SortableItem.js +110 -0
  10. package/src/controls/button-group.js +49 -0
  11. package/src/controls/checkbox-group.js +55 -0
  12. package/src/controls/checkbox.js +13 -0
  13. package/src/controls/code.js +21 -0
  14. package/src/controls/color.js +235 -0
  15. package/src/controls/date.js +37 -0
  16. package/src/controls/datetime.js +54 -0
  17. package/src/controls/email.js +15 -0
  18. package/src/controls/file.js +134 -0
  19. package/src/controls/gallery.js +338 -0
  20. package/src/controls/google-map.js +117 -0
  21. package/src/controls/heading-level.js +99 -0
  22. package/src/controls/icon.js +301 -0
  23. package/src/controls/image.js +334 -0
  24. package/src/controls/index.js +95 -0
  25. package/src/controls/message.js +56 -0
  26. package/src/controls/number.js +17 -0
  27. package/src/controls/oembed.js +32 -0
  28. package/src/controls/page-link.js +9 -0
  29. package/src/controls/post-object.js +788 -0
  30. package/src/controls/radio.js +18 -0
  31. package/src/controls/range.js +110 -0
  32. package/src/controls/relationship.js +14 -0
  33. package/src/controls/repeater.js +290 -0
  34. package/src/controls/richtext.js +505 -0
  35. package/src/controls/select.js +141 -0
  36. package/src/controls/size.js +49 -0
  37. package/src/controls/spacing.js +141 -0
  38. package/src/controls/taxonomy.js +488 -0
  39. package/src/controls/text.js +14 -0
  40. package/src/controls/textarea.js +15 -0
  41. package/src/controls/toggle-group.js +34 -0
  42. package/src/controls/toggle.js +13 -0
  43. package/src/controls/url.js +164 -0
  44. package/src/controls/user.js +343 -0
  45. package/src/controls/wysiwyg.js +23 -0
  46. package/src/hooks/useTokens.js +44 -0
  47. package/src/index.js +38 -0
  48. package/src/inspector.js +174 -0
  49. package/src/provider.js +59 -0
  50. package/src/utils/map-utils.js +51 -0
  51. package/src/utils/token-helper.js +243 -0
  52. package/src/validation-context.js +19 -0
@@ -0,0 +1,13 @@
1
+ import { ToggleControl } from '@wordpress/components';
2
+
3
+ export default function ToggleField({ control, value, onChange }) {
4
+ return (
5
+ <ToggleControl
6
+ label={control.label}
7
+ help={control.helpText}
8
+ checked={!!value}
9
+ onChange={onChange}
10
+ __nextHasNoMarginBottom
11
+ />
12
+ );
13
+ }
@@ -0,0 +1,164 @@
1
+ import { BaseControl, Button, Popover, Modal, TextControl, CheckboxControl, __experimentalHStack as HStack, __experimentalSpacer as Spacer } from '@wordpress/components';
2
+ import { useState, useContext } from '@wordpress/element';
3
+ import { __ } from '@wordpress/i18n';
4
+ import { __experimentalLinkControl as LinkControl } from '@wordpress/block-editor';
5
+ import { ControlContext } from '../control-context';
6
+
7
+ /**
8
+ * URL control — stores `{ url, text, opensInNewTab }`.
9
+ *
10
+ * Two renderings of the same stored shape:
11
+ * - **Sidebar context** (block Inspector): compact summary + Edit popover
12
+ * that mounts the rich @wordpress/block-editor LinkControl.
13
+ * - **Meta-box context** (post-fields panel): three stacked inputs
14
+ * (URL, link text, open-in-new-tab checkbox). The popover-based UI
15
+ * feels out of place in a wide meta-box and the LinkControl's
16
+ * post/page suggestions push the popover off-screen.
17
+ *
18
+ * Both render the same data; the chooser is the `variant` field on the
19
+ * ControlContext provider supplied by the host (block edit.js vs the
20
+ * post-fields meta-box App). Default is sidebar.
21
+ *
22
+ * When wiring a React component on the frontend:
23
+ * const { url, text, opensInNewTab } = link || {};
24
+ * if (!url) return null;
25
+ * return <a
26
+ * href={url}
27
+ * target={opensInNewTab ? '_blank' : undefined}
28
+ * rel={opensInNewTab ? 'noopener noreferrer' : undefined}
29
+ * >{text || url}</a>;
30
+ */
31
+ export default function UrlField({ control, value, onChange }) {
32
+ const ctx = useContext(ControlContext);
33
+ const link = value && typeof value === 'object'
34
+ ? value
35
+ : { url: '', text: '', opensInNewTab: false };
36
+
37
+ if (ctx.variant === 'metabox') {
38
+ return <MetaboxUrl control={control} link={link} onChange={onChange} />;
39
+ }
40
+ return <SidebarUrl control={control} link={link} onChange={onChange} />;
41
+ }
42
+
43
+ function MetaboxUrl({ control, link, onChange }) {
44
+ const [editing, setEditing] = useState(false);
45
+ // Draft state so Cancel can discard pending edits without touching the
46
+ // real meta value. Seed from `link` whenever the modal opens.
47
+ const [draft, setDraft] = useState(link);
48
+ const hasUrl = !!link.url;
49
+
50
+ const openModal = () => {
51
+ setDraft(link);
52
+ setEditing(true);
53
+ };
54
+
55
+ const save = () => {
56
+ onChange({
57
+ url: draft.url || '',
58
+ text: draft.text || '',
59
+ opensInNewTab: !!draft.opensInNewTab,
60
+ });
61
+ setEditing(false);
62
+ };
63
+
64
+ return (
65
+ <BaseControl label={control.label} help={control.helpText} __nextHasNoMarginBottom>
66
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
67
+ <div style={{ flex: 1, fontSize: 13, color: hasUrl ? '#1e1e1e' : '#757575', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
68
+ {hasUrl ? (link.text || link.url) : __('No link set', 'gcblite')}
69
+ </div>
70
+ <Button variant="secondary" onClick={openModal}>
71
+ {hasUrl ? __('Edit', 'gcblite') : __('Set link', 'gcblite')}
72
+ </Button>
73
+ {hasUrl && (
74
+ <Button variant="tertiary" isDestructive onClick={() => onChange({ url: '', text: '', opensInNewTab: false })}>
75
+ {__('Clear', 'gcblite')}
76
+ </Button>
77
+ )}
78
+ </div>
79
+
80
+ {editing && (
81
+ <Modal
82
+ title={control.label || __('Edit link', 'gcblite')}
83
+ onRequestClose={() => setEditing(false)}
84
+ size="medium"
85
+ >
86
+ <div style={{ display: 'grid', gap: 16 }}>
87
+ <TextControl
88
+ label={__('URL', 'gcblite')}
89
+ value={draft.url || ''}
90
+ onChange={(url) => setDraft({ ...draft, url })}
91
+ placeholder="https://example.com"
92
+ type="url"
93
+ __nextHasNoMarginBottom
94
+ __next40pxDefaultSize
95
+ />
96
+ <TextControl
97
+ label={__('Link text', 'gcblite')}
98
+ value={draft.text || ''}
99
+ onChange={(text) => setDraft({ ...draft, text })}
100
+ placeholder={draft.url || __('Optional display label', 'gcblite')}
101
+ __nextHasNoMarginBottom
102
+ __next40pxDefaultSize
103
+ />
104
+ <CheckboxControl
105
+ label={__('Open in new tab', 'gcblite')}
106
+ checked={!!draft.opensInNewTab}
107
+ onChange={(opensInNewTab) => setDraft({ ...draft, opensInNewTab })}
108
+ __nextHasNoMarginBottom
109
+ />
110
+ </div>
111
+ <Spacer marginTop={6} />
112
+ <HStack justify="flex-end" spacing={3}>
113
+ <Button variant="tertiary" onClick={() => setEditing(false)}>
114
+ {__('Cancel', 'gcblite')}
115
+ </Button>
116
+ <Button variant="primary" onClick={save}>
117
+ {__('Save link', 'gcblite')}
118
+ </Button>
119
+ </HStack>
120
+ </Modal>
121
+ )}
122
+ </BaseControl>
123
+ );
124
+ }
125
+
126
+ function SidebarUrl({ control, link, onChange }) {
127
+ const [editing, setEditing] = useState(false);
128
+ const hasUrl = !!link.url;
129
+
130
+ return (
131
+ <BaseControl label={control.label} help={control.helpText} __nextHasNoMarginBottom>
132
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
133
+ <div style={{ flex: 1, fontSize: 12, color: hasUrl ? '#1e1e1e' : '#757575', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
134
+ {hasUrl ? (link.text || link.url) : __('No link set', 'gcblite')}
135
+ </div>
136
+ <Button variant="secondary" size="small" onClick={() => setEditing((e) => !e)}>
137
+ {hasUrl ? __('Edit', 'gcblite') : __('Set link', 'gcblite')}
138
+ </Button>
139
+ {hasUrl && (
140
+ <Button variant="tertiary" size="small" isDestructive onClick={() => onChange({ url: '', text: '', opensInNewTab: false })}>
141
+ {__('Clear', 'gcblite')}
142
+ </Button>
143
+ )}
144
+ </div>
145
+ {editing && (
146
+ <Popover onClose={() => setEditing(false)} placement="bottom-start">
147
+ <div style={{ width: 320, padding: 8 }}>
148
+ <LinkControl
149
+ value={link}
150
+ onChange={(next) => onChange({
151
+ url: next.url || '',
152
+ text: next.title || link.text || '',
153
+ opensInNewTab: !!next.opensInNewTab,
154
+ })}
155
+ settings={[
156
+ { id: 'opensInNewTab', title: __('Open in new tab', 'gcblite') },
157
+ ]}
158
+ />
159
+ </div>
160
+ </Popover>
161
+ )}
162
+ </BaseControl>
163
+ );
164
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * UserField — ported from the original GCB.
3
+ *
4
+ * Single OR multiple user selection (control.multiple). Stored as ID(s) or
5
+ * full user object(s) (control.returnFormat). Drag-and-drop reordering for
6
+ * multi-select.
7
+ */
8
+
9
+ import { __ } from '@wordpress/i18n';
10
+ import {
11
+ Button,
12
+ TextControl,
13
+ } from '@wordpress/components';
14
+ import PopoverOrModal from './PopoverOrModal';
15
+ import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
16
+ import apiFetch from '@wordpress/api-fetch';
17
+ import {
18
+ DndContext,
19
+ closestCenter,
20
+ PointerSensor,
21
+ useSensor,
22
+ useSensors,
23
+ DragOverlay,
24
+ } from '@dnd-kit/core';
25
+ import {
26
+ SortableContext,
27
+ verticalListSortingStrategy,
28
+ useSortable,
29
+ arrayMove,
30
+ } from '@dnd-kit/sortable';
31
+ import { CSS } from '@dnd-kit/utilities';
32
+
33
+ const TOGGLE_BUTTON_STYLE = {
34
+ width: '100%',
35
+ height: 'auto',
36
+ padding: '12px',
37
+ justifyContent: 'flex-start',
38
+ border: '1px solid #ddd',
39
+ borderRadius: '2px',
40
+ backgroundColor: '#fff',
41
+ };
42
+
43
+ function UserIcon() {
44
+ return (
45
+ <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 }}>
46
+ <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
47
+ </svg>
48
+ );
49
+ }
50
+
51
+ function SortableUserItem({ user, onRemove }) {
52
+ const {
53
+ attributes: dndAttributes,
54
+ listeners,
55
+ setNodeRef,
56
+ transform,
57
+ transition,
58
+ isDragging,
59
+ } = useSortable({ id: user.id });
60
+
61
+ const style = {
62
+ transform: CSS.Transform.toString(transform),
63
+ transition,
64
+ opacity: isDragging ? 0.5 : 1,
65
+ };
66
+
67
+ return (
68
+ <div
69
+ ref={setNodeRef}
70
+ style={style}
71
+ className="gcb-post-object-selected-item"
72
+ {...dndAttributes}
73
+ {...listeners}
74
+ >
75
+ <div className="gcb-post-object-drag-handle" aria-hidden>
76
+ <svg viewBox="0 0 20 20" width="12">
77
+ <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" />
78
+ </svg>
79
+ </div>
80
+ <UserIcon />
81
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'none' }}>
82
+ {user.name || __('(no name)', 'gcblite')}
83
+ </span>
84
+ <button
85
+ type="button"
86
+ onClick={(e) => {
87
+ e.stopPropagation();
88
+ onRemove(user.id);
89
+ }}
90
+ onPointerDown={(e) => e.stopPropagation()}
91
+ className="gcb-sortable-remove"
92
+ aria-label={__('Remove', 'gcblite')}
93
+ >
94
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
95
+ <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" />
96
+ </svg>
97
+ </button>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export default function UserField({ control, value, onChange }) {
103
+ const [allUsers, setAllUsers] = useState([]);
104
+ const [searchResults, setSearchResults] = useState([]);
105
+ const [loading, setLoading] = useState(false);
106
+ const [search, setSearch] = useState('');
107
+ const [activeId, setActiveId] = useState(null);
108
+
109
+ const isMultiple = !!control.multiple;
110
+
111
+ const selectedIds = isMultiple
112
+ ? (Array.isArray(value) ? value : (value ? [value] : []))
113
+ : (value ? [value] : []);
114
+
115
+ const selectedUsers = useMemo(
116
+ () => selectedIds.map((id) => allUsers.find((u) => u.id === id)).filter(Boolean),
117
+ [selectedIds, allUsers]
118
+ );
119
+
120
+ const mergeUsersIntoCache = useCallback((newUsers) => {
121
+ setAllUsers((prev) => {
122
+ const merged = [...prev];
123
+ newUsers.forEach((nu) => {
124
+ if (!merged.find((u) => u.id === nu.id)) merged.push(nu);
125
+ });
126
+ return merged;
127
+ });
128
+ }, []);
129
+
130
+ const loadUsers = useCallback(async (term = '') => {
131
+ setLoading(true);
132
+ try {
133
+ const response = await apiFetch({
134
+ path: `/wp/v2/users?search=${encodeURIComponent(term)}&per_page=50&_fields=id,name`,
135
+ });
136
+ setSearchResults(response);
137
+ mergeUsersIntoCache(response);
138
+ } catch {
139
+ // ignore
140
+ }
141
+ setLoading(false);
142
+ }, [mergeUsersIntoCache]);
143
+
144
+ useEffect(() => {
145
+ loadUsers();
146
+ }, [loadUsers]);
147
+
148
+ const handleSelect = (userId) => {
149
+ const newIds = isMultiple
150
+ ? (selectedIds.includes(userId)
151
+ ? selectedIds.filter((id) => id !== userId)
152
+ : [...selectedIds, userId])
153
+ : [userId];
154
+
155
+ if (control.returnFormat === 'object') {
156
+ const objs = newIds.map((id) => allUsers.find((u) => u.id === id)).filter(Boolean);
157
+ onChange(isMultiple ? objs : (objs[0] || null));
158
+ } else {
159
+ onChange(isMultiple ? newIds : (newIds[0] || null));
160
+ }
161
+ };
162
+
163
+ const handleRemove = (userId) => {
164
+ const newIds = selectedIds.filter((id) => id !== userId);
165
+ if (control.returnFormat === 'object') {
166
+ const objs = newIds.map((id) => allUsers.find((u) => u.id === id)).filter(Boolean);
167
+ onChange(isMultiple ? objs : (objs[0] || null));
168
+ } else {
169
+ onChange(isMultiple ? newIds : (newIds[0] || null));
170
+ }
171
+ };
172
+
173
+ const handleReorder = (newOrder) => {
174
+ if (control.returnFormat === 'object') {
175
+ onChange(newOrder.map((id) => allUsers.find((u) => u.id === id)).filter(Boolean));
176
+ } else {
177
+ onChange(newOrder);
178
+ }
179
+ };
180
+
181
+ const handleClear = () => onChange(isMultiple ? [] : null);
182
+
183
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
184
+
185
+ const handleDragStart = (e) => setActiveId(e.active.id);
186
+ const handleDragCancel = () => setActiveId(null);
187
+ const handleDragEnd = (e) => {
188
+ const { active, over } = e;
189
+ if (over && active.id !== over.id) {
190
+ const oldIndex = selectedIds.indexOf(active.id);
191
+ const newIndex = selectedIds.indexOf(over.id);
192
+ handleReorder(arrayMove(selectedIds, oldIndex, newIndex));
193
+ }
194
+ setActiveId(null);
195
+ };
196
+
197
+ const activeUser = activeId ? selectedUsers.find((u) => u.id === activeId) : null;
198
+ const availableUsers = searchResults.filter((u) => !selectedIds.includes(u.id));
199
+
200
+ return (
201
+ <div className="components-base-control gcb-user-control">
202
+ <div className="components-base-control__field">
203
+ <label className="components-base-control__label">{control.label}</label>
204
+ </div>
205
+ {control.helpText && (
206
+ <p className="components-base-control__help">{control.helpText}</p>
207
+ )}
208
+
209
+ <div className="gcb-post-object-stacked">
210
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
211
+ <PopoverOrModal
212
+ modalTitle={control.label || __('Select users', 'gcblite')}
213
+ dropdownProps={{ popoverProps: { placement: 'left-start' } }}
214
+ renderToggle={({ isOpen, onToggle }) => (
215
+ <Button
216
+ onClick={onToggle}
217
+ aria-expanded={isOpen}
218
+ className="gcb-modal-toggle-button"
219
+ style={{ ...TOGGLE_BUTTON_STYLE, flex: 1 }}
220
+ >
221
+ {selectedUsers.length > 0
222
+ ? `${selectedUsers.length} ${selectedUsers.length === 1 ? __('user', 'gcblite') : __('users', 'gcblite')} ${__('selected', 'gcblite')}`
223
+ : __('Select Users', 'gcblite')}
224
+ </Button>
225
+ )}
226
+ renderContent={({ close: onClose, variant }) => (
227
+ <div style={variant === 'modal' ? { width: '100%' } : { minWidth: 320, maxWidth: 400 }}>
228
+ <div style={{ padding: '0 16px 8px 16px' }}>
229
+ <TextControl
230
+ value={search}
231
+ onChange={(val) => {
232
+ setSearch(val);
233
+ loadUsers(val);
234
+ }}
235
+ placeholder={__('Search users…', 'gcblite')}
236
+ __nextHasNoMarginBottom
237
+ />
238
+ </div>
239
+
240
+ <div className="block-editor-link-control__search-results-wrapper" style={{ maxHeight: 300, overflowY: 'auto' }}>
241
+ {loading && (
242
+ <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
243
+ {__('Loading…', 'gcblite')}
244
+ </p>
245
+ )}
246
+ {!loading && availableUsers.length === 0 && (
247
+ <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
248
+ {__('No users found', 'gcblite')}
249
+ </p>
250
+ )}
251
+ {!loading && availableUsers.length > 0 && (
252
+ <div className="block-editor-link-control__search-results" role="listbox">
253
+ <div className="components-menu-group">
254
+ <div role="group">
255
+ {availableUsers.map((user) => (
256
+ <button
257
+ key={user.id}
258
+ type="button"
259
+ role="option"
260
+ className="components-button components-menu-item__button block-editor-link-control__search-item"
261
+ onClick={() => handleSelect(user.id)}
262
+ style={{
263
+ display: 'flex',
264
+ alignItems: 'center',
265
+ width: '100%',
266
+ padding: '8px 16px',
267
+ textAlign: 'left',
268
+ border: 'none',
269
+ background: 'transparent',
270
+ }}
271
+ >
272
+ <UserIcon />
273
+ <span className="components-menu-item__item" style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
274
+ {user.name}
275
+ </span>
276
+ </button>
277
+ ))}
278
+ </div>
279
+ </div>
280
+ </div>
281
+ )}
282
+ </div>
283
+ </div>
284
+ )}
285
+ />
286
+
287
+ {selectedUsers.length > 0 && (
288
+ <Button
289
+ onClick={handleClear}
290
+ variant="secondary"
291
+ isSmall
292
+ className="components-range-control__reset"
293
+ >
294
+ {__('Reset', 'gcblite')}
295
+ </Button>
296
+ )}
297
+ </div>
298
+
299
+ {selectedUsers.length > 0 && isMultiple && (
300
+ <div className="gcb-post-object-selected-list" style={{ marginTop: 8 }}>
301
+ <DndContext
302
+ sensors={sensors}
303
+ collisionDetection={closestCenter}
304
+ onDragStart={handleDragStart}
305
+ onDragEnd={handleDragEnd}
306
+ onDragCancel={handleDragCancel}
307
+ >
308
+ <SortableContext items={selectedIds} strategy={verticalListSortingStrategy}>
309
+ {selectedUsers.map((user) => (
310
+ <SortableUserItem key={user.id} user={user} onRemove={handleRemove} />
311
+ ))}
312
+ </SortableContext>
313
+ <DragOverlay>
314
+ {activeUser ? (
315
+ <div className="gcb-post-object-selected-item" style={{ opacity: 0.8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
316
+ <div className="gcb-post-object-drag-handle" aria-hidden>
317
+ <svg viewBox="0 0 20 20" width="12">
318
+ <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" />
319
+ </svg>
320
+ </div>
321
+ <UserIcon />
322
+ <span style={{ flex: 1 }}>{activeUser.name}</span>
323
+ </div>
324
+ ) : null}
325
+ </DragOverlay>
326
+ </DndContext>
327
+ </div>
328
+ )}
329
+
330
+ {selectedUsers.length > 0 && !isMultiple && (
331
+ <div className="gcb-post-object-selected-list" style={{ marginTop: 8 }}>
332
+ <div className="gcb-post-object-selected-item">
333
+ <UserIcon />
334
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
335
+ {selectedUsers[0].name}
336
+ </span>
337
+ </div>
338
+ </div>
339
+ )}
340
+ </div>
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * WYSIWYG control — backwards-compat alias for the Tiptap-based
3
+ * `richtext` control.
4
+ *
5
+ * Historically this control had two render paths: @wordpress/block-editor's
6
+ * RichText in the Inspector sidebar, and a full wp_editor() / TinyMCE
7
+ * boot on meta-box surfaces. Carrying two rich-text engines (RichText
8
+ * for blocks, TinyMCE for everywhere else, plus Tiptap for the new
9
+ * `richtext` type) was three engines too many — and the meta-box
10
+ * TinyMCE path dragged jQuery + the wp.editor global namespace along
11
+ * with it.
12
+ *
13
+ * Now: one Tiptap-backed component serves every surface. `wysiwyg`
14
+ * stays in the registry as a stable alias so themes already using
15
+ * `"type": "wysiwyg"` in block.fields.json or post-fields configs
16
+ * don't have to change anything. Storage shape (HTML string) is
17
+ * unchanged, so existing meta values continue to round-trip cleanly.
18
+ *
19
+ * For new fields, `richtext` is the canonical type name. `wysiwyg`
20
+ * is kept for back-compat.
21
+ */
22
+
23
+ export { default } from './richtext';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * useTokens — theme.json + built-in design tokens.
3
+ *
4
+ * Theme tokens come from GcbFieldsProvider when a host injects them, else fall
5
+ * back to window.gcbLite.tokens (handled inside useTokensConfig). Built-ins are
6
+ * merged in by getAllTokenGroups.
7
+ */
8
+ import { useState, useEffect } from '@wordpress/element';
9
+ import { getAllTokenGroups } from '../utils/token-helper';
10
+ import { useTokensConfig } from '../provider';
11
+
12
+ export function useTokens() {
13
+ const themeTokens = useTokensConfig();
14
+ const [tokens, setTokens] = useState(() => getAllTokenGroups(themeTokens));
15
+
16
+ useEffect(() => {
17
+ setTokens(getAllTokenGroups(themeTokens));
18
+ }, [themeTokens]);
19
+
20
+ return { tokens, loading: false, error: null };
21
+ }
22
+
23
+ /**
24
+ * Resolve a `tokenGroup` value (e.g. "custom:gap") to its tokens array.
25
+ */
26
+ export function getTokensByGroup(allTokens, tokenGroup) {
27
+ if (!allTokens || !tokenGroup) return null;
28
+ const [categoryKey, subKey] = tokenGroup.split(':');
29
+ const category = allTokens[categoryKey];
30
+ if (!category?.children) return null;
31
+ return category.children[subKey]?.tokens || null;
32
+ }
33
+
34
+ /**
35
+ * Build a `key → { label, token }` map for SelectField / RangeField legacy shape.
36
+ */
37
+ export function generateMapFromTokens(tokens) {
38
+ if (!Array.isArray(tokens)) return null;
39
+ const map = {};
40
+ tokens.forEach((t) => {
41
+ map[t.key] = { label: t.label, token: t.slug || t.value };
42
+ });
43
+ return map;
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @gcb/fields — public API.
3
+ *
4
+ * The GCB typed-field UI as a standalone package: control components, the
5
+ * inspector renderer that turns a `block.fields.json` `controls` array into a
6
+ * settings panel, conditional-logic + validation helpers, and the design-token
7
+ * helpers. No hard dependency on the GCB WordPress plugin — host data
8
+ * (tokens, google-maps gate, etc.) is supplied via <GcbFieldsProvider>, with a
9
+ * window.gcbLite fallback so the existing plugin keeps working unchanged while
10
+ * it adopts the SDK.
11
+ *
12
+ * Not included (stays in the plugin — see gcb-pro/docs/eject-blocks-scope.md):
13
+ * the gcb/repeater InnerBlocks *block* + useRepeaterSeeding/useRepeaterValidation
14
+ * (editor-coupled), the PHP-preview pipeline, focus-field handling, the admin
15
+ * builder. The repeater *control* (form-of-forms field) IS included.
16
+ */
17
+
18
+ // Injected-config boundary.
19
+ export {
20
+ GcbFieldsProvider,
21
+ GcbFieldsContext,
22
+ useGcbFieldsConfig,
23
+ useTokensConfig,
24
+ useGoogleMapsEnabled,
25
+ } from './provider';
26
+
27
+ // Inspector renderer + the control registry.
28
+ export { renderInspector } from './inspector';
29
+ export { controlComponents } from './controls';
30
+
31
+ // Conditional logic + validation/control contexts (host-overridable).
32
+ export { shouldRender, panelsContainingErrors, STRUCTURAL_TYPES } from './conditional-logic';
33
+ export { ValidationContext } from './validation-context';
34
+ export { ControlContext } from './control-context';
35
+
36
+ // Token helpers (for custom token-aware UI outside the standard controls).
37
+ export { useTokens, getTokensByGroup, generateMapFromTokens } from './hooks/useTokens';
38
+ export { getAllTokenGroups } from './utils/token-helper';