@wordpress-gcb/fields 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +49 -0
  2. package/package.json +39 -0
  3. package/src/conditional-logic.js +77 -0
  4. package/src/control-context.js +17 -0
  5. package/src/controls/MediaCapabilityGate.js +33 -0
  6. package/src/controls/MediaPicker.js +139 -0
  7. package/src/controls/MediaTriggerBadges.js +31 -0
  8. package/src/controls/PopoverOrModal.js +83 -0
  9. package/src/controls/SortableItem.js +110 -0
  10. package/src/controls/button-group.js +49 -0
  11. package/src/controls/checkbox-group.js +55 -0
  12. package/src/controls/checkbox.js +13 -0
  13. package/src/controls/code.js +21 -0
  14. package/src/controls/color.js +235 -0
  15. package/src/controls/date.js +37 -0
  16. package/src/controls/datetime.js +54 -0
  17. package/src/controls/email.js +15 -0
  18. package/src/controls/file.js +134 -0
  19. package/src/controls/gallery.js +338 -0
  20. package/src/controls/google-map.js +117 -0
  21. package/src/controls/heading-level.js +99 -0
  22. package/src/controls/icon.js +301 -0
  23. package/src/controls/image.js +334 -0
  24. package/src/controls/index.js +95 -0
  25. package/src/controls/message.js +56 -0
  26. package/src/controls/number.js +17 -0
  27. package/src/controls/oembed.js +32 -0
  28. package/src/controls/page-link.js +9 -0
  29. package/src/controls/post-object.js +788 -0
  30. package/src/controls/radio.js +18 -0
  31. package/src/controls/range.js +110 -0
  32. package/src/controls/relationship.js +14 -0
  33. package/src/controls/repeater.js +290 -0
  34. package/src/controls/richtext.js +505 -0
  35. package/src/controls/select.js +141 -0
  36. package/src/controls/size.js +49 -0
  37. package/src/controls/spacing.js +141 -0
  38. package/src/controls/taxonomy.js +488 -0
  39. package/src/controls/text.js +14 -0
  40. package/src/controls/textarea.js +15 -0
  41. package/src/controls/toggle-group.js +34 -0
  42. package/src/controls/toggle.js +13 -0
  43. package/src/controls/url.js +164 -0
  44. package/src/controls/user.js +343 -0
  45. package/src/controls/wysiwyg.js +23 -0
  46. package/src/hooks/useTokens.js +44 -0
  47. package/src/index.js +38 -0
  48. package/src/inspector.js +174 -0
  49. package/src/provider.js +59 -0
  50. package/src/utils/map-utils.js +51 -0
  51. package/src/utils/token-helper.js +243 -0
  52. package/src/validation-context.js +19 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Render an Inspector panel tree from a block's `gcb.controls` array.
