@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
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @wordpress-gcb/fields
2
+
3
+ GCB's typed-field UI as a standalone package: the control components, the
4
+ inspector renderer (`block.fields.json` → settings panel), and the
5
+ conditional-logic / validation helpers — **with no dependency on the GCB
6
+ WordPress plugin's globals or REST endpoints**.
7
+
8
+ This is the free, open core of GCB. The Lite plugin consumes it; headless setups
9
+ can use it directly. See `~/sites/gcb/README.md` for how this fits the whole
10
+ project, and `gcb-pro/docs/eject-blocks-scope.md` for the architecture rationale.
11
+
12
+ **Status: skeleton.** The public API surface and the injected-config contract are
13
+ defined (`src/index.js`); the control source is extracted from the plugin in a
14
+ later step — see `EXTRACTION.md`.
15
+
16
+ ## Why it exists
17
+
18
+ The plugin currently bakes the controls into itself and feeds them via
19
+ `window.gcbLite` + plugin REST. That couples the field UI to the plugin. Pulling
20
+ it into `@wordpress-gcb/fields` means:
21
+
22
+ - headless editors get the exact same controls without the plugin;
23
+ - "eject" can scaffold a block that uses the SDK and works plugin-free;
24
+ - graceful degradation: static `block.json` is the floor, the SDK restores live UI.
25
+
26
+ ## Host contract (what you inject)
27
+
28
+ The plugin used to read these off `window.gcbLite`; here they're explicit:
29
+
30
+ | Inject | Replaces | For |
31
+ | --- | --- | --- |
32
+ | `controls` (arg to `renderInspector`) | `window.gcbLite.blocks[name].controls` | the fields to render |
33
+ | `tokens` | `window.gcbLite.tokens` | token-aware controls (select, range, spacing) |
34
+ | `googleMapsEnabled` | `window.gcbLite.googleMaps.hasApiKey` | the google-map control |
35
+ | `media` adapter | `wp.media` / block-editor `MediaUpload` | image / gallery / file / richtext |
36
+ | `apiFetch` (optional) | `@wordpress/api-fetch` | reference controls — **core `wp/v2` only** |
37
+ | `variant` | `ControlContext` | `'sidebar'` (popovers) vs `'metabox'` (modals) |
38
+
39
+ ## Not included (stays in the plugin)
40
+
41
+ The `gcb/repeater` **block** + `useRepeaterSeeding` / `useRepeaterValidation`
42
+ (editor-coupled: clientId, block/editor stores, the validation transient), the
43
+ PHP-preview pipeline, focus-field handling, the admin builder. The `repeater`
44
+ **control** (form-of-forms field) IS included — don't confuse the two.
45
+
46
+ ## Peer vs bundled deps
47
+
48
+ - **peerDependencies:** all `@wordpress/*` — provided by the host editor.
49
+ - **bundled:** `@dnd-kit/*`, `@tiptap/*` — not part of the WP runtime.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@wordpress-gcb/fields",
3
+ "version": "0.2.0",
4
+ "description": "GCB typed-field controls, inspector renderer, and schema — decoupled from the WordPress plugin so any Gutenberg/headless editor can use them.",
5
+ "license": "GPL-2.0-or-later",
6
+ "//type": "Intentionally NOT \"type\": \"module\" — the source is ESM syntax consumed by the host's bundler (wp-scripts/webpack), which handles extensionless relative imports. Marking it a strict ESM module would force fully-specified .js extensions on every import.",
7
+ "main": "./src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./conditional-logic": "./src/conditional-logic.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "test": "echo \"no tests yet\" && exit 0"
17
+ },
18
+ "//": "WordPress packages are provided by the host editor (externalised in wp-scripts builds) — peer deps, not bundled.",
19
+ "peerDependencies": {
20
+ "@wordpress/components": ">=27",
21
+ "@wordpress/element": ">=5",
22
+ "@wordpress/i18n": ">=4",
23
+ "@wordpress/block-editor": ">=12",
24
+ "@wordpress/api-fetch": ">=6",
25
+ "@wordpress/blocks": ">=12",
26
+ "@wordpress/data": ">=9",
27
+ "@wordpress/icons": ">=9"
28
+ },
29
+ "//deps": "Bundled because they are NOT part of the WordPress runtime: the rich-control libraries.",
30
+ "dependencies": {
31
+ "@dnd-kit/core": "^6.3.1",
32
+ "@dnd-kit/sortable": "^10.0.0",
33
+ "@dnd-kit/utilities": "^3.2.2",
34
+ "@tiptap/react": "^3.23.6",
35
+ "@tiptap/starter-kit": "^3.23.6",
36
+ "@tiptap/extension-link": "^3.23.6",
37
+ "@tiptap/extension-image": "^3.23.6"
38
+ }
39
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Conditional logic — pure functions, no React, no @wordpress/* deps.
3
+ *
4
+ * Kept separate from inspector.js so unit tests can import without
5
+ * pulling in the whole control library. Keep in sync with PHP mirror:
6
+ * includes/PostFields/Conditional.php
7
+ *
8
+ * Config shape on a control:
9
+ *
10
+ * {
11
+ * conditionalLogic: {
12
+ * enabled: true,
13
+ * operator: 'and' | 'or', // default: 'and'
14
+ * rules: [
15
+ * { field: 'show_cta', operator: '==', value: true },
16
+ * { field: 'count', operator: '>', value: 0 },
17
+ * ],
18
+ * }
19
+ * }
20
+ */
21
+
22
+ // Structural control types (group, panel, tools-panel) render as panel
23
+ // containers, not as fields. Exported so callers that walk the control
24
+ // list can skip them consistently.
25
+ export const STRUCTURAL_TYPES = new Set(['group', 'panel', 'tools-panel']);
26
+
27
+ /**
28
+ * Decide whether a control should render against the current attribute
29
+ * values. A control with no `conditionalLogic` (or a disabled one) is
30
+ * always rendered.
31
+ */
32
+ export function shouldRender(control, attributes) {
33
+ const cl = control.conditionalLogic;
34
+ if (!cl?.enabled || !Array.isArray(cl.rules) || cl.rules.length === 0) {
35
+ return true;
36
+ }
37
+ const op = cl.operator === 'or' ? 'or' : 'and';
38
+ const results = cl.rules.map((rule) => evalRule(rule, attributes));
39
+ return op === 'or' ? results.some(Boolean) : results.every(Boolean);
40
+ }
41
+
42
+ function evalRule(rule, attributes) {
43
+ const actual = attributes[rule.field];
44
+ switch (rule.operator) {
45
+ case '==': return actual == rule.value; // eslint-disable-line eqeqeq
46
+ case '!=': return actual != rule.value; // eslint-disable-line eqeqeq
47
+ case '>': return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) > Number(rule.value);
48
+ case '<': return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) < Number(rule.value);
49
+ case '>=': return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) >= Number(rule.value);
50
+ case '<=': return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) <= Number(rule.value);
51
+ case 'contains':
52
+ return typeof actual === 'string' && actual.includes(rule.value);
53
+ case 'in':
54
+ return Array.isArray(rule.value) && rule.value.includes(actual);
55
+ default:
56
+ return true;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Walk controls and return the set of panel ids that contain at least one
62
+ * control whose attributeKey appears in `errors`. Used by the meta-box to
63
+ * auto-open panels that have validation errors.
64
+ */
65
+ export function panelsContainingErrors(controls, errors) {
66
+ const errorKeys = new Set(Object.keys(errors || {}));
67
+ if (errorKeys.size === 0) return new Set();
68
+
69
+ const ids = new Set();
70
+ for (const c of controls) {
71
+ if (STRUCTURAL_TYPES.has(c.type)) continue;
72
+ if (c.attributeKey && errorKeys.has(c.attributeKey) && c.parentPanelId) {
73
+ ids.add(c.parentPanelId);
74
+ }
75
+ }
76
+ return ids;
77
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tells controls *which surface* they're being rendered into so they can
3
+ * render differently. Set by the host (block edit.js for Inspector, post-
4
+ * fields App for meta-boxes).
5
+ *
6
+ * Values:
7
+ * - 'sidebar' (default): the block editor's Inspector — narrow column,
8
+ * popovers welcome.
9
+ * - 'metabox': a classic add_meta_box panel — wider, popovers feel
10
+ * out-of-place, controls should lay out inline.
11
+ *
12
+ * Controls that don't care can ignore the context entirely.
13
+ */
14
+
15
+ import { createContext } from '@wordpress/element';
16
+
17
+ export const ControlContext = createContext({ variant: 'sidebar' });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Surface-aware replacement for @wordpress/block-editor's MediaUploadCheck.
3
+ *
4
+ * MediaUploadCheck reads from the `core/editor` store to decide whether to
5
+ * render its children — but `core/editor` is only bootstrapped on screens
6
+ * that load the block editor. On a classic meta-box-only screen (which is
7
+ * what gcb-lite post-fields produces by stripping 'editor' support from
8
+ * field-only CPTs), the store doesn't exist and MediaUploadCheck silently
9
+ * renders nothing. Result: image / gallery / file fields appear as a label
10
+ * only.
11
+ *
12
+ * We don't need MediaUploadCheck on the meta-box surface because admin-side
13
+ * gcb-lite enqueues only happen for users WP already granted screen access
14
+ * to — anyone editing this post has at minimum `edit_posts`. (Real upload
15
+ * gating still happens server-side inside wp.media when the user tries to
16
+ * actually upload a file.)
17
+ *
18
+ * On the sidebar (block editor) surface, MediaUploadCheck still matters —
19
+ * blocks can be rendered in contexts where the editor store exists but the
20
+ * user can't upload. So we pass through to it there.
21
+ */
22
+
23
+ import { useContext } from '@wordpress/element';
24
+ import { MediaUploadCheck } from '@wordpress/block-editor';
25
+ import { ControlContext } from '../control-context';
26
+
27
+ export default function MediaCapabilityGate({ children }) {
28
+ const ctx = useContext(ControlContext);
29
+ if (ctx.variant === 'metabox') {
30
+ return children;
31
+ }
32
+ return <MediaUploadCheck>{children}</MediaUploadCheck>;
33
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * MediaPicker — a `MediaUpload`-shaped component that works on any
3
+ * admin screen.
4
+ *
5
+ * `@wordpress/block-editor`'s `MediaUpload` requires the `core/block-editor`
6
+ * data store, which is only initialised on screens that bootstrap the
7
+ * block editor itself (post.php, site-editor.php, widgets.php). On
8
+ * profile.php, term.php, options.php — anywhere we mount our post-fields
9
+ * bundle on a classic admin page — the store isn't there, so the
10
+ * MediaUpload modal silently fails to open and the image / gallery /
11
+ * file controls feel broken.
12
+ *
13
+ * This wrapper falls back to the lower-level `wp.media()` JS API on
14
+ * non-editor screens. wp.media is what WP's own avatar uploader uses
15
+ * and is loaded by wp_enqueue_media() — available on every admin page
16
+ * we care about. It's not React; we wrap it in the same render-prop
17
+ * shape MediaUpload exposes so callers don't need to know which path
18
+ * they're on.
19
+ *
20
+ * Shape:
21
+ * <MediaPicker
22
+ * onSelect={(media) => …} // single media object (or array)
23
+ * allowedTypes={['image']}
24
+ * value={123 | [123,124] | null}
25
+ * multiple={false}
26
+ * gallery={false}
27
+ * render={({ open }) => <button onClick={open}>Pick</button>}
28
+ * />
29
+ */
30
+
31
+ import { useContext } from '@wordpress/element';
32
+ import { MediaUpload } from '@wordpress/block-editor';
33
+ import { ControlContext } from '../control-context';
34
+
35
+ /**
36
+ * Translate a wp.media attachment model into the same plain object
37
+ * @wordpress/block-editor's MediaUpload onSelect receives.
38
+ */
39
+ function toPlainAttachment(attachment) {
40
+ const a = attachment.toJSON ? attachment.toJSON() : attachment;
41
+ return {
42
+ id: a.id,
43
+ url: a.url,
44
+ alt: a.alt || '',
45
+ title: a.title || a.filename || '',
46
+ filename: a.filename || '',
47
+ caption: a.caption || '',
48
+ description: a.description || '',
49
+ mime: a.mime || a.type || '',
50
+ type: a.type || '',
51
+ subtype: a.subtype || '',
52
+ width: a.width,
53
+ height: a.height,
54
+ filesizeInBytes: a.filesizeInBytes,
55
+ sizes: a.sizes,
56
+ };
57
+ }
58
+
59
+ function openClassicMediaFrame({ allowedTypes, multiple, gallery, value, onSelect }) {
60
+ if (typeof window === 'undefined' || !window.wp || !window.wp.media) {
61
+ // eslint-disable-next-line no-console
62
+ console.warn('gcb-lite: wp.media not loaded — call wp_enqueue_media() on this screen.');
63
+ return;
64
+ }
65
+
66
+ // IMPORTANT: don't open directly into 'gallery-edit' — WP 7.0's
67
+ // media-views.js bootstraps menu/router state lazily, and asking for
68
+ // 'gallery-edit' before the menu state exists throws
69
+ // `Cannot read properties of undefined (reading 'get')` in
70
+ // setMenuTabPanelAriaAttributes. The supported gallery flow is to open
71
+ // the library with `multiple: true`, pre-select the existing images,
72
+ // and treat the result as a gallery on 'select'.
73
+ const isMulti = !!(gallery || multiple);
74
+
75
+ const frame = window.wp.media({
76
+ title: gallery ? 'Edit gallery' : (isMulti ? 'Select media' : 'Select media'),
77
+ button: { text: gallery ? 'Update gallery' : 'Use this media' },
78
+ library: allowedTypes ? { type: allowedTypes } : undefined,
79
+ multiple: isMulti,
80
+ });
81
+
82
+ // Pre-select prior value so the user lands on the existing pick.
83
+ frame.on('open', () => {
84
+ const selection = frame.state().get('selection');
85
+ if (!selection) return;
86
+ const ids = Array.isArray(value) ? value : (value ? [value] : []);
87
+ ids.forEach((id) => {
88
+ if (!id) return;
89
+ const attachment = window.wp.media.attachment(id);
90
+ if (!attachment) return;
91
+ attachment.fetch();
92
+ selection.add([attachment]);
93
+ });
94
+ });
95
+
96
+ frame.on('select', () => {
97
+ const selection = frame.state().get('selection');
98
+ if (!selection) return;
99
+ const picks = selection.toArray().map(toPlainAttachment);
100
+ if (isMulti) {
101
+ onSelect(picks);
102
+ } else {
103
+ onSelect(picks[0] || null);
104
+ }
105
+ });
106
+
107
+ frame.open();
108
+ }
109
+
110
+ /**
111
+ * Decide which backend to use based on render surface:
112
+ *
113
+ * - Block Inspector ('sidebar' variant): MediaUpload from
114
+ * @wordpress/block-editor. That's the right tool inside the block
115
+ * editor — it integrates with the editor's upload flow + capability
116
+ * store.
117
+ *
118
+ * - Post-fields meta-box / Options / Taxonomy / User pages
119
+ * ('metabox' variant): wp.media() directly. These screens often DON'T
120
+ * bootstrap the block editor (we strip 'editor' support from
121
+ * field-only CPTs), and MediaUpload silently renders nothing when the
122
+ * editor stores aren't fully initialised. wp.media() is the lower-
123
+ * level JS API loaded by wp_enqueue_media() — works on any admin page.
124
+ *
125
+ * Surface decision is made via ControlContext, which post-fields.js
126
+ * sets to 'metabox' and the block Inspector leaves at 'sidebar'.
127
+ */
128
+ export default function MediaPicker(props) {
129
+ const ctx = useContext(ControlContext);
130
+
131
+ if (ctx.variant !== 'metabox') {
132
+ return <MediaUpload {...props} />;
133
+ }
134
+
135
+ // Metabox surface — synthesize the same render-prop shape with a
136
+ // wp.media() backend.
137
+ const open = () => openClassicMediaFrame(props);
138
+ return props.render ? props.render({ open }) : null;
139
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * MediaTriggerBadges — places an inline clear (×) button to the right of
3
+ * a media-control toggle. Used by ImageField and FileField on the metabox
4
+ * surface. Edit affordance isn't needed — clicking the trigger itself
5
+ * opens the picker/popover.
6
+ */
7
+
8
+ import { __ } from '@wordpress/i18n';
9
+ import { Button } from '@wordpress/components';
10
+
11
+ // Default sizing is content-width (inline-flex). In sidebar / Inspector
12
+ // contexts an external rule promotes it to full-width — see editor.scss
13
+ // "Sidebar / Inspector surface: media trigger fills width".
14
+ export default function MediaTriggerBadges({ children, onClear, hideClear }) {
15
+ return (
16
+ <span className="gcb-media-trigger-badges">
17
+ {children}
18
+ {!hideClear && (
19
+ <Button
20
+ variant="tertiary"
21
+ isDestructive
22
+ onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClear?.(); }}
23
+ aria-label={__('Clear', 'gcblite')}
24
+ title={__('Clear', 'gcblite')}
25
+ >
26
+
27
+ </Button>
28
+ )}
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Container that hosts a control's "rich settings" UI in the right surface
3
+ * for the current ControlContext:
4
+ * - sidebar → <Dropdown> (popover anchored to the toggle)
5
+ * - metabox → <Modal> (centered overlay with backdrop)
6
+ *
7
+ * Why: popovers feel right in a narrow sidebar where they extend outward,
8
+ * but in a wide meta-box they're awkward (they can run off-screen, they
9
+ * obscure adjacent fields, and they don't have the visual weight to read
10
+ * as "editing this field"). Modals match meta-box context.
11
+ *
12
+ * Usage:
13
+ * <PopoverOrModal
14
+ * renderToggle={({ onToggle, isOpen }) => <Button onClick={onToggle}>Open</Button>}
15
+ * renderContent={({ close, variant }) => (
16
+ * <div style={variant === 'modal' ? { width: '100%' } : { minWidth: 320, maxWidth: 400 }}>
17
+ * <MySettings onDone={close} />
18
+ * </div>
19
+ * )}
20
+ * modalTitle="Image settings"
21
+ * />
22
+ *
23
+ * renderContent receives `{ close, variant }`:
24
+ * - close(): dismiss the popover or modal
25
+ * - variant: 'popover' | 'modal' — lets callers branch widths,
26
+ * padding, etc. ('modal' contents typically want to
27
+ * fill the modal width; 'popover' contents want a
28
+ * comfortable popover-sized box.)
29
+ *
30
+ * The dropdown render passes the same { onToggle, isOpen } shape as
31
+ * @wordpress/components Dropdown.renderToggle so the call-site doesn't
32
+ * have to know which it's getting.
33
+ */
34
+
35
+ import { useContext, useState } from '@wordpress/element';
36
+ import { Dropdown, Modal } from '@wordpress/components';
37
+ import { ControlContext } from '../control-context';
38
+
39
+ export default function PopoverOrModal({
40
+ renderToggle,
41
+ renderContent,
42
+ modalTitle,
43
+ modalSize = 'medium',
44
+ dropdownProps = {},
45
+ }) {
46
+ const ctx = useContext(ControlContext);
47
+
48
+ if (ctx.variant === 'metabox') {
49
+ return (
50
+ <ModalAffordance
51
+ renderToggle={renderToggle}
52
+ renderContent={renderContent}
53
+ modalTitle={modalTitle}
54
+ modalSize={modalSize}
55
+ />
56
+ );
57
+ }
58
+
59
+ return (
60
+ <Dropdown
61
+ {...dropdownProps}
62
+ renderToggle={renderToggle}
63
+ renderContent={({ onClose }) => renderContent({ close: onClose, variant: 'popover' })}
64
+ />
65
+ );
66
+ }
67
+
68
+ function ModalAffordance({ renderToggle, renderContent, modalTitle, modalSize }) {
69
+ const [open, setOpen] = useState(false);
70
+ const onToggle = () => setOpen((v) => !v);
71
+ const close = () => setOpen(false);
72
+
73
+ return (
74
+ <>
75
+ {renderToggle({ onToggle, isOpen: open })}
76
+ {open && (
77
+ <Modal title={modalTitle} onRequestClose={close} size={modalSize}>
78
+ {renderContent({ close, variant: 'modal' })}
79
+ </Modal>
80
+ )}
81
+ </>
82
+ );
83
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Sortable item — ported from the original GCB SortableItemComponent.
3
+ *
4
+ * Used by gallery, post-object (and any other future multi-select reorderable
5
+ * field) to render a draggable row with handle, title, and remove button.
6
+ */
7
+ import { useSortable } from '@dnd-kit/sortable';
8
+ import { CSS } from '@dnd-kit/utilities';
9
+ import { __ } from '@wordpress/i18n';
10
+
11
+ const ICONS = {
12
+ post: {
13
+ viewBox: '0 0 24 24',
14
+ path: (
15
+ <>
16
+ <path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" />
17
+ <path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" />
18
+ </>
19
+ ),
20
+ },
21
+ tag: {
22
+ viewBox: '0 0 24 24',
23
+ path: <path d="M12.5 4.5h-2v7h7v-2l-5-5zm-2.5 8.5v5h2v-5h-2zm5 5h2v-5h-2v5z" />,
24
+ },
25
+ image: {
26
+ viewBox: '0 0 24 24',
27
+ path: <path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z" />,
28
+ },
29
+ };
30
+
31
+ export default function SortableItem({ item, onRemove, icon = 'post', getTitle, thumb }) {
32
+ const {
33
+ attributes,
34
+ listeners,
35
+ setNodeRef,
36
+ transform,
37
+ transition,
38
+ isDragging,
39
+ } = useSortable({ id: item.id });
40
+
41
+ const style = {
42
+ transform: CSS.Transform.toString(transform),
43
+ transition,
44
+ opacity: isDragging ? 0.5 : 1,
45
+ };
46
+
47
+ const selectedIcon = ICONS[icon] || ICONS.post;
48
+
49
+ return (
50
+ <div
51
+ ref={setNodeRef}
52
+ style={style}
53
+ className="gcb-sortable-item"
54
+ {...attributes}
55
+ {...listeners}
56
+ >
57
+ <div className="gcb-sortable-drag-handle" aria-hidden>
58
+ <svg viewBox="0 0 20 20" width="12">
59
+ <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" />
60
+ </svg>
61
+ </div>
62
+
63
+ {thumb ? (
64
+ <img
65
+ src={thumb}
66
+ alt=""
67
+ style={{
68
+ width: 28, height: 28, borderRadius: 2, objectFit: 'cover',
69
+ marginRight: 8, flexShrink: 0, border: '1px solid #ddd',
70
+ }}
71
+ />
72
+ ) : (
73
+ <svg
74
+ xmlns="http://www.w3.org/2000/svg"
75
+ viewBox={selectedIcon.viewBox}
76
+ width="20"
77
+ height="20"
78
+ style={{ marginRight: 8, flexShrink: 0, opacity: 0.6 }}
79
+ >
80
+ {selectedIcon.path}
81
+ </svg>
82
+ )}
83
+
84
+ <span style={{
85
+ flex: 1,
86
+ overflow: 'hidden',
87
+ textOverflow: 'ellipsis',
88
+ whiteSpace: 'nowrap',
89
+ userSelect: 'none',
90
+ }}>
91
+ {getTitle ? getTitle(item) : (item.title?.rendered || item.name || __('(no title)', 'gcblite'))}
92
+ </span>
93
+
94
+ <button
95
+ type="button"
96
+ onClick={(e) => {
97
+ e.stopPropagation();
98
+ onRemove(item.id);
99
+ }}
100
+ onPointerDown={(e) => e.stopPropagation()}
101
+ className="gcb-sortable-remove"
102
+ aria-label={__('Remove', 'gcblite')}
103
+ >
104
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
105
+ <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" />
106
+ </svg>
107
+ </button>
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ButtonGroup — multi-select shown as a row of toggle buttons. Each option
3
+ * looks like a pressable button; clicking toggles it on/off.
4
+ *
5
+ * Stored shape: array of selected values, same as checkbox-group.
6
+ * ['apple', 'cherry']
7
+ *
8
+ * Why not alias checkbox-group? Checkbox-group is checkboxes-in-a-column
9
+ * (vertical, ☑ ☐ ☐). ButtonGroup is buttons-in-a-row (horizontal, compact,
10
+ * visually weightier). Same data shape, different affordance.
11
+ *
12
+ * For single-select segmented controls use `toggle-group` instead.
13
+ */
14
+
15
+ import { BaseControl, Button, __experimentalHStack as HStack } from '@wordpress/components';
16
+
17
+ export default function ButtonGroupField({ control, value, onChange }) {
18
+ const current = Array.isArray(value) ? value : [];
19
+
20
+ return (
21
+ <BaseControl
22
+ label={control.label}
23
+ help={control.helpText}
24
+ className="gcb-button-group-control components-base-control"
25
+ __nextHasNoMarginBottom
26
+ >
27
+ <HStack spacing={1} justify="flex-start" wrap>
28
+ {(control.options || []).map((option) => {
29
+ const isOn = current.includes(option.value);
30
+ return (
31
+ <Button
32
+ key={option.value}
33
+ variant={isOn ? 'primary' : 'secondary'}
34
+ onClick={() => {
35
+ const next = isOn
36
+ ? current.filter((v) => v !== option.value)
37
+ : [...current, option.value];
38
+ onChange(next);
39
+ }}
40
+ aria-pressed={isOn}
41
+ >
42
+ {option.label}
43
+ </Button>
44
+ );
45
+ })}
46
+ </HStack>
47
+ </BaseControl>
48
+ );
49
+ }