@wordpress-gcb/fields 0.2.0 → 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 (92) hide show
  1. package/README.md +51 -35
  2. package/dist/conditional-logic.js +83 -0
  3. package/{src → dist}/control-context.js +3 -2
  4. package/{src → dist}/controls/MediaCapabilityGate.js +12 -8
  5. package/dist/controls/MediaPicker.js +149 -0
  6. package/dist/controls/MediaTriggerBadges.js +35 -0
  7. package/{src → dist}/controls/PopoverOrModal.js +49 -43
  8. package/dist/controls/SortableItem.js +126 -0
  9. package/dist/controls/button-group.js +46 -0
  10. package/dist/controls/checkbox-group.js +65 -0
  11. package/dist/controls/checkbox.js +15 -0
  12. package/dist/controls/code.js +24 -0
  13. package/dist/controls/color.js +241 -0
  14. package/dist/controls/date.js +55 -0
  15. package/dist/controls/datetime.js +61 -0
  16. package/dist/controls/email.js +17 -0
  17. package/dist/controls/file.js +163 -0
  18. package/dist/controls/gallery.js +371 -0
  19. package/dist/controls/google-map.js +143 -0
  20. package/dist/controls/heading-level.js +93 -0
  21. package/dist/controls/icon.js +292 -0
  22. package/dist/controls/image.js +360 -0
  23. package/dist/controls/index.js +88 -0
  24. package/dist/controls/message.js +86 -0
  25. package/dist/controls/number.js +19 -0
  26. package/dist/controls/oembed.js +42 -0
  27. package/{src → dist}/controls/page-link.js +1 -2
  28. package/dist/controls/post-object.js +913 -0
  29. package/dist/controls/radio.js +19 -0
  30. package/dist/controls/range.js +108 -0
  31. package/{src → dist}/controls/relationship.js +12 -7
  32. package/dist/controls/repeater.js +277 -0
  33. package/dist/controls/richtext.js +494 -0
  34. package/dist/controls/select.js +144 -0
  35. package/dist/controls/size.js +59 -0
  36. package/dist/controls/spacing.js +141 -0
  37. package/dist/controls/taxonomy.js +569 -0
  38. package/dist/controls/text.js +16 -0
  39. package/dist/controls/textarea.js +17 -0
  40. package/dist/controls/toggle-group.js +28 -0
  41. package/dist/controls/toggle.js +15 -0
  42. package/dist/controls/url.js +235 -0
  43. package/dist/controls/user.js +383 -0
  44. package/{src → dist}/controls/wysiwyg.js +1 -1
  45. package/{src → dist}/hooks/useTokens.js +25 -21
  46. package/{src → dist}/index.js +2 -8
  47. package/dist/inspector.js +163 -0
  48. package/{src → dist}/provider.js +18 -17
  49. package/dist/utils/map-utils.js +54 -0
  50. package/dist/utils/token-helper.js +396 -0
  51. package/{src → dist}/validation-context.js +4 -4
  52. package/package.json +35 -13
  53. package/src/conditional-logic.js +0 -77
  54. package/src/controls/MediaPicker.js +0 -139
  55. package/src/controls/MediaTriggerBadges.js +0 -31
  56. package/src/controls/SortableItem.js +0 -110
  57. package/src/controls/button-group.js +0 -49
  58. package/src/controls/checkbox-group.js +0 -55
  59. package/src/controls/checkbox.js +0 -13
  60. package/src/controls/code.js +0 -21
  61. package/src/controls/color.js +0 -235
  62. package/src/controls/date.js +0 -37
  63. package/src/controls/datetime.js +0 -54
  64. package/src/controls/email.js +0 -15
  65. package/src/controls/file.js +0 -134
  66. package/src/controls/gallery.js +0 -338
  67. package/src/controls/google-map.js +0 -117
  68. package/src/controls/heading-level.js +0 -99
  69. package/src/controls/icon.js +0 -301
  70. package/src/controls/image.js +0 -334
  71. package/src/controls/index.js +0 -95
  72. package/src/controls/message.js +0 -56
  73. package/src/controls/number.js +0 -17
  74. package/src/controls/oembed.js +0 -32
  75. package/src/controls/post-object.js +0 -788
  76. package/src/controls/radio.js +0 -18
  77. package/src/controls/range.js +0 -110
  78. package/src/controls/repeater.js +0 -290
  79. package/src/controls/richtext.js +0 -505
  80. package/src/controls/select.js +0 -141
  81. package/src/controls/size.js +0 -49
  82. package/src/controls/spacing.js +0 -141
  83. package/src/controls/taxonomy.js +0 -488
  84. package/src/controls/text.js +0 -14
  85. package/src/controls/textarea.js +0 -15
  86. package/src/controls/toggle-group.js +0 -34
  87. package/src/controls/toggle.js +0 -13
  88. package/src/controls/url.js +0 -164
  89. package/src/controls/user.js +0 -343
  90. package/src/inspector.js +0 -174
  91. package/src/utils/map-utils.js +0 -51
  92. package/src/utils/token-helper.js +0 -243