3
+ *
4
+ * Controls are flat in the JSON; structure comes from `parentPanelId` references
5
+ * to `type: "group"` controls. We bucket controls under their group and emit one
6
+ * <PanelBody> per group, plus a default panel for anything ungrouped.
7
+ */
8
+
9
+ import { PanelBody } from '@wordpress/components';
10
+ import { Fragment, useContext } from '@wordpress/element';
11
+ import { __ } from '@wordpress/i18n';
12
+ import { controlComponents } from './controls';
13
+ import { ValidationContext } from './validation-context';
14
+ import {
15
+ STRUCTURAL_TYPES,
16
+ shouldRender,
17
+ panelsContainingErrors,
18
+ } from './conditional-logic';
19
+
20
+ // Re-export so existing callers keep working (post-fields.js imports
21
+ // shouldRender + panelsContainingErrors from './inspector').
22
+ export { shouldRender, panelsContainingErrors };
23
+
24
+ /**
25
+ * @param {Array} controls
26
+ * @param {Object} attributes
27
+ * @param {Function} setAttributes
28
+ * @param {Object} [options]
29
+ * @param {boolean} [options.flatten] When true and no groups exist, render
30
+ * ungrouped controls flat (no outer "Settings" PanelBody). Used by the
31
+ * post-fields meta-box where the meta-box itself IS the panel — nesting
32
+ * another PanelBody would look like a redundant dropdown.
33
+ * @param {Set<string>} [options.forceOpenPanelIds] Panel ids to render
34
+ * with initialOpen=true. Used by the meta-box to auto-open any panel
35
+ * containing a field that just failed validation, so the user can see
36
+ * the offending field.
37
+ */
38
+ export function renderInspector(controls, attributes, setAttributes, options = {}) {
39
+ const { groups, ungrouped } = bucketControls(controls);
40
+ const flatten = options.flatten === true && groups.length === 0;
41
+ const forceOpen = options.forceOpenPanelIds || new Set();
42
+
43
+ return (
44
+ <Fragment>
45
+ {ungrouped.length > 0 && (
46
+ flatten ? (
47
+ ungrouped.map((control) =>
48
+ renderControl(control, attributes, setAttributes)
49
+ )
50
+ ) : (
51
+ <PanelBody title={__('Settings', 'gcblite')} initialOpen={true}>
52
+ {ungrouped.map((control) =>
53
+ renderControl(control, attributes, setAttributes)
54
+ )}
55
+ </PanelBody>
56
+ )
57
+ )}
58
+ {groups.map(({ group, children }) => (
59
+ <PanelBody
60
+ // Remount the panel when its forced-open status changes so
61
+ // the new initialOpen value is honoured. (PanelBody only
62
+ // reads initialOpen on mount.)
63
+ key={`${group.id}:${forceOpen.has(group.id) ? 'open' : 'closed'}`}
64
+ title={group.label}
65
+ initialOpen={forceOpen.has(group.id)}
66
+ >
67
+ {children.map((control) =>
68
+ renderControl(control, attributes, setAttributes)
69
+ )}
70
+ </PanelBody>
71
+ ))}
72
+ </Fragment>
73
+ );
74
+ }
75
+
76
+ function bucketControls(controls) {
77
+ const groupsById = new Map();
78
+ const groupOrder = [];
79
+ const ungrouped = [];
80
+
81
+ // First pass: register structural controls (group / panel / tools-panel)
82
+ // in declaration order.
83
+ controls.forEach((control) => {
84
+ if (STRUCTURAL_TYPES.has(control.type) && control.id) {
85
+ groupsById.set(control.id, { group: control, children: [] });
86
+ groupOrder.push(control.id);
87
+ }
88
+ });
89
+
90
+ // Second pass: assign each non-structural control to a panel or to ungrouped.
91
+ controls.forEach((control) => {
92
+ if (STRUCTURAL_TYPES.has(control.type)) return;
93
+
94
+ const parentId = control.parentPanelId;
95
+ if (parentId && groupsById.has(parentId)) {
96
+ groupsById.get(parentId).children.push(control);
97
+ } else {
98
+ ungrouped.push(control);
99
+ }
100
+ });
101
+
102
+ return {
103
+ groups: groupOrder.map((id) => groupsById.get(id)),
104
+ ungrouped,
105
+ };
106
+ }
107
+
108
+ function renderControl(control, attributes, setAttributes) {
109
+ if (!shouldRender(control, attributes)) {
110
+ return null;
111
+ }
112
+
113
+ const Component = controlComponents[control.type];
114
+ if (!Component) {
115
+ return (
116
+ <div key={control.id} style={{ padding: 8, background: '#fff3cd', border: '1px solid #ffeeba', marginBottom: 8 }}>
117
+ <strong>{control.label}</strong>: unknown control type <code>{control.type}</code>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ const value = attributes[control.attributeKey];
123
+ const onChange = (next) => setAttributes({ [control.attributeKey]: next });
124
+
125
+ // Wrap each rendered control in a ValidationWrapper that overlays the
126
+ // required `*`, the inline error message, and the data-attribute used
127
+ // by the meta-box submit interceptor to scroll-into-view. The wrapper
128
+ // is invisible in surfaces without validation (sidebar default
129
+ // ValidationContext is { errors:{}, showErrors:false }) so blocks
130
+ // don't see any change.
131
+ return (
132
+ <ValidationWrapper key={control.id} control={control}>
133
+ <Component
134
+ control={control}
135
+ value={value}
136
+ onChange={onChange}
137
+ attributes={attributes}
138
+ />
139
+ </ValidationWrapper>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Per-field decorator: stamps data-gcblite-field for scroll-to-error,
145
+ * shows the required asterisk, and surfaces the inline error message
146
+ * once the host has flipped showErrors on.
147
+ */
148
+ function ValidationWrapper({ control, children }) {
149
+ const { errors, showErrors } = useContext(ValidationContext);
150
+ const key = control.attributeKey;
151
+ const required = !!control.validation?.required;
152
+ const errorMessage = showErrors && key ? errors[key] : null;
153
+
154
+ if (!required && !errorMessage && !key) {
155
+ return children;
156
+ }
157
+
158
+ return (
159
+ <div
160
+ data-gcblite-field={key || undefined}
161
+ className={[
162
+ 'gcblite-field',
163
+ required ? 'gcblite-field--required' : '',
164
+ errorMessage ? 'gcblite-field--has-error' : '',
165
+ ].filter(Boolean).join(' ')}
166
+ >
167
+ {children}
168
+ {errorMessage && (
169
+ <p className="gcblite-field__error" role="alert">{errorMessage}</p>
170
+ )}
171
+ </div>
172
+ );
173
+ }
174
+
@@ -0,0 +1,59 @@
1
+ /**
2
+ * GcbFieldsProvider — the SDK's injected-config boundary.
3
+ *
4
+ * The controls used to read host data off `window.gcbLite` directly. To make the
5
+ * package plugin-independent, that data is now provided via this context:
6
+ *
7
+ * <GcbFieldsProvider value={{ tokens, googleMapsEnabled, media, apiFetch }}>
8
+ * ...controls / inspector...
9
+ * </GcbFieldsProvider>
10
+ *
11
+ * Every field is optional. To keep the *existing plugin* working unchanged when
12
+ * it adopts the SDK (and to support drop-in headless use), each accessor falls
13
+ * back to the legacy `window.gcbLite` global when the context doesn't supply a
14
+ * value. So:
15
+ * - plugin (provides nothing yet) → falls back to window.gcbLite, works as before;
16
+ * - headless host → wraps the tree in the provider and injects its own values.
17
+ *
18
+ * config shape:
19
+ * tokens object design-token tree (was window.gcbLite.tokens)
20
+ * googleMapsEnabled boolean gate for the google-map control (was .googleMaps.hasApiKey)
21
+ * media object optional media-picker adapter (else block-editor/wp.media)
22
+ * apiFetch fn optional REST fetcher for reference controls (else @wordpress/api-fetch)
23
+ */
24
+
25
+ import { createContext, useContext } from '@wordpress/element';
26
+
27
+ export const GcbFieldsContext = createContext(null);
28
+
29
+ export function GcbFieldsProvider({ value, children }) {
30
+ return (
31
+ <GcbFieldsContext.Provider value={value || {}}>
32
+ {children}
33
+ </GcbFieldsContext.Provider>
34
+ );
35
+ }
36
+
37
+ /** Legacy global fallback so the plugin keeps working before it injects config. */
38
+ function legacyGlobal() {
39
+ return (typeof window !== 'undefined' && window.gcbLite) || {};
40
+ }
41
+
42
+ /** Full resolved config (context first, window.gcbLite fallback). */
43
+ export function useGcbFieldsConfig() {
44
+ return useContext(GcbFieldsContext) || {};
45
+ }
46
+
47
+ /** Design-token tree. */
48
+ export function useTokensConfig() {
49
+ const cfg = useContext(GcbFieldsContext) || {};
50
+ if (cfg.tokens !== undefined) return cfg.tokens || {};
51
+ return legacyGlobal().tokens || {};
52
+ }
53
+
54
+ /** Whether the google-map control should enable Maps features. */
55
+ export function useGoogleMapsEnabled() {
56
+ const cfg = useContext(GcbFieldsContext) || {};
57
+ if (cfg.googleMapsEnabled !== undefined) return !!cfg.googleMapsEnabled;
58
+ return !!legacyGlobal().googleMaps?.hasApiKey;
59
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Map utilities for fields that translate user-friendly keys to backend tokens.
3
+ *
4
+ * simple: { sm: 'spacing-sm', md: 'spacing-md' }
5
+ * labelled:{ sm: { label: 'Small (16px)', token: 'spacing-sm' } }
6
+ */
7
+
8
+ export const parseMap = (map) => {
9
+ if (!map || typeof map !== 'object') return null;
10
+ const out = {};
11
+ Object.entries(map).forEach(([key, value]) => {
12
+ if (typeof value === 'string') {
13
+ out[key] = { label: key, token: value };
14
+ } else if (typeof value === 'object' && value.token) {
15
+ out[key] = { label: value.label || key, token: value.token };
16
+ }
17
+ });
18
+ return Object.keys(out).length > 0 ? out : null;
19
+ };
20
+
21
+ export const getTokenFromKey = (m, key) => (m && key ? m[key]?.token || null : null);
22
+
23
+ export const getKeyFromToken = (m, token) => {
24
+ if (!m || !token) return null;
25
+ const entry = Object.entries(m).find(([, v]) => v.token === token);
26
+ return entry ? entry[0] : null;
27
+ };
28
+
29
+ export const mapToOptions = (m) => {
30
+ if (!m) return [];
31
+ return Object.entries(m).map(([key, v]) => ({ label: v.label, value: key }));
32
+ };
33
+
34
+ export const getMapKeys = (m) => {
35
+ if (!m) return [];
36
+ return Object.keys(m)
37
+ .map((k) => Number(k))
38
+ .filter((k) => !Number.isNaN(k))
39
+ .sort((a, b) => a - b);
40
+ };
41
+
42
+ export const mapToRangeMarks = (m) => {
43
+ if (!m) return null;
44
+ return Object.entries(m).map(([key, v]) => ({ value: Number(key), label: v.label }));
45
+ };
46
+
47
+ export const isValidMapValue = (m, value) => {
48
+ if (!m || value === null || value === undefined) return false;
49
+ if (m[value]) return true;
50
+ return Object.values(m).some((e) => e.token === value);
51
+ };
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Token helpers — ported from the original GCB.
3
+ *
4
+ * Two layers of tokens:
5
+ * - Built-in groups (this file): hard-coded design system tokens that ship
6
+ * with the plugin. Useful as a fallback when a theme has no theme.json.
7
+ * - Theme groups (window.gcbLite.tokens): parsed from theme.json by PHP.
8
+ * Always merged in by getTokenGroupTokens() below — theme tokens win on
9
+ * a path collision.
10
+ *
11
+ * Token path syntax: "category:subKey" or "category:subKey:tokenKey".
12
+ * "spacing:scale" — the group of tokens
13
+ * "spacing:scale:30" — a specific token
14
+ */
15
+
16
+ const builtInTokenGroups = {
17
+ color: {
18
+ label: 'Color',
19
+ children: {
20
+ palette: {
21
+ label: 'Palette',
22
+ tokens: [
23
+ { key: 'primary', value: 'color-primary', label: 'Primary' },
24
+ { key: 'secondary', value: 'color-secondary', label: 'Secondary' },
25
+ { key: 'accent', value: 'color-accent', label: 'Accent' },
26
+ { key: 'neutral', value: 'color-neutral', label: 'Neutral' },
27
+ { key: 'dark', value: 'color-dark', label: 'Dark' },
28
+ { key: 'light', value: 'color-light', label: 'Light' },
29
+ ],
30
+ },
31
+ duotone: {
32
+ label: 'Duotone',
33
+ tokens: [
34
+ { key: 'blue', value: 'duotone-blue', label: 'Blue Duotone' },
35
+ { key: 'purple', value: 'duotone-purple', label: 'Purple Duotone' },
36
+ { key: 'green', value: 'duotone-green', label: 'Green Duotone' },
37
+ ],
38
+ },
39
+ },
40
+ },
41
+ spacing: {
42
+ label: 'Spacing',
43
+ children: {
44
+ scale: {
45
+ label: 'Scale',
46
+ tokens: [
47
+ { key: '0', value: 'spacing-none', label: 'None (0)', size: '0' },
48
+ { key: '10', value: 'spacing-10', label: 'Step 1 (0.25rem)', size: '0.25rem' },
49
+ { key: '20', value: 'spacing-20', label: 'Step 2 (0.5rem)', size: '0.5rem' },
50
+ { key: '30', value: 'spacing-30', label: 'Step 3 (1rem)', size: '1rem' },
51
+ { key: '40', value: 'spacing-40', label: 'Step 4 (1.5rem)', size: '1.5rem' },
52
+ { key: '50', value: 'spacing-50', label: 'Step 5 (2rem)', size: '2rem' },
53
+ { key: '60', value: 'spacing-60', label: 'Step 6 (3rem)', size: '3rem' },
54
+ { key: '70', value: 'spacing-70', label: 'Step 7 (4rem)', size: '4rem' },
55
+ { key: '80', value: 'spacing-80', label: 'Step 8 (6rem)', size: '6rem' },
56
+ ],
57
+ },
58
+ presets: {
59
+ label: 'Presets',
60
+ tokens: [
61
+ { key: 'xs', value: 'spacing-xs', label: 'Extra Small (0.5rem)', size: '0.5rem' },
62
+ { key: 'sm', value: 'spacing-sm', label: 'Small (1rem)', size: '1rem' },
63
+ { key: 'md', value: 'spacing-md', label: 'Medium (1.5rem)', size: '1.5rem' },
64
+ { key: 'lg', value: 'spacing-lg', label: 'Large (2rem)', size: '2rem' },
65
+ { key: 'xl', value: 'spacing-xl', label: 'Extra Large (3rem)', size: '3rem' },
66
+ { key: '2xl', value: 'spacing-2xl', label: '2X Large (4rem)', size: '4rem' },
67
+ { key: '3xl', value: 'spacing-3xl', label: '3X Large (6rem)', size: '6rem' },
68
+ ],
69
+ },
70
+ semantic: {
71
+ label: 'Semantic',
72
+ tokens: [
73
+ { key: 'content', value: 'spacing-content', label: 'Content', size: 'var(--wp--style--block-gap, 1.5rem)' },
74
+ { key: 'section', value: 'spacing-section', label: 'Section', size: 'clamp(2rem, 5vw, 4rem)' },
75
+ { key: 'container', value: 'spacing-container', label: 'Container', size: 'clamp(1rem, 3vw, 2rem)' },
76
+ ],
77
+ },
78
+ },
79
+ },
80
+ typography: {
81
+ label: 'Typography',
82
+ children: {
83
+ fontSize: {
84
+ label: 'Font Sizes',
85
+ tokens: [
86
+ { key: 'xs', value: 'text-xs', label: 'Extra Small (12px)' },
87
+ { key: 'sm', value: 'text-sm', label: 'Small (14px)' },
88
+ { key: 'base', value: 'text-base', label: 'Base (16px)' },
89
+ { key: 'lg', value: 'text-lg', label: 'Large (18px)' },
90
+ { key: 'xl', value: 'text-xl', label: 'Extra Large (20px)' },
91
+ { key: '2xl', value: 'text-2xl', label: '2X Large (24px)' },
92
+ ],
93
+ },
94
+ fontWeight: {
95
+ label: 'Font Weights',
96
+ tokens: [
97
+ { key: 'light', value: 'font-light', label: 'Light (300)' },
98
+ { key: 'normal', value: 'font-normal', label: 'Normal (400)' },
99
+ { key: 'medium', value: 'font-medium', label: 'Medium (500)' },
100
+ { key: 'semibold', value: 'font-semibold', label: 'Semibold (600)' },
101
+ { key: 'bold', value: 'font-bold', label: 'Bold (700)' },
102
+ ],
103
+ },
104
+ },
105
+ },
106
+ sizing: {
107
+ label: 'Sizing',
108
+ children: {
109
+ containers: {
110
+ label: 'Container Widths',
111
+ tokens: [
112
+ { key: 'narrow', value: 'container-narrow', label: 'Narrow (600px)' },
113
+ { key: 'normal', value: 'container-normal', label: 'Normal (1200px)' },
114
+ { key: 'wide', value: 'container-wide', label: 'Wide (1600px)' },
115
+ { key: 'full', value: 'container-full', label: 'Full Width' },
116
+ ],
117
+ },
118
+ borderRadius: {
119
+ label: 'Border Radius',
120
+ tokens: [
121
+ { key: 'none', value: 'radius-none', label: 'None' },
122
+ { key: 'sm', value: 'radius-sm', label: 'Small' },
123
+ { key: 'md', value: 'radius-md', label: 'Medium' },
124
+ { key: 'lg', value: 'radius-lg', label: 'Large' },
125
+ { key: 'full', value: 'radius-full', label: 'Full (Pill)' },
126
+ ],
127
+ },
128
+ },
129
+ },
130
+ };
131
+
132
+ /**
133
+ * Get all token groups, merging built-ins with theme.json tokens.
134
+ * Theme tokens override built-ins on a path collision.
135
+ *
136
+ * @param {object} [themeTokens] Theme token tree. Defaults to the legacy
137
+ * `window.gcbLite.tokens` global so non-React callers (and the unmigrated
138
+ * plugin) keep working; the SDK's useTokens() hook passes the value from
139
+ * GcbFieldsProvider instead.
140
+ */
141
+ export function getAllTokenGroups(themeTokens) {
142
+ if (themeTokens === undefined) {
143
+ themeTokens = (typeof window !== 'undefined' && window.gcbLite?.tokens) || {};
144
+ }
145
+ const merged = { ...builtInTokenGroups };
146
+
147
+ Object.keys(themeTokens).forEach((category) => {
148
+ if (merged[category]) {
149
+ merged[category] = {
150
+ ...merged[category],
151
+ children: {
152
+ ...(merged[category].children || {}),
153
+ ...(themeTokens[category].children || {}),
154
+ },
155
+ };
156
+ } else {
157
+ merged[category] = themeTokens[category];
158
+ }
159
+ });
160
+
161
+ return merged;
162
+ }
163
+
164
+ /**
165
+ * Resolve a token group path to its token list.
166
+ *
167
+ * @param {string} tokenGroup e.g. "spacing:scale", "custom:gap"
168
+ * @returns {Array|null}
169
+ */
170
+ export function getTokenGroupTokens(tokenGroup) {
171
+ if (!tokenGroup || typeof tokenGroup !== 'string') return null;
172
+ const [categoryKey, subKey] = tokenGroup.split(':');
173
+ if (!categoryKey || !subKey) return null;
174
+
175
+ const all = getAllTokenGroups();
176
+ const category = all[categoryKey];
177
+ if (!category?.children) return null;
178
+
179
+ return category.children[subKey]?.tokens || null;
180
+ }
181
+
182
+ /**
183
+ * Convert a token list to {label, value} options for a select/radio control.
184
+ */
185
+ export function tokensToOptions(tokens) {
186
+ if (!Array.isArray(tokens)) return [];
187
+ return tokens.map((t) => ({ label: t.label, value: t.key }));
188
+ }
189
+
190
+ /**
191
+ * For spacing tokens: resolve the raw size value (e.g. "1rem").
192
+ * Accepts a full path ("spacing:scale:30"), a partial ("scale:30"),
193
+ * or just a key ("30") — searches in priority order.
194
+ */
195
+ export function getSpacingSize(tokenKey) {
196
+ if (!tokenKey) return null;
197
+ const parts = tokenKey.split(':');
198
+ let categoryKey, subKey, key;
199
+
200
+ if (parts.length === 3) {
201
+ [categoryKey, subKey, key] = parts;
202
+ } else if (parts.length === 2) {
203
+ categoryKey = 'spacing';
204
+ [subKey, key] = parts;
205
+ } else {
206
+ // Bare key — search every spacing subcategory.
207
+ const spacing = getAllTokenGroups().spacing;
208
+ if (spacing?.children) {
209
+ for (const subCat of Object.values(spacing.children)) {
210
+ const t = subCat.tokens?.find((tok) => tok.key === tokenKey);
211
+ if (t?.size) return t.size;
212
+ if (t?.value) return t.value; // theme.json tokens use `value`
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ const cat = getAllTokenGroups()[categoryKey];
219
+ const tok = cat?.children?.[subKey]?.tokens?.find((t) => t.key === key);
220
+ return tok?.size || tok?.value || null;
221
+ }
222
+
223
+ /**
224
+ * Convert a spacing token to the WP CSS custom property.
225
+ * Falls back to var(--wp--preset--spacing--{key}) for unknown tokens.
226
+ */
227
+ export function spacingTokenToCSSVar(tokenKey) {
228
+ if (!tokenKey) return null;
229
+ const parts = tokenKey.split(':');
230
+ const key = parts.length > 1 ? parts[parts.length - 1] : tokenKey;
231
+ return `var(--wp--preset--spacing--${key})`;
232
+ }
233
+
234
+ /**
235
+ * Resolve a token to its cssVar, preferring an explicit cssVar property
236
+ * (theme.json tokens have one), falling back to the spacing convention.
237
+ */
238
+ export function tokenToCSSVar(token) {
239
+ if (!token) return null;
240
+ if (token.cssVar) return token.cssVar;
241
+ if (token.slug) return `var(--wp--preset--spacing--${token.slug})`;
242
+ return null;
243
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tells each rendered control whether its value is currently invalid, so it
3
+ * can show inline error UI without each control type knowing how the host
4
+ * tracks validation state.
5
+ *
6
+ * Provided by the meta-box App (and, in future, the block Inspector) at the
7
+ * tree root. Controls read it via useContext(ValidationContext).
8
+ *
9
+ * Default value is { errors: {}, showErrors: false } — i.e. the sidebar
10
+ * Inspector, which doesn't yet drive validation, gets a no-op context
11
+ * so the same controls render fine in both surfaces.
12
+ */
13
+
14
+ import { createContext } from '@wordpress/element';
15
+
16
+ export const ValidationContext = createContext({
17
+ errors: {}, // { attributeKey: 'human message' }
18
+ showErrors: false, // becomes true after the first failed save attempt
19
+ });