@wordpress-gcb/fields 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/conditional-logic.js +83 -0
  2. package/{src → dist}/control-context.js +3 -2
  3. package/{src → dist}/controls/MediaCapabilityGate.js +12 -8
  4. package/dist/controls/MediaPicker.js +149 -0
  5. package/dist/controls/MediaTriggerBadges.js +35 -0
  6. package/{src → dist}/controls/PopoverOrModal.js +49 -43
  7. package/dist/controls/SortableItem.js +126 -0
  8. package/dist/controls/button-group.js +46 -0
  9. package/dist/controls/checkbox-group.js +65 -0
  10. package/dist/controls/checkbox.js +15 -0
  11. package/dist/controls/code.js +24 -0
  12. package/dist/controls/color.js +249 -0
  13. package/dist/controls/date.js +55 -0
  14. package/dist/controls/datetime.js +61 -0
  15. package/dist/controls/email.js +17 -0
  16. package/dist/controls/file.js +163 -0
  17. package/dist/controls/gallery.js +371 -0
  18. package/dist/controls/google-map.js +143 -0
  19. package/dist/controls/heading-level.js +93 -0
  20. package/dist/controls/icon.js +292 -0
  21. package/dist/controls/image.js +360 -0
  22. package/dist/controls/index.js +88 -0
  23. package/dist/controls/message.js +86 -0
  24. package/dist/controls/number.js +19 -0
  25. package/dist/controls/oembed.js +42 -0
  26. package/{src → dist}/controls/page-link.js +1 -2
  27. package/dist/controls/post-object.js +913 -0
  28. package/dist/controls/radio.js +19 -0
  29. package/dist/controls/range.js +108 -0
  30. package/{src → dist}/controls/relationship.js +12 -7
  31. package/dist/controls/repeater.js +277 -0
  32. package/dist/controls/richtext.js +494 -0
  33. package/dist/controls/select.js +144 -0
  34. package/dist/controls/size.js +59 -0
  35. package/dist/controls/spacing.js +172 -0
  36. package/dist/controls/taxonomy.js +569 -0
  37. package/dist/controls/text.js +16 -0
  38. package/dist/controls/textarea.js +17 -0
  39. package/dist/controls/toggle-group.js +28 -0
  40. package/dist/controls/toggle.js +15 -0
  41. package/dist/controls/url.js +235 -0
  42. package/dist/controls/user.js +383 -0
  43. package/{src → dist}/controls/wysiwyg.js +1 -1
  44. package/{src → dist}/hooks/useTokens.js +25 -21
  45. package/{src → dist}/index.js +2 -8
  46. package/dist/inspector.js +163 -0
  47. package/{src → dist}/provider.js +18 -17
  48. package/dist/utils/map-utils.js +54 -0
  49. package/dist/utils/token-helper.js +396 -0
  50. package/{src → dist}/validation-context.js +4 -4
  51. package/package.json +20 -13
  52. package/src/conditional-logic.js +0 -77
  53. package/src/controls/MediaPicker.js +0 -139
  54. package/src/controls/MediaTriggerBadges.js +0 -31
  55. package/src/controls/SortableItem.js +0 -110
  56. package/src/controls/button-group.js +0 -49
  57. package/src/controls/checkbox-group.js +0 -55
  58. package/src/controls/checkbox.js +0 -13
  59. package/src/controls/code.js +0 -21
  60. package/src/controls/color.js +0 -235
  61. package/src/controls/date.js +0 -37
  62. package/src/controls/datetime.js +0 -54
  63. package/src/controls/email.js +0 -15
  64. package/src/controls/file.js +0 -134
  65. package/src/controls/gallery.js +0 -338
  66. package/src/controls/google-map.js +0 -117
  67. package/src/controls/heading-level.js +0 -99
  68. package/src/controls/icon.js +0 -301
  69. package/src/controls/image.js +0 -334
  70. package/src/controls/index.js +0 -95
  71. package/src/controls/message.js +0 -56
  72. package/src/controls/number.js +0 -17
  73. package/src/controls/oembed.js +0 -32
  74. package/src/controls/post-object.js +0 -788
  75. package/src/controls/radio.js +0 -18
  76. package/src/controls/range.js +0 -110
  77. package/src/controls/repeater.js +0 -290
  78. package/src/controls/richtext.js +0 -505
  79. package/src/controls/select.js +0 -141
  80. package/src/controls/size.js +0 -49
  81. package/src/controls/spacing.js +0 -141
  82. package/src/controls/taxonomy.js +0 -488
  83. package/src/controls/text.js +0 -14
  84. package/src/controls/textarea.js +0 -15
  85. package/src/controls/toggle-group.js +0 -34
  86. package/src/controls/toggle.js +0 -13
  87. package/src/controls/url.js +0 -164
  88. package/src/controls/user.js +0 -343
  89. package/src/inspector.js +0 -174
  90. package/src/utils/map-utils.js +0 -51
  91. package/src/utils/token-helper.js +0 -243
@@ -1,505 +0,0 @@
1
- /**
2
- * RichtextField — modern alternative to the `wysiwyg` (TinyMCE) control.
3
- *
4
- * Built on Tiptap (ProseMirror under the hood). Lighter, no jQuery, no
5
- * global state — fits the rest of the Inspector model where every
6
- * control is a self-contained React component.
7
- *
8
- * Stored shape: HTML string (e.g. `<p>Hello <strong>world</strong></p>`).
9
- * Compatible with WP's standard wp_kses() flow and any frontend that
10
- * renders HTML via dangerouslySetInnerHTML.
11
- *
12
- * Why TWO rich-text controls? The existing `wysiwyg` control wraps
13
- * wp_editor() / TinyMCE — it's what authors expect on every WordPress
14
- * install and we keep it for backwards compat. `richtext` is the new
15
- * default for themes that don't need TinyMCE-era plugin compatibility
16
- * and want a smaller, more modern editor.
17
- *
18
- * Toolbar uses @wordpress/components so it visually matches the rest
19
- * of our Inspector. Image button opens wp.media() and inserts an <img>
20
- * referencing the existing media library — same lower-level API as
21
- * MediaPicker, no @wordpress/block-editor dependency required (works
22
- * on any admin screen).
23
- */
24
-
25
- import { __ } from '@wordpress/i18n';
26
- import { useState, useEffect, useContext } from '@wordpress/element';
27
- import {
28
- Button,
29
- Icon,
30
- Modal,
31
- Popover,
32
- TextControl,
33
- } from '@wordpress/components';
34
- import { ControlContext } from '../control-context';
35
- import PopoverOrModal from './PopoverOrModal';
36
- import {
37
- formatBold,
38
- formatItalic,
39
- formatStrikethrough,
40
- formatListBullets,
41
- formatListNumbered,
42
- code as codeIcon,
43
- quote as quoteIcon,
44
- link as linkIcon,
45
- image as imageIcon,
46
- undo as undoIcon,
47
- redo as redoIcon,
48
- pencil as editIcon,
49
- } from '@wordpress/icons';
50
- import { useEditor, EditorContent } from '@tiptap/react';
51
- import StarterKit from '@tiptap/starter-kit';
52
- import Link from '@tiptap/extension-link';
53
- import Image from '@tiptap/extension-image';
54
-
55
- /**
56
- * Open wp.media() and resolve with the picked image. Same wp.media
57
- * API our MediaPicker shim uses — no @wordpress/block-editor required.
58
- */
59
- function pickImageFromMedia() {
60
- return new Promise((resolve) => {
61
- if (typeof window === 'undefined' || !window.wp?.media) {
62
- // eslint-disable-next-line no-console
63
- console.warn('gcb-lite: wp.media not loaded — call wp_enqueue_media() on this screen.');
64
- resolve(null);
65
- return;
66
- }
67
- const frame = window.wp.media({
68
- title: __('Insert image', 'gcblite'),
69
- button: { text: __('Insert image', 'gcblite') },
70
- library: { type: ['image'] },
71
- multiple: false,
72
- });
73
- frame.on('select', () => {
74
- const a = frame.state().get('selection').first()?.toJSON();
75
- resolve(a || null);
76
- });
77
- frame.on('close', () => resolve(null));
78
- frame.open();
79
- });
80
- }
81
-
82
- function ToolbarSeparator() {
83
- return (
84
- <span aria-hidden style={{
85
- display: 'inline-block',
86
- width: 1,
87
- height: 24,
88
- background: '#ddd',
89
- margin: '0 4px',
90
- alignSelf: 'center',
91
- }} />
92
- );
93
- }
94
-
95
- /**
96
- * Toolbar button. Uses @wordpress/icons so the toolbar visually
97
- * matches the rest of WP admin (block-editor toolbar, post-meta
98
- * controls, etc.). The icon prop on @wordpress/components Button
99
- * renders the SVG at WP's standard 24×24.
100
- */
101
- function TbButton({ icon, label, isPressed, disabled, onClick }) {
102
- return (
103
- <Button
104
- icon={icon}
105
- label={label}
106
- showTooltip
107
- onClick={onClick}
108
- disabled={disabled}
109
- aria-pressed={isPressed}
110
- className="gcb-richtext-toolbar__btn"
111
- />
112
- );
113
- }
114
-
115
- function LinkPopover({ editor, anchor, onClose }) {
116
- const initial = editor.getAttributes('link').href || '';
117
- const [url, setUrl] = useState(initial);
118
-
119
- const apply = () => {
120
- if (!url) {
121
- editor.chain().focus().unsetLink().run();
122
- } else {
123
- editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
124
- }
125
- onClose();
126
- };
127
-
128
- return (
129
- <Popover anchor={anchor} onClose={onClose} placement="bottom-start">
130
- <div style={{ padding: 12, minWidth: 280 }}>
131
- <TextControl
132
- label={__('Link URL', 'gcblite')}
133
- value={url}
134
- onChange={setUrl}
135
- placeholder="https://example.com"
136
- __nextHasNoMarginBottom
137
- __next40pxDefaultSize
138
- />
139
- <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
140
- <Button variant="primary" onClick={apply}>{__('Apply', 'gcblite')}</Button>
141
- {initial && (
142
- <Button
143
- variant="tertiary"
144
- onClick={() => {
145
- editor.chain().focus().unsetLink().run();
146
- onClose();
147
- }}
148
- >
149
- {__('Remove', 'gcblite')}
150
- </Button>
151
- )}
152
- </div>
153
- </div>
154
- </Popover>
155
- );
156
- }
157
-
158
- function Toolbar({ editor, control }) {
159
- const [linkPopoverAnchor, setLinkPopoverAnchor] = useState(null);
160
-
161
- if (!editor) return null;
162
-
163
- const headingLevels = control.headingLevels || [2, 3, 4];
164
- // Build heading select options. 'p' is paragraph (no heading).
165
- const headingValue = editor.isActive('heading', { level: headingLevels[0] })
166
- ? `h${headingLevels[0]}`
167
- : editor.isActive('heading', { level: headingLevels[1] })
168
- ? `h${headingLevels[1]}`
169
- : editor.isActive('heading', { level: headingLevels[2] })
170
- ? `h${headingLevels[2]}`
171
- : 'p';
172
-
173
- const setHeading = (val) => {
174
- if (val === 'p') {
175
- editor.chain().focus().setParagraph().run();
176
- } else {
177
- const level = parseInt(val.slice(1), 10);
178
- editor.chain().focus().toggleHeading({ level }).run();
179
- }
180
- };
181
-
182
- const insertImage = async () => {
183
- const img = await pickImageFromMedia();
184
- if (!img?.url) return;
185
- editor.chain().focus().setImage({ src: img.url, alt: img.alt || '' }).run();
186
- };
187
-
188
- return (
189
- <div className="gcb-richtext-toolbar" style={{
190
- display: 'flex',
191
- flexWrap: 'wrap',
192
- alignItems: 'center',
193
- gap: 2,
194
- padding: 6,
195
- // Light border — the surface should feel calm. Matches the
196
- // editor body below; their shared edge (toolbar-bottom +
197
- // body-top) collapses to one line.
198
- border: '1px solid #ddd',
199
- borderBottom: '1px solid #ddd',
200
- background: '#fff',
201
- borderRadius: '8px 8px 0 0',
202
- }}>
203
- {/* Block-type select */}
204
- <select
205
- value={headingValue}
206
- onChange={(e) => setHeading(e.target.value)}
207
- className="gcb-richtext-block-select"
208
- aria-label={__('Text style', 'gcblite')}
209
- >
210
- <option value="p">{__('Paragraph', 'gcblite')}</option>
211
- {headingLevels.map((l) => (
212
- <option key={l} value={`h${l}`}>{`Heading ${l}`}</option>
213
- ))}
214
- </select>
215
-
216
- <ToolbarSeparator />
217
-
218
- <TbButton
219
- icon={formatBold}
220
- label={__('Bold', 'gcblite')}
221
- isPressed={editor.isActive('bold')}
222
- onClick={() => editor.chain().focus().toggleBold().run()}
223
- />
224
- <TbButton
225
- icon={formatItalic}
226
- label={__('Italic', 'gcblite')}
227
- isPressed={editor.isActive('italic')}
228
- onClick={() => editor.chain().focus().toggleItalic().run()}
229
- />
230
- <TbButton
231
- icon={formatStrikethrough}
232
- label={__('Strikethrough', 'gcblite')}
233
- isPressed={editor.isActive('strike')}
234
- onClick={() => editor.chain().focus().toggleStrike().run()}
235
- />
236
- <TbButton
237
- icon={codeIcon}
238
- label={__('Inline code', 'gcblite')}
239
- isPressed={editor.isActive('code')}
240
- onClick={() => editor.chain().focus().toggleCode().run()}
241
- />
242
-
243
- <ToolbarSeparator />
244
-
245
- <TbButton
246
- icon={formatListBullets}
247
- label={__('Bulleted list', 'gcblite')}
248
- isPressed={editor.isActive('bulletList')}
249
- onClick={() => editor.chain().focus().toggleBulletList().run()}
250
- />
251
- <TbButton
252
- icon={formatListNumbered}
253
- label={__('Numbered list', 'gcblite')}
254
- isPressed={editor.isActive('orderedList')}
255
- onClick={() => editor.chain().focus().toggleOrderedList().run()}
256
- />
257
- <TbButton
258
- icon={quoteIcon}
259
- label={__('Blockquote', 'gcblite')}
260
- isPressed={editor.isActive('blockquote')}
261
- onClick={() => editor.chain().focus().toggleBlockquote().run()}
262
- />
263
-
264
- <ToolbarSeparator />
265
-
266
- <TbButton
267
- icon={linkIcon}
268
- label={__('Link', 'gcblite')}
269
- isPressed={editor.isActive('link')}
270
- onClick={(e) => setLinkPopoverAnchor(e.currentTarget)}
271
- />
272
- {control.allowImages !== false && (
273
- <TbButton
274
- icon={imageIcon}
275
- label={__('Insert image', 'gcblite')}
276
- onClick={insertImage}
277
- />
278
- )}
279
-
280
- <ToolbarSeparator />
281
-
282
- <TbButton
283
- icon={undoIcon}
284
- label={__('Undo', 'gcblite')}
285
- onClick={() => editor.chain().focus().undo().run()}
286
- disabled={!editor.can().undo()}
287
- />
288
- <TbButton
289
- icon={redoIcon}
290
- label={__('Redo', 'gcblite')}
291
- onClick={() => editor.chain().focus().redo().run()}
292
- disabled={!editor.can().redo()}
293
- />
294
-
295
- {linkPopoverAnchor && (
296
- <LinkPopover
297
- editor={editor}
298
- anchor={linkPopoverAnchor}
299
- onClose={() => setLinkPopoverAnchor(null)}
300
- />
301
- )}
302
- </div>
303
- );
304
- }
305
-
306
- /**
307
- * Strip a chunk of HTML down to a plain-text preview for the
308
- * collapsed button in sidebar variant. No DOMParser — we run inside
309
- * the block-editor JS bundle which has access to document, but a
310
- * regex-based strip is plenty for a short summary line.
311
- */
312
- function htmlToPreview(html, limit = 80) {
313
- if (!html) return '';
314
- const text = String(html)
315
- .replace(/<[^>]+>/g, ' ')
316
- .replace(/&nbsp;/g, ' ')
317
- .replace(/\s+/g, ' ')
318
- .trim();
319
- if (text.length <= limit) return text;
320
- return text.slice(0, limit - 1) + '…';
321
- }
322
-
323
- /**
324
- * The actual Tiptap editor surface — toolbar + EditorContent. Used
325
- * inline on wide surfaces (meta-box, options page) and inside a Modal
326
- * on the narrow block Inspector sidebar.
327
- */
328
- function RichtextEditorSurface({ editor, control }) {
329
- if (!editor) return null;
330
- return (
331
- <>
332
- <Toolbar editor={editor} control={control} />
333
- <EditorContent editor={editor} className="gcb-richtext-editor" />
334
- </>
335
- );
336
- }
337
-
338
- /**
339
- * Resolve the display mode for the field. Authors can pin a specific
340
- * mode via control.display ('inline' | 'popover' | 'modal'); otherwise
341
- * we pick the best fit for the surface:
342
- * - sidebar variant (narrow ~280px block Inspector) → 'popover' so
343
- * the editor hangs off to the side, leaving the canvas visible
344
- * for live preview of edits
345
- * - metabox/wide variants → 'inline' (toolbar + editor fit fine)
346
- */
347
- function resolveDisplay(control, ctx) {
348
- if (control.display && ['inline', 'popover', 'modal'].includes(control.display)) {
349
- return control.display;
350
- }
351
- if (ctx?.variant === 'sidebar') return 'popover';
352
- return 'inline';
353
- }
354
-
355
- export default function RichtextField({ control, value, onChange }) {
356
- const ctx = useContext(ControlContext);
357
- const display = resolveDisplay(control, ctx);
358
- const headingLevels = control.headingLevels || [2, 3, 4];
359
-
360
- const [modalOpen, setModalOpen] = useState(false);
361
-
362
- const editor = useEditor({
363
- extensions: [
364
- StarterKit.configure({
365
- // Disable headings here; we'll re-add only the levels the
366
- // theme has allowed so the toolbar select stays in sync.
367
- heading: { levels: headingLevels },
368
- }),
369
- Link.configure({
370
- openOnClick: false,
371
- HTMLAttributes: { rel: 'noopener noreferrer' },
372
- }),
373
- Image.configure({
374
- HTMLAttributes: { class: 'gcb-richtext-image' },
375
- }),
376
- ],
377
- content: value || '',
378
- onUpdate: ({ editor: ed }) => {
379
- const html = ed.getHTML();
380
- // Tiptap returns "<p></p>" for an empty document. Normalise
381
- // to '' so validation's required-check still triggers on
382
- // truly-empty input.
383
- onChange(html === '<p></p>' ? '' : html);
384
- },
385
- });
386
-
387
- // Sync external value changes (e.g. value reset, conditional logic
388
- // swap) without re-triggering onUpdate.
389
- useEffect(() => {
390
- if (!editor) return;
391
- const current = editor.getHTML();
392
- const incoming = value || '';
393
- if (current === incoming) return;
394
- if (current === '<p></p>' && incoming === '') return;
395
- editor.commands.setContent(incoming, false);
396
- }, [value, editor]);
397
-
398
- // Popover or Modal: collapsed button + opens the editor in an
399
- // overlay. Popover (default for sidebar) anchors to the side of
400
- // the field, leaving the canvas visible for live-update editing.
401
- // Modal is for surfaces where you want focus-mode authoring and
402
- // don't mind covering the canvas.
403
- if (display === 'popover' || display === 'modal') {
404
- const preview = htmlToPreview(value);
405
- const triggerButton = ({ onToggle, isOpen }) => (
406
- <Button
407
- onClick={onToggle}
408
- aria-expanded={isOpen}
409
- className="gcb-richtext-control__open"
410
- >
411
- <span className="gcb-richtext-control__open-text">
412
- {preview || control.placeholder || __('Edit rich text…', 'gcblite')}
413
- </span>
414
- <Icon icon={editIcon} size={20} className="gcb-richtext-control__open-icon" />
415
- </Button>
416
- );
417
-
418
- const editorPanel = ({ close, variant }) => (
419
- <div
420
- className={`gcb-richtext-control__panel gcb-richtext-control__panel--${variant}`}
421
- >
422
- <RichtextEditorSurface editor={editor} control={control} />
423
- </div>
424
- );
425
-
426
- // For 'modal' mode, force PopoverOrModal into modal by mounting
427
- // inside a metabox-flavoured ControlContext. (PopoverOrModal
428
- // dispatches purely on ctx.variant.) Pinning it explicitly
429
- // keeps the per-field opt-in honest regardless of where the
430
- // control is used.
431
- if (display === 'modal') {
432
- return (
433
- <div className="components-base-control gcb-richtext-control gcb-richtext-control--popout">
434
- <div className="components-base-control__field">
435
- {control.label && (
436
- <label className="components-base-control__label">
437
- {control.label}
438
- </label>
439
- )}
440
- <ControlContext.Provider value={{ variant: 'metabox' }}>
441
- <PopoverOrModal
442
- modalTitle={control.label || __('Edit rich text', 'gcblite')}
443
- modalSize="large"
444
- renderToggle={triggerButton}
445
- renderContent={editorPanel}
446
- />
447
- </ControlContext.Provider>
448
- </div>
449
- {control.helpText && (
450
- <p className="components-base-control__help">{control.helpText}</p>
451
- )}
452
- </div>
453
- );
454
- }
455
-
456
- // Popover variant — used by default in sidebar. PopoverOrModal
457
- // reads the surrounding variant, which is 'sidebar' here, so
458
- // it'll render as a Dropdown popover anchored to the trigger.
459
- return (
460
- <div className="components-base-control gcb-richtext-control gcb-richtext-control--popout">
461
- <div className="components-base-control__field">
462
- {control.label && (
463
- <label className="components-base-control__label">
464
- {control.label}
465
- </label>
466
- )}
467
- <PopoverOrModal
468
- modalTitle={control.label || __('Edit rich text', 'gcblite')}
469
- modalSize="large"
470
- dropdownProps={{
471
- popoverProps: {
472
- placement: 'left-start',
473
- className: 'gcb-richtext-control__popover',
474
- },
475
- }}
476
- renderToggle={triggerButton}
477
- renderContent={editorPanel}
478
- />
479
- </div>
480
- {control.helpText && (
481
- <p className="components-base-control__help">{control.helpText}</p>
482
- )}
483
- </div>
484
- );
485
- }
486
-
487
- // Inline variant (default on wide surfaces: meta-box, options,
488
- // taxonomy, user-profile). Toolbar + editor render directly into
489
- // the field — no overlay.
490
- return (
491
- <div className="components-base-control gcb-richtext-control">
492
- <div className="components-base-control__field">
493
- {control.label && (
494
- <label className="components-base-control__label">
495
- {control.label}
496
- </label>
497
- )}
498
- <RichtextEditorSurface editor={editor} control={control} />
499
- </div>
500
- {control.helpText && (
501
- <p className="components-base-control__help">{control.helpText}</p>
502
- )}
503
- </div>
504
- );
505
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * SelectField — ported from the original GCB SelectField.js verbatim.
3
- *
4
- * Supports plain {label, value} options, legacy `map` configs, and full
5
- * `tokenGroup` binding to theme.json tokens (with optional tokenKeys filter
6
- * and defaultOptionKey). When tokenGroup is set, the saved value is the
7
- * full token object { key, slug, value, cssVar } rather than a string.
8
- */
9
-
10
- import { __ } from '@wordpress/i18n';
11
- import { SelectControl, Spinner } from '@wordpress/components';
12
- import { parseMap, getTokenFromKey, getKeyFromToken, mapToOptions } from '../utils/map-utils';
13
- import { useTokens, getTokensByGroup, generateMapFromTokens } from '../hooks/useTokens';
14
-
15
- function SelectFieldImpl({
16
- label,
17
- value,
18
- options = [],
19
- onChange,
20
- help,
21
- className = '',
22
- map,
23
- tokenGroup,
24
- tokenKeys,
25
- defaultOptionKey,
26
- placeholder,
27
- }) {
28
- const { tokens, loading } = useTokens();
29
-
30
- let groupTokens = null;
31
- let normalizedMap = null;
32
-
33
- if (tokenGroup && tokens) {
34
- groupTokens = getTokensByGroup(tokens, tokenGroup);
35
- if (groupTokens && tokenKeys && Array.isArray(tokenKeys) && tokenKeys.length > 0) {
36
- groupTokens = groupTokens.filter((t) => tokenKeys.includes(t.key));
37
- }
38
- if (groupTokens) {
39
- const generatedMap = generateMapFromTokens(groupTokens);
40
- normalizedMap = generatedMap ? parseMap(generatedMap) : null;
41
- }
42
- } else if (map) {
43
- normalizedMap = parseMap(map);
44
- }
45
-
46
- if (tokenGroup && loading) {
47
- return (
48
- <div style={{ padding: '12px 0' }}>
49
- <div style={{ fontWeight: 500, marginBottom: '8px' }}>{label}</div>
50
- <Spinner />
51
- </div>
52
- );
53
- }
54
-
55
- let effectiveOptions = normalizedMap ? mapToOptions(normalizedMap) : options;
56
-
57
- // Add a clearing/placeholder option if no default is set, so users can pick blank.
58
- if (!defaultOptionKey && !effectiveOptions.some((o) => o.value === '')) {
59
- effectiveOptions = [
60
- { label: '— ' + (placeholder || __('Select', 'gcblite')) + ' —', value: '' },
61
- ...effectiveOptions,
62
- ];
63
- }
64
-
65
- // Apply default if value is empty.
66
- if ((value === undefined || value === null || value === '') && defaultOptionKey && tokenGroup && groupTokens) {
67
- const defaultToken = groupTokens.find((t) => t.key === defaultOptionKey);
68
- if (defaultToken && onChange) {
69
- const defaultValue = {
70
- key: defaultToken.key,
71
- slug: defaultToken.slug,
72
- value: defaultToken.value,
73
- cssVar: defaultToken.cssVar,
74
- };
75
- onChange(defaultValue);
76
- }
77
- }
78
-
79
- let displayValue = value;
80
- if (typeof value === 'object' && value !== null) {
81
- displayValue = value.key !== undefined ? value.key : '';
82
- } else if (normalizedMap && value !== undefined && value !== null && value !== '') {
83
- displayValue = getKeyFromToken(normalizedMap, value) || value;
84
- }
85
- if ((displayValue === undefined || displayValue === null || displayValue === '') && defaultOptionKey) {
86
- displayValue = defaultOptionKey;
87
- }
88
-
89
- const handleChange = (newOptionKey) => {
90
- let finalValue = newOptionKey;
91
-
92
- if (tokenGroup && groupTokens) {
93
- const selected = groupTokens.find((t) => t.key === newOptionKey);
94
- if (selected) {
95
- finalValue = {
96
- key: selected.key,
97
- slug: selected.slug,
98
- value: selected.value,
99
- cssVar: selected.cssVar,
100
- };
101
- }
102
- } else if (normalizedMap) {
103
- finalValue = getTokenFromKey(normalizedMap, newOptionKey);
104
- }
105
-
106
- if (onChange) onChange(finalValue);
107
- };
108
-
109
- return (
110
- <SelectControl
111
- label={label}
112
- value={displayValue ?? ''}
113
- options={effectiveOptions}
114
- onChange={handleChange}
115
- help={help}
116
- className={className}
117
- __nextHasNoMarginBottom
118
- />
119
- );
120
- }
121
-
122
- /**
123
- * Adapter: maps the registry's `{ control, value, onChange }` contract onto
124
- * SelectFieldImpl's flat-prop contract.
125
- */
126
- export default function SelectField({ control, value, onChange }) {
127
- return (
128
- <SelectFieldImpl
129
- label={control.label}
130
- value={value}
131
- options={(control.options || []).map((o) => ({ label: o.label, value: o.value }))}
132
- onChange={onChange}
133
- help={control.helpText}
134
- placeholder={control.placeholder}
135
- map={control.map}
136
- tokenGroup={control.tokenGroup}
137
- tokenKeys={control.tokenKeys}
138
- defaultOptionKey={control.defaultOptionKey ?? control.default}
139
- />
140
- );
141
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * Size — dimension with a unit selector.
3
- *
4
- * Stored shape: a CSS length string with unit, e.g. `'16px'`, `'1.5rem'`,
5
- * `'100%'`. Empty string when not set.
6
- *
7
- * Supported units: px, em, rem, %, vh, vw.
8
- *
9
- * In a React component you can pass the value directly to `style.width`,
10
- * `style.height`, `style.padding`, etc. — it's already a valid CSS length.
11
- */
12
- import { __experimentalUnitControl as UnitControl, TextControl } from '@wordpress/components';
13
- import { __ } from '@wordpress/i18n';
14
-
15
- const UNITS = [
16
- { value: 'px', label: 'px' },
17
- { value: 'em', label: 'em' },
18
- { value: 'rem', label: 'rem' },
19
- { value: '%', label: '%' },
20
- { value: 'vh', label: 'vh' },
21
- { value: 'vw', label: 'vw' },
22
- ];
23
-
24
- export default function SizeField({ control, value, onChange }) {
25
- if (UnitControl) {
26
- return (
27
- <UnitControl
28
- label={control.label}
29
- value={value || ''}
30
- onChange={onChange}
31
- units={UNITS}
32
- help={control.helpText}
33
- __nextHasNoMarginBottom
34
- />
35
- );
36
- }
37
-
38
- // Fallback when UnitControl isn't available — manual entry.
39
- return (
40
- <TextControl
41
- label={control.label}
42
- value={value || ''}
43
- onChange={onChange}
44
- placeholder="e.g. 16px, 1em, 100%"
45
- help={control.helpText || __('Enter a size with unit (px, em, rem, %, vh, vw)', 'gcblite')}
46
- __nextHasNoMarginBottom
47
- />
48
- );
49
- }