@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.
- package/README.md +49 -0
- package/package.json +39 -0
- package/src/conditional-logic.js +77 -0
- package/src/control-context.js +17 -0
- package/src/controls/MediaCapabilityGate.js +33 -0
- package/src/controls/MediaPicker.js +139 -0
- package/src/controls/MediaTriggerBadges.js +31 -0
- package/src/controls/PopoverOrModal.js +83 -0
- package/src/controls/SortableItem.js +110 -0
- package/src/controls/button-group.js +49 -0
- package/src/controls/checkbox-group.js +55 -0
- package/src/controls/checkbox.js +13 -0
- package/src/controls/code.js +21 -0
- package/src/controls/color.js +235 -0
- package/src/controls/date.js +37 -0
- package/src/controls/datetime.js +54 -0
- package/src/controls/email.js +15 -0
- package/src/controls/file.js +134 -0
- package/src/controls/gallery.js +338 -0
- package/src/controls/google-map.js +117 -0
- package/src/controls/heading-level.js +99 -0
- package/src/controls/icon.js +301 -0
- package/src/controls/image.js +334 -0
- package/src/controls/index.js +95 -0
- package/src/controls/message.js +56 -0
- package/src/controls/number.js +17 -0
- package/src/controls/oembed.js +32 -0
- package/src/controls/page-link.js +9 -0
- package/src/controls/post-object.js +788 -0
- package/src/controls/radio.js +18 -0
- package/src/controls/range.js +110 -0
- package/src/controls/relationship.js +14 -0
- package/src/controls/repeater.js +290 -0
- package/src/controls/richtext.js +505 -0
- package/src/controls/select.js +141 -0
- package/src/controls/size.js +49 -0
- package/src/controls/spacing.js +141 -0
- package/src/controls/taxonomy.js +488 -0
- package/src/controls/text.js +14 -0
- package/src/controls/textarea.js +15 -0
- package/src/controls/toggle-group.js +34 -0
- package/src/controls/toggle.js +13 -0
- package/src/controls/url.js +164 -0
- package/src/controls/user.js +343 -0
- package/src/controls/wysiwyg.js +23 -0
- package/src/hooks/useTokens.js +44 -0
- package/src/index.js +38 -0
- package/src/inspector.js +174 -0
- package/src/provider.js +59 -0
- package/src/utils/map-utils.js +51 -0
- package/src/utils/token-helper.js +243 -0
- 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';
|