package/README.md CHANGED
@@ -1,49 +1,65 @@
1
1
  # @wordpress-gcb/fields
2
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**.
3
+ Typed-field UI for the WordPress block editor: control components, an inspector
4
+ renderer that turns a `block.fields.json` schema into a settings panel, plus
5
+ conditional-logic and validation helpers — **decoupled from any plugin**. Host
6
+ data (design tokens, media picker, etc.) is injected, so the same controls run
7
+ inside the [GCB Lite plugin](https://github.com/wordpress-gcb/gutenberg-control-blocks-lite),
8
+ a meta box, or a headless editor.
7
9
 
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.
10
+ This is the free, open core of **[GCB](https://gutenbergcontrolblocks.com)**
11
+ [Gutenberg Control Blocks](https://gutenbergcontrolblocks.com).
11
12
 
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`.
13
+ ## Install
15
14
 
16
- ## Why it exists
15
+ ```bash
16
+ npm install @wordpress-gcb/fields
17
+ ```
17
18
 
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:
19
+ `@wordpress/*` are peer dependencies provided by the WordPress editor runtime
20
+ (or your build's externals). `@dnd-kit/*` and `@tiptap/*` ship bundled.
21
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.
22
+ ## Usage
25
23
 
26
- ## Host contract (what you inject)
24
+ ```jsx
25
+ import { GcbFieldsProvider, renderInspector } from '@wordpress-gcb/fields';
27
26
 
28
- The plugin used to read these off `window.gcbLite`; here they're explicit:
27
+ // controls = the `controls` array from a block.fields.json
28
+ <GcbFieldsProvider value={{ tokens, googleMapsEnabled: true }}>
29
+ {renderInspector(controls, attributes, setAttributes)}
30
+ </GcbFieldsProvider>
31
+ ```
29
32
 
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) |
33
+ Each control receives `{ control, value, onChange, attributes }`. The provider
34
+ supplies what the controls need from the host:
38
35
 
39
- ## Not included (stays in the plugin)
36
+ | Inject | For |
37
+ | --- | --- |
38
+ | `tokens` | token-aware controls (select, range, spacing) |
39
+ | `googleMapsEnabled` | the google-map control |
40
+ | `media` | media picker adapter (image / gallery / file / richtext) |
41
+ | `apiFetch` | reference controls — calls core `wp/v2` (optional; defaults to `@wordpress/api-fetch`) |
42
+ | `variant` | `'sidebar'` (popovers) vs `'metabox'` (modals) |
40
43
 
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.
44
+ All are optional; with none provided the package falls back to the WordPress
45
+ editor globals.
45
46
 
46
- ## Peer vs bundled deps
47
+ ## Exports
47
48
 
48
- - **peerDependencies:** all `@wordpress/*` — provided by the host editor.
49
- - **bundled:** `@dnd-kit/*`, `@tiptap/*` not part of the WP runtime.
49
+ - `renderInspector(controls, attributes, setAttributes, options?)`
50
+ - `controlComponents`the control registry (`type` component)
51
+ - `GcbFieldsProvider`, `useGcbFieldsConfig`
52
+ - `shouldRender`, `panelsContainingErrors`, `STRUCTURAL_TYPES`
53
+ - `ValidationContext`, `ControlContext`
54
+ - token helpers: `useTokens`, `getTokensByGroup`, `getAllTokenGroups`
55
+
56
+ ## Links
57
+
58
+ - **Website:** <https://gutenbergcontrolblocks.com>
59
+ - **Source:** <https://github.com/wordpress-gcb/gcb-fields>
60
+ - **Issues:** <https://github.com/wordpress-gcb/gcb-fields/issues>
61
+ - **GCB Lite plugin:** <https://github.com/wordpress-gcb/gutenberg-control-blocks-lite>
62
+
63
+ ## License
64
+
65
+ GPL-2.0-or-later
@@ -0,0 +1,83 @@
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
+ function evalRule(rule, attributes) {
42
+ const actual = attributes[rule.field];
43
+ switch (rule.operator) {
44
+ case '==':
45
+ return actual == rule.value;
46
+ // eslint-disable-line eqeqeq
47
+ case '!=':
48
+ return actual != rule.value;
49
+ // eslint-disable-line eqeqeq
50
+ case '>':
51
+ return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) > Number(rule.value);
52
+ case '<':
53
+ return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) < Number(rule.value);
54
+ case '>=':
55
+ return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) >= Number(rule.value);
56
+ case '<=':
57
+ return Number.isFinite(Number(actual)) && Number.isFinite(Number(rule.value)) && Number(actual) <= Number(rule.value);
58
+ case 'contains':
59
+ return typeof actual === 'string' && actual.includes(rule.value);
60
+ case 'in':
61
+ return Array.isArray(rule.value) && rule.value.includes(actual);
62
+ default:
63
+ return true;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Walk controls and return the set of panel ids that contain at least one
69
+ * control whose attributeKey appears in `errors`. Used by the meta-box to
70
+ * auto-open panels that have validation errors.
71
+ */
72
+ export function panelsContainingErrors(controls, errors) {
73
+ const errorKeys = new Set(Object.keys(errors || {}));
74
+ if (errorKeys.size === 0) return new Set();
75
+ const ids = new Set();
76
+ for (const c of controls) {
77
+ if (STRUCTURAL_TYPES.has(c.type)) continue;
78
+ if (c.attributeKey && errorKeys.has(c.attributeKey) && c.parentPanelId) {
79
+ ids.add(c.parentPanelId);
80
+ }
81
+ }
82
+ return ids;
83
+ }
@@ -13,5 +13,6 @@
13
13
  */
14
14
 
15
15
  import { createContext } from '@wordpress/element';
16
-
17
- export const ControlContext = createContext({ variant: 'sidebar' });
16
+ export const ControlContext = createContext({
17
+ variant: 'sidebar'
18
+ });
@@ -23,11 +23,15 @@
23
23
  import { useContext } from '@wordpress/element';
24
24
  import { MediaUploadCheck } from '@wordpress/block-editor';
25
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
- }
26
+ import { jsx as _jsx } from "react/jsx-runtime";
27
+ export default function MediaCapabilityGate({
28
+ children
29
+ }) {
30
+ const ctx = useContext(ControlContext);
31
+ if (ctx.variant === 'metabox') {
32
+ return children;
33
+ }
34
+ return /*#__PURE__*/_jsx(MediaUploadCheck, {
35
+ children: children
36
+ });
37
+ }
@@ -0,0 +1,149 @@
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
+ import { jsx as _jsx } from "react/jsx-runtime";
40
+ function toPlainAttachment(attachment) {
41
+ const a = attachment.toJSON ? attachment.toJSON() : attachment;
42
+ return {
43
+ id: a.id,
44
+ url: a.url,
45
+ alt: a.alt || '',
46
+ title: a.title || a.filename || '',
47
+ filename: a.filename || '',
48
+ caption: a.caption || '',
49
+ description: a.description || '',
50
+ mime: a.mime || a.type || '',
51
+ type: a.type || '',
52
+ subtype: a.subtype || '',
53
+ width: a.width,
54
+ height: a.height,
55
+ filesizeInBytes: a.filesizeInBytes,
56
+ sizes: a.sizes
57
+ };
58
+ }
59
+ function openClassicMediaFrame({
60
+ allowedTypes,
61
+ multiple,
62
+ gallery,
63
+ value,
64
+ onSelect
65
+ }) {
66
+ if (typeof window === 'undefined' || !window.wp || !window.wp.media) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn('gcb-lite: wp.media not loaded — call wp_enqueue_media() on this screen.');
69
+ return;
70
+ }
71
+
72
+ // IMPORTANT: don't open directly into 'gallery-edit' — WP 7.0's
73
+ // media-views.js bootstraps menu/router state lazily, and asking for
74
+ // 'gallery-edit' before the menu state exists throws
75
+ // `Cannot read properties of undefined (reading 'get')` in
76
+ // setMenuTabPanelAriaAttributes. The supported gallery flow is to open
77
+ // the library with `multiple: true`, pre-select the existing images,
78
+ // and treat the result as a gallery on 'select'.
79
+ const isMulti = !!(gallery || multiple);
80
+ const frame = window.wp.media({
81
+ title: gallery ? 'Edit gallery' : isMulti ? 'Select media' : 'Select media',
82
+ button: {
83
+ text: gallery ? 'Update gallery' : 'Use this media'
84
+ },
85
+ library: allowedTypes ? {
86
+ type: allowedTypes
87
+ } : undefined,
88
+ multiple: isMulti
89
+ });
90
+
91
+ // Pre-select prior value so the user lands on the existing pick.
92
+ frame.on('open', () => {
93
+ const selection = frame.state().get('selection');
94
+ if (!selection) return;
95
+ const ids = Array.isArray(value) ? value : value ? [value] : [];
96
+ ids.forEach(id => {
97
+ if (!id) return;
98
+ const attachment = window.wp.media.attachment(id);
99
+ if (!attachment) return;
100
+ attachment.fetch();
101
+ selection.add([attachment]);
102
+ });
103
+ });
104
+ frame.on('select', () => {
105
+ const selection = frame.state().get('selection');
106
+ if (!selection) return;
107
+ const picks = selection.toArray().map(toPlainAttachment);
108
+ if (isMulti) {
109
+ onSelect(picks);
110
+ } else {
111
+ onSelect(picks[0] || null);
112
+ }
113
+ });
114
+ frame.open();
115
+ }
116
+
117
+ /**
118
+ * Decide which backend to use based on render surface:
119
+ *
120
+ * - Block Inspector ('sidebar' variant): MediaUpload from
121
+ * @wordpress/block-editor. That's the right tool inside the block
122
+ * editor — it integrates with the editor's upload flow + capability
123
+ * store.
124
+ *
125
+ * - Post-fields meta-box / Options / Taxonomy / User pages
126
+ * ('metabox' variant): wp.media() directly. These screens often DON'T
127
+ * bootstrap the block editor (we strip 'editor' support from
128
+ * field-only CPTs), and MediaUpload silently renders nothing when the
129
+ * editor stores aren't fully initialised. wp.media() is the lower-
130
+ * level JS API loaded by wp_enqueue_media() — works on any admin page.
131
+ *
132
+ * Surface decision is made via ControlContext, which post-fields.js
133
+ * sets to 'metabox' and the block Inspector leaves at 'sidebar'.
134
+ */
135
+ export default function MediaPicker(props) {
136
+ const ctx = useContext(ControlContext);
137
+ if (ctx.variant !== 'metabox') {
138
+ return /*#__PURE__*/_jsx(MediaUpload, {
139
+ ...props
140
+ });
141
+ }
142
+
143
+ // Metabox surface — synthesize the same render-prop shape with a
144
+ // wp.media() backend.
145
+ const open = () => openClassicMediaFrame(props);
146
+ return props.render ? props.render({
147
+ open
148
+ }) : null;
149
+ }
@@ -0,0 +1,35 @@
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
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
+ export default function MediaTriggerBadges({
16
+ children,
17
+ onClear,
18
+ hideClear
19
+ }) {
20
+ return /*#__PURE__*/_jsxs("span", {
21
+ className: "gcb-media-trigger-badges",
22
+ children: [children, !hideClear && /*#__PURE__*/_jsx(Button, {
23
+ variant: "tertiary",
24
+ isDestructive: true,
25
+ onClick: e => {
26
+ e.stopPropagation();
27
+ e.preventDefault();
28
+ onClear?.();
29
+ },
30
+ "aria-label": __('Clear', 'gcblite'),
31
+ title: __('Clear', 'gcblite'),
32
+ children: "\u2715"
33
+ })]
34
+ });
35
+ }
@@ -35,49 +35,55 @@
35
35
  import { useContext, useState } from '@wordpress/element';
36
36
  import { Dropdown, Modal } from '@wordpress/components';
37
37
  import { ControlContext } from '../control-context';
38
-
38
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
39
39
  export default function PopoverOrModal({
40
- renderToggle,
41
- renderContent,
42
- modalTitle,
43
- modalSize = 'medium',
44
- dropdownProps = {},
40
+ renderToggle,
41
+ renderContent,
42
+ modalTitle,
43
+ modalSize = 'medium',
44
+ dropdownProps = {}
45
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
- );
46
+ const ctx = useContext(ControlContext);
47
+ if (ctx.variant === 'metabox') {
48
+ return /*#__PURE__*/_jsx(ModalAffordance, {
49
+ renderToggle: renderToggle,
50
+ renderContent: renderContent,
51
+ modalTitle: modalTitle,
52
+ modalSize: modalSize
53
+ });
54
+ }
55
+ return /*#__PURE__*/_jsx(Dropdown, {
56
+ ...dropdownProps,
57
+ renderToggle: renderToggle,
58
+ renderContent: ({
59
+ onClose
60
+ }) => renderContent({
61
+ close: onClose,
62
+ variant: 'popover'
63
+ })
64
+ });
83
65
  }
66
+ function ModalAffordance({
67
+ renderToggle,
68
+ renderContent,
69
+ modalTitle,
70
+ modalSize
71
+ }) {
72
+ const [open, setOpen] = useState(false);
73
+ const onToggle = () => setOpen(v => !v);
74
+ const close = () => setOpen(false);
75
+ return /*#__PURE__*/_jsxs(_Fragment, {
76
+ children: [renderToggle({
77
+ onToggle,
78
+ isOpen: open
79
+ }), open && /*#__PURE__*/_jsx(Modal, {
80
+ title: modalTitle,
81
+ onRequestClose: close,
82
+ size: modalSize,
83
+ children: renderContent({
84
+ close,
85
+ variant: 'modal'
86
+ })
87
+ })]
88
+ });
89
+ }
@@ -0,0 +1,126 @@
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
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
11
+ const ICONS = {
12
+ post: {
13
+ viewBox: '0 0 24 24',
14
+ path: /*#__PURE__*/_jsxs(_Fragment, {
15
+ children: [/*#__PURE__*/_jsx("path", {
16
+ d: "M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z"
17
+ }), /*#__PURE__*/_jsx("path", {
18
+ 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"
19
+ })]
20
+ })
21
+ },
22
+ tag: {
23
+ viewBox: '0 0 24 24',
24
+ path: /*#__PURE__*/_jsx("path", {
25
+ d: "M12.5 4.5h-2v7h7v-2l-5-5zm-2.5 8.5v5h2v-5h-2zm5 5h2v-5h-2v5z"
26
+ })
27
+ },
28
+ image: {
29
+ viewBox: '0 0 24 24',
30
+ path: /*#__PURE__*/_jsx("path", {
31
+ 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"
32
+ })
33
+ }
34
+ };
35
+ export default function SortableItem({
36
+ item,
37
+ onRemove,
38
+ icon = 'post',
39
+ getTitle,
40
+ thumb
41
+ }) {
42
+ const {
43
+ attributes,
44
+ listeners,
45
+ setNodeRef,
46
+ transform,
47
+ transition,
48
+ isDragging
49
+ } = useSortable({
50
+ id: item.id
51
+ });
52
+ const style = {
53
+ transform: CSS.Transform.toString(transform),
54
+ transition,
55
+ opacity: isDragging ? 0.5 : 1
56
+ };
57
+ const selectedIcon = ICONS[icon] || ICONS.post;
58
+ return /*#__PURE__*/_jsxs("div", {
59
+ ref: setNodeRef,
60
+ style: style,
61
+ className: "gcb-sortable-item",
62
+ ...attributes,
63
+ ...listeners,
64
+ children: [/*#__PURE__*/_jsx("div", {
65
+ className: "gcb-sortable-drag-handle",
66
+ "aria-hidden": true,
67
+ children: /*#__PURE__*/_jsx("svg", {
68
+ viewBox: "0 0 20 20",
69
+ width: "12",
70
+ children: /*#__PURE__*/_jsx("path", {
71
+ 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"
72
+ })
73
+ })
74
+ }), thumb ? /*#__PURE__*/_jsx("img", {
75
+ src: thumb,
76
+ alt: "",
77
+ style: {
78
+ width: 28,
79
+ height: 28,
80
+ borderRadius: 2,
81
+ objectFit: 'cover',
82
+ marginRight: 8,
83
+ flexShrink: 0,
84
+ border: '1px solid #ddd'
85
+ }
86
+ }) : /*#__PURE__*/_jsx("svg", {
87
+ xmlns: "http://www.w3.org/2000/svg",
88
+ viewBox: selectedIcon.viewBox,
89
+ width: "20",
90
+ height: "20",
91
+ style: {
92
+ marginRight: 8,
93
+ flexShrink: 0,
94
+ opacity: 0.6
95
+ },
96
+ children: selectedIcon.path
97
+ }), /*#__PURE__*/_jsx("span", {
98
+ style: {
99
+ flex: 1,
100
+ overflow: 'hidden',
101
+ textOverflow: 'ellipsis',
102
+ whiteSpace: 'nowrap',
103
+ userSelect: 'none'
104
+ },
105
+ children: getTitle ? getTitle(item) : item.title?.rendered || item.name || __('(no title)', 'gcblite')
106
+ }), /*#__PURE__*/_jsx("button", {
107
+ type: "button",
108
+ onClick: e => {
109
+ e.stopPropagation();
110
+ onRemove(item.id);
111
+ },
112
+ onPointerDown: e => e.stopPropagation(),
113
+ className: "gcb-sortable-remove",
114
+ "aria-label": __('Remove', 'gcblite'),
115
+ children: /*#__PURE__*/_jsx("svg", {
116
+ xmlns: "http://www.w3.org/2000/svg",
117
+ viewBox: "0 0 24 24",
118
+ width: "20",
119
+ height: "20",
120
+ children: /*#__PURE__*/_jsx("path", {
121
+ 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"
122
+ })
123
+ })
124
+ })]
125
+ });
126
+ }
@@ -0,0 +1,46 @@
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
+ import { jsx as _jsx } from "react/jsx-runtime";
17
+ export default function ButtonGroupField({
18
+ control,
19
+ value,
20
+ onChange
21
+ }) {
22
+ const current = Array.isArray(value) ? value : [];
23
+ return /*#__PURE__*/_jsx(BaseControl, {
24
+ label: control.label,
25
+ help: control.helpText,
26
+ className: "gcb-button-group-control components-base-control",
27
+ __nextHasNoMarginBottom: true,
28
+ children: /*#__PURE__*/_jsx(HStack, {
29
+ spacing: 1,
30
+ justify: "flex-start",
31
+ wrap: true,
32
+ children: (control.options || []).map(option => {
33
+ const isOn = current.includes(option.value);
34
+ return /*#__PURE__*/_jsx(Button, {
35
+ variant: isOn ? 'primary' : 'secondary',
36
+ onClick: () => {
37
+ const next = isOn ? current.filter(v => v !== option.value) : [...current, option.value];
38
+ onChange(next);
39
+ },
40
+ "aria-pressed": isOn,
41
+ children: option.label
42
+ }, option.value);
43
+ })
44
+ })
45
+ });
46
+ }