Complete technical specification for Experience Cloud LWC Custom Property Editors
Version: 1.0
Author: Marc Swan (Salesforce Architect)
Last Updated: December 2025
Minimum API Version: 61.0+
Custom Property Editors (CPEs) are Lightning Web Components that replace default Experience Builder property inputs with custom UIs. They enable visual pickers, complex data structures, and enhanced validation beyond standard text/picklist inputs.
| Use CPE | Use Standard Properties |
|---|---|
| Visual pickers (alignment, color) | Simple strings/booleans |
| Complex nested configurations | Static picklists |
| Real-time validation feedback | Single integer inputs |
| Grouped property tabs/accordions | Properties with datasource |
| Record selection with filtering | Basic required validation |
┌─────────────────────────────────────────────────────────┐
│ Experience Builder │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Property Panel │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Custom Property Editor (LWC) │ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ @api value (from framework) │ │ │ │
│ │ │ │ @api label, description, etc. │ │ │ │
│ │ │ │ ───────────────────────────── │ │ │ │
│ │ │ │ valuechange event (to framework)│ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Target Component (LWC) │ │
│ │ @api propertyName │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Understanding how to correctly declare and register CPEs is the most common source of errors when building Experience Cloud components. The patterns differ significantly from Flow/Apex CPEs.
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXPERIENCE CLOUD CPE │
├─────────────────────────────────────────────────────────────────────────────┤
│ Registration: editor="c/myPropertyEditor" (in target component XML) │
│ Value Access: @api value │
│ Update Event: 'valuechange' │
│ Event Detail: { value: newValue } │
│ Validation: @api errors (received FROM framework) │
│ Context: @api schema │
│ CPE isExposed: false │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLOW/APEX CPE │
├─────────────────────────────────────────────────────────────────────────────┤
│ Registration: configurationEditor="c-my-property-editor" (in Apex/XML) │
│ Value Access: @api inputVariables (array of {name, value, valueDataType})│
│ Update Event: 'configuration_editor_input_value_changed' │
│ Event Detail: { name, newValue, newValueDataType } │
│ Validation: @api validate() method (you implement, return errors) │
│ Context: @api builderContext, @api genericTypeMappings │
│ CPE isExposed: false │
└─────────────────────────────────────────────────────────────────────────────┘
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>My Component</masterLabel>
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
<targetConfigs>
<targetConfig targets="lightningCommunity__Default">
<!-- Standard property (no custom editor) -->
<property name="title" type="String" label="Title"/>
<!-- Property WITH custom editor -->
<property name="alignment"
type="String"
label="Text Alignment"
editor="c/alignmentPropertyEditor"/>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
EXPERIENCE CLOUD: Uses "editor" attribute
Format: namespace/componentName (camelCase)
<!-- Property using Custom Lightning Type -->
<property name="styling"
type="cardStyleType"
label="Card Style"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>false</isExposed> <!-- ⚠️ MUST be false for CPEs -->
<!-- NO targets needed - CPE is invoked by framework, not dragged onto page -->
</LightningComponentBundle>
import { LightningElement, api } from 'lwc';
export default class AlignmentPropertyEditor extends LightningElement {
// ═══════════════════════════════════════════════════════════════════
// EXPERIENCE CLOUD CONTRACT: These 6 @api properties are REQUIRED
// ═══════════════════════════════════════════════════════════════════
@api label; // String - Display label from XML
@api description; // String - Help text
@api required; // Boolean - Is property required
@api value; // Any - THE CURRENT VALUE (read from here!)
@api errors; // Array - Validation errors FROM framework
@api schema; // Object - JSON Schema info for complex types
// ═══════════════════════════════════════════════════════════════════
// EXPERIENCE CLOUD EVENT: 'valuechange' with bubbles+composed=true
// ═══════════════════════════════════════════════════════════════════
handleChange(event) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: event.target.value },
bubbles: true, // ⚠️ REQUIRED
composed: true // ⚠️ REQUIRED
}));
}
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__FlowScreen</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__FlowScreen"
configurationEditor="c-my-flow-cpe">
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FLOW: Uses "configurationEditor" attribute
Format: kebab-case with namespace prefix
<property name="volume" type="Integer" role="inputOnly"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
public class MyInvocableAction {
@InvocableMethod(
label='My Action'
configurationEditor='c-my-action-cpe' // ⚠️ kebab-case!
)
public static List<Result> execute(List<Request> requests) {
// Implementation
}
public class Request {
@InvocableVariable(required=true)
public String inputValue;
}
}
import { LightningElement, api } from 'lwc';
export default class MyFlowCpe extends LightningElement {
// ═══════════════════════════════════════════════════════════════════
// FLOW CONTRACT: Uses inputVariables array, NOT single value
// ═══════════════════════════════════════════════════════════════════
_inputVariables = [];
@api
get inputVariables() {
return this._inputVariables;
}
set inputVariables(variables) {
this._inputVariables = variables || [];
}
@api builderContext; // Flow context (screens, variables, etc.)
@api genericTypeMappings; // For generic sObject types
@api elementInfo; // Info about current element
// Access a specific property value
get volumeValue() {
const param = this._inputVariables.find(
({ name }) => name === 'volume'
);
return param?.value;
}
// ═══════════════════════════════════════════════════════════════════
// FLOW VALIDATION: You implement validate() method
// ═══════════════════════════════════════════════════════════════════
@api
validate() {
const errors = [];
if (!this.volumeValue || this.volumeValue < 0) {
errors.push({
key: 'volume',
errorString: 'Volume must be a positive number'
});
}
return errors; // Return empty array if valid
}
// ═══════════════════════════════════════════════════════════════════
// FLOW EVENT: 'configuration_editor_input_value_changed'
// ═══════════════════════════════════════════════════════════════════
handleChange(event) {
this.dispatchEvent(new CustomEvent(
'configuration_editor_input_value_changed', // ⚠️ Different event!
{
bubbles: true,
cancelable: false,
composed: true,
detail: {
name: 'volume', // ⚠️ Property name required
newValue: event.target.value,
newValueDataType: 'Number' // ⚠️ Data type required
}
}
));
}
}
| Aspect | Experience Cloud | Flow/Apex |
|---|---|---|
| XML Attribute | editor="c/componentName" | configurationEditor="c-component-name" |
| Naming Format | camelCase with / | kebab-case with - |
| Where Declared | <property> tag | <targetConfig> or @InvocableMethod |
| CPE isExposed | false | false |
| Value Property | @api value (direct) | @api inputVariables (array) |
| Event Name | valuechange | configuration_editor_input_value_changed |
| Event Detail | { value } | { name, newValue, newValueDataType } |
| Validation | @api errors (received) | @api validate() (implemented) |
<!-- WRONG: configurationEditor doesn't work for Experience Cloud -->
<targetConfig targets="lightningCommunity__Default"
configurationEditor="c-alignment-editor">
// WRONG: inputVariables doesn't exist in Experience Cloud CPE
@api inputVariables;
// WRONG: Flow event name doesn't work in Experience Cloud
this.dispatchEvent(new CustomEvent('configuration_editor_input_value_changed', {
detail: { name: 'alignment', newValue: 'center', newValueDataType: 'String' }
}));
<!-- CORRECT: Use editor attribute on property -->
<property name="alignment"
type="String"
label="Alignment"
editor="c/alignmentPropertyEditor"/>
// CORRECT: Use @api value
@api value;
// CORRECT: Use valuechange event
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: 'center' },
bubbles: true,
composed: true
}));
<!-- WRONG: editor attribute doesn't work for Flow -->
<property name="volume" type="Integer" editor="c/volumeEditor"/>
// WRONG: @api value doesn't receive Flow data
@api value;
// WRONG: valuechange isn't recognized by Flow Builder
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: 50 }
}));
<!-- CORRECT: configurationEditor on targetConfig -->
<targetConfig targets="lightning__FlowScreen"
configurationEditor="c-volume-editor">
<property name="volume" type="Integer"/>
</targetConfig>
// CORRECT: Use inputVariables array
@api inputVariables;
// CORRECT: Use Flow event with full detail
this.dispatchEvent(new CustomEvent('configuration_editor_input_value_changed', {
bubbles: true,
cancelable: false,
composed: true,
detail: {
name: 'volume',
newValue: 50,
newValueDataType: 'Number'
}
}));
Is your component for Experience Builder (Experience Cloud sites)?
│
├─ YES → Use Experience Cloud Pattern:
│ • editor="c/componentName" on <property>
│ • @api value
│ • 'valuechange' event
│ • @api errors (received)
│
└─ NO → Is it for Flow Builder?
│
├─ YES → Use Flow Pattern:
│ • configurationEditor="c-component-name" on <targetConfig>
│ • @api inputVariables
│ • 'configuration_editor_input_value_changed' event
│ • @api validate() method
│
└─ NO → Standard LWC (no CPE needed)
Every Experience Cloud CPE MUST expose these six public properties:
import { LightningElement, api } from 'lwc';
export default class MyPropertyEditor extends LightningElement {
// ═══════════════════════════════════════════════════════════
// MANDATORY: These six properties form the CPE contract
// ═══════════════════════════════════════════════════════════
@api label; // String: Display label from XML
@api description; // String: Help text for property
@api required; // Boolean: Whether property is mandatory
@api value; // Any: Current property value (READ FROM HERE)
@api errors; // Array: Validation error objects [{message: '...'}]
@api schema; // Object: JSON Schema for complex types
}
To update property values, dispatch valuechange with both flags set to true:
handleChange(event) {
const newValue = event.target.value;
// CRITICAL: bubbles AND composed must be true
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true, // ← Required
composed: true // ← Required (crosses shadow boundary)
}));
}
The property editor component must have isExposed="false":
<!-- myPropertyEditor.js-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>false</isExposed> <!-- CPEs are NOT draggable -->
</LightningComponentBundle>
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>My Configurable Component</masterLabel>
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
<targetConfigs>
<targetConfig targets="lightningCommunity__Default">
<!-- Standard property (default UI) -->
<property name="title"
type="String"
label="Title"
default="Welcome"/>
<!-- Property with custom editor -->
<property name="alignment"
type="String"
label="Text Alignment"
editor="c/alignmentEditor"/>
<!-- Property using ExperiencePropertyTypeBundle -->
<property name="layoutConfig"
type="layoutStyleType"
label="Layout Settings"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
Once a component is used in a site or managed package, you CANNOT:
| Prohibited Change | Workaround |
|---|---|
Add required="true" to existing property | Create new optional property |
Remove existing <property> tag | Deprecate with default value |
| Change property type (Integer→String) | Create new property with correct type |
Remove default if required="true" | Keep default value |
Add min where none existed | Handle in CPE validation |
Reduce existing max value | Handle in CPE validation |
Best Practice: Finalize component design before packaging. Consider future extensibility.
┌────────────────────────────────────────────────────────────┐
│ bubbles: false, composed: false (DEFAULT - Most Restrictive)│
│ ───────────────────────────────────────────────────────────│
│ Event stays within component, doesn't cross shadow boundary │
│ Use for: Internal component events │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ bubbles: true, composed: false │
│ ───────────────────────────────────────────────────────────│
│ Event bubbles up but stops at shadow boundary │
│ Use for: Parent component communication (same shadow tree) │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ bubbles: true, composed: true (REQUIRED FOR CPE) │
│ ───────────────────────────────────────────────────────────│
│ Event bubbles up AND crosses shadow boundaries │
│ Use for: Experience Builder CPE valuechange events │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ bubbles: false, composed: true │
│ ───────────────────────────────────────────────────────────│
│ ⚠️ UNSUPPORTED on Salesforce Platform - Undefined behavior │
└────────────────────────────────────────────────────────────┘
Never pass @api or @wire data directly in events:
// ❌ BAD: Passing reactive data directly
handleSelect(event) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: this.someWiredData } // Read-only membrane issue!
}));
}
// ✅ GOOD: Copy data to new object
handleSelect(event) {
const valueCopy = JSON.parse(JSON.stringify(this.someWiredData));
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: valueCopy }
}));
}
Best for: Alignment pickers, icon selectors, color pickers
<!-- alignmentEditor.html -->
<template>
<div class="slds-form-element">
<label class="slds-form-element__label">{label}</label>
<div class="slds-form-element__control">
<lightning-button-group>
<lightning-button-icon
icon-name="utility:left_align_text"
data-value="left"
variant={leftVariant}
onclick={handleSelect}
alternative-text="Left">
</lightning-button-icon>
<lightning-button-icon
icon-name="utility:center_align_text"
data-value="center"
variant={centerVariant}
onclick={handleSelect}
alternative-text="Center">
</lightning-button-icon>
<lightning-button-icon
icon-name="utility:right_align_text"
data-value="right"
variant={rightVariant}
onclick={handleSelect}
alternative-text="Right">
</lightning-button-icon>
</lightning-button-group>
</div>
<template if:true={description}>
<div class="slds-form-element__help">{description}</div>
</template>
</div>
</template>
// alignmentEditor.js
import { LightningElement, api } from 'lwc';
export default class AlignmentEditor extends LightningElement {
@api label;
@api description;
@api required;
@api value = 'left';
@api errors;
@api schema;
get leftVariant() {
return this.value === 'left' ? 'brand' : 'border';
}
get centerVariant() {
return this.value === 'center' ? 'brand' : 'border';
}
get rightVariant() {
return this.value === 'right' ? 'brand' : 'border';
}
handleSelect(event) {
const newValue = event.currentTarget.dataset.value;
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
Best for: Reusable editors that adapt to different configurations
// schemaAwareEditor.js
import { LightningElement, api } from 'lwc';
export default class SchemaAwareEditor extends LightningElement {
@api label;
@api description;
@api required;
@api value;
@api errors;
@api schema;
get options() {
// Read allowed values from schema
if (this.schema?.enum) {
return this.schema.enum.map(val => ({
label: this.formatLabel(val),
value: val
}));
}
return [];
}
get minValue() {
return this.schema?.minimum ?? null;
}
get maxValue() {
return this.schema?.maximum ?? null;
}
formatLabel(value) {
// Convert camelCase/snake_case to Title Case
return value
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/^\w/, c => c.toUpperCase());
}
handleChange(event) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: event.target.value },
bubbles: true,
composed: true
}));
}
}
Best for: Complex components with many related properties
<!-- groupedPropertyEditor.html -->
<template>
<lightning-accordion allow-multiple-sections-open active-section-name={activeSections}>
<lightning-accordion-section name="appearance" label="Appearance">
<lightning-input
label="Background Color"
type="color"
value={backgroundColor}
onchange={handleColorChange}>
</lightning-input>
<!-- More appearance properties -->
</lightning-accordion-section>
<lightning-accordion-section name="layout" label="Layout">
<lightning-combobox
label="Alignment"
options={alignmentOptions}
value={alignment}
onchange={handleAlignmentChange}>
</lightning-combobox>
<!-- More layout properties -->
</lightning-accordion-section>
<lightning-accordion-section name="advanced" label="Advanced">
<!-- Advanced properties -->
</lightning-accordion-section>
</lightning-accordion>
</template>
Problem: CPEs are completely isolated from their target components. You cannot dynamically push data from the target component into the CPE.
// ❌ This will NOT work
// Target component trying to update CPE
this.template.querySelector('c-my-property-editor').customData = someData;
Solution: Use property defaults or accept that CPE only receives value from framework.
These are completely different APIs!
| Aspect | Experience Cloud CPE | Flow CPE |
|---|---|---|
| Value Source | @api value | @api inputVariables (array) |
| Value Update Event | valuechange | configuration_editor_input_value_changed |
| Event Detail | { value: newValue } | { name, newValue, newValueDataType } |
| Validation | @api errors | @api validate() method |
| Schema | @api schema | @api builderContext, @api genericTypeMappings |
Experience Cloud CPE:
handleChange(event) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: event.target.value },
bubbles: true,
composed: true
}));
}
Flow CPE:
handleChange(event) {
this.dispatchEvent(new CustomEvent('configuration_editor_input_value_changed', {
bubbles: true,
cancelable: false,
composed: true,
detail: {
name: 'myProperty',
newValue: event.target.value,
newValueDataType: 'String'
}
}));
}
Problem: Component references use kebab-case while file names use camelCase.
<!-- XML configuration -->
<property name="alignment" editor="c/alignmentPropertyEditor"/>
^^^^^^^^^^^^^^^^^^^^^^
camelCase (namespace/componentName)
force-app/main/default/lwc/
alignmentPropertyEditor/ ← Folder name: camelCase
alignmentPropertyEditor.js ← File name: camelCase
alignmentPropertyEditor.html
alignmentPropertyEditor.js-meta.xml
Problem: Complex types like arrays are not natively supported in property values.
Solution: Serialize to JSON strings:
// In CPE
get itemsArray() {
try {
return this.value ? JSON.parse(this.value) : [];
} catch (e) {
console.error('Failed to parse items:', e);
return [];
}
}
handleAddItem(event) {
const newItem = event.detail;
const updated = [...this.itemsArray, newItem];
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: JSON.stringify(updated) },
bubbles: true,
composed: true
}));
}
// In target component
@api itemsJson;
get items() {
try {
return this.itemsJson ? JSON.parse(this.itemsJson) : [];
} catch (e) {
return [];
}
}
Symptom: Value changes don't persist; Experience Builder doesn't receive updates.
Cause: Missing bubbles: true and/or composed: true on valuechange event.
Debugging:
handleChange(event) {
console.log('Dispatching valuechange with:', event.target.value);
const customEvent = new CustomEvent('valuechange', {
detail: { value: event.target.value },
bubbles: true, // Check this!
composed: true // Check this!
});
console.log('Event bubbles:', customEvent.bubbles);
console.log('Event composed:', customEvent.composed);
this.dispatchEvent(customEvent);
}
Problem: Components are "frozen" at publish time. Changes to component logic require republishing the entire site.
Impact:
Mitigation:
Problem: Only one custom property editor can be active per component instance.
Workaround: Create a single "master" CPE that handles all properties with tabs/accordion:
// masterPropertyEditor.js - handles multiple properties
export default class MasterPropertyEditor extends LightningElement {
@api label; // May contain combined label
@api value; // May contain JSON with multiple property values
@api schema; // May contain schema for all properties
get parsedValue() {
try {
return this.value ? JSON.parse(this.value) : {};
} catch {
return {};
}
}
handlePropertyChange(event) {
const propertyName = event.target.dataset.property;
const newValue = event.target.value;
const updated = {
...this.parsedValue,
[propertyName]: newValue
};
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: JSON.stringify(updated) },
bubbles: true,
composed: true
}));
}
}
Problem: You cannot show/hide properties based on other property values without a CPE.
Solution: Use CPE to implement conditional rendering:
<template>
<lightning-combobox
label="Display Type"
options={displayTypeOptions}
value={displayType}
onchange={handleDisplayTypeChange}>
</lightning-combobox>
<!-- Only show if displayType is 'custom' -->
<template if:true={showCustomOptions}>
<lightning-input
label="Custom Value"
value={customValue}
onchange={handleCustomValueChange}>
</lightning-input>
</template>
</template>
Problem: Apex VisualEditor.DynamicPickList works for lightningCommunity__Default but NOT for lightning__FlowScreen.
// This works for Experience Cloud, NOT for Flows
public class RecordTypePicklist extends VisualEditor.DynamicPickList {
VisualEditor.DesignTimePageContext context;
public RecordTypePicklist(VisualEditor.DesignTimePageContext context) {
this.context = context;
}
public override VisualEditor.DynamicPickListRows getValues() {
// Implementation
}
}
Problem: Special expressions like {!recordId} and {!objectApiName} work in property values but not within CPE logic.
What Works:
<!-- In Experience Builder property panel -->
<property name="recordId" type="String" expression="{!recordId}"/>
What Doesn't Work:
// CPE cannot access these expressions programmatically
// The value comes pre-resolved from the framework
Custom Lightning Types allow you to create reusable, complex property configurations with grouped sub-properties, built-in validation, and optional custom UI layouts. They're defined using the ExperiencePropertyTypeBundle metadata type.
force-app/main/default/
experiencePropertyTypeBundles/
myCustomType/
schema.json ← REQUIRED: Property structure & validation
design.json ← OPTIONAL: UI layout & editor overrides
| Use Custom Type | Use Standard Properties |
|---|---|
| Multiple related properties (borders, spacing) | Single isolated properties |
| Reusable configurations across components | Component-specific settings |
| Grouped UI (tabs/accordion) needed | Flat property list is fine |
| Complex validation rules | Simple required/min/max |
| Team standardization of property patterns | One-off implementations |
Salesforce provides these out-of-the-box lightning:type values for use in schema.json:
| Lightning Type | UI Editor | Data Type | Use For |
|---|---|---|---|
lightning__stringType | Text input | String | General text |
lightning__integerType | Number input | Integer | Whole numbers |
lightning__colorType | Color picker | String (hex) | Colors |
lightning__dateTimeType | Date/time picker | DateTime | Dates |
lightning__booleanType | Toggle/checkbox | Boolean | True/false |
lightning__urlType | URL input | String | Links |
lightning__textType | Text area | String | Long text |
lightning__richTextType | Rich text editor | String | Formatted text |
lightning__imageType | Image selector | String (URL) | Images |
lightning__iconType | Icon picker | String | SLDS icons |
The schema.json file defines property structure, types, and validation using JSON Schema specification with Salesforce extensions.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"propertyName": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Display Label",
"description": "Help text shown to admin",
"default": "defaultValue"
}
},
"required": ["propertyName"]
}
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Layout and Border Style",
"description": "Configure component layout and border styling",
"properties": {
"borderStyle": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Border Style",
"description": "Style of the component border",
"enum": ["none", "solid", "dashed", "dotted", "double"],
"default": "none"
},
"borderWidth": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Border Width",
"description": "Width in pixels (0-20)",
"minimum": 0,
"maximum": 20,
"default": 1
},
"borderColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"title": "Border Color",
"description": "Color of the border",
"default": "#cccccc"
},
"borderRadius": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Border Radius",
"description": "Corner roundness in pixels",
"minimum": 0,
"maximum": 50,
"default": 0
},
"layoutWidth": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Width",
"description": "Component width",
"enum": ["auto", "full", "half", "third", "quarter"],
"default": "auto"
},
"layoutHeight": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Height",
"description": "Fixed height in pixels (0 for auto)",
"minimum": 0,
"maximum": 1000,
"default": 0
},
"padding": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Padding",
"description": "Internal spacing in pixels",
"minimum": 0,
"maximum": 100,
"default": 16
},
"margin": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Margin",
"description": "External spacing in pixels",
"minimum": 0,
"maximum": 100,
"default": 0
},
"backgroundColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"title": "Background Color",
"description": "Component background color",
"default": "#ffffff"
}
},
"required": ["borderStyle", "layoutWidth"]
}
| Keyword | Purpose | Example |
|---|---|---|
type | JSON Schema data type | "string", "integer", "boolean", "object", "array" |
lightning:type | Salesforce UI editor | "lightning__colorType" |
title | Display label | "Border Width" |
description | Help text | "Width in pixels" |
default | Default value | 1 |
enum | Allowed values (picklist) | ["none", "solid", "dashed"] |
minimum | Min numeric value | 0 |
maximum | Max numeric value | 100 |
minLength | Min string length | 1 |
maxLength | Max string length | 255 |
pattern | Regex validation | "^https?://" |
required | Required properties | ["prop1", "prop2"] |
format | String format | "uri", "email", "date-time" |
The design.json file controls UI layout and can override default editors with custom property editors.
| Type | Description | Best For |
|---|---|---|
tabs | Horizontal tab navigation | 2-5 property groups |
accordion | Collapsible sections | Many groups, space-constrained |
vertical | Stacked properties | Simple linear layout |
| (default) | No grouping | Few properties |
{
"type": "tabs",
"properties": {
"Borders": ["borderStyle", "borderWidth", "borderColor", "borderRadius"],
"Size": ["layoutWidth", "layoutHeight"],
"Spacing": ["padding", "margin"],
"Colors": ["backgroundColor"]
}
}
{
"type": "accordion",
"properties": {
"Border Settings": ["borderStyle", "borderWidth", "borderColor", "borderRadius"],
"Layout Settings": ["layoutWidth", "layoutHeight", "padding", "margin"],
"Appearance": ["backgroundColor"]
}
}
{
"type": "vertical",
"properties": {
"": ["borderStyle", "borderWidth", "borderColor"]
}
}
Override default editors with custom property editor components:
{
"type": "tabs",
"properties": {
"Alignment": ["textAlignment"],
"Borders": ["borderStyle", "borderWidth", "borderColor"]
},
"propertyEditors": {
"textAlignment": "c/alignmentPropertyEditor",
"borderStyle": "c/borderStylePicker"
}
}
{
"type": "tabs",
"properties": {
"Layout": ["alignment", "spacing", "width"],
"Borders": ["borderStyle", "borderWidth", "borderColor", "borderRadius"],
"Colors": ["backgroundColor", "textColor"]
},
"propertyEditors": {
"alignment": "c/alignmentPropertyEditor",
"borderStyle": "c/borderStyleVisualPicker",
"backgroundColor": "c/colorPickerWithPreview",
"textColor": "c/colorPickerWithPreview"
}
}
When you create a custom type with only schema.json (no design.json), Salesforce provides default editors based on lightning:type.
experiencePropertyTypeBundles/
cardStyleType/
schema.json ← Only this file
schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"shadowDepth": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Shadow Depth",
"enum": ["none", "small", "medium", "large"],
"default": "small"
},
"roundedCorners": {
"type": "boolean",
"lightning:type": "lightning__booleanType",
"title": "Rounded Corners",
"default": true
},
"accentColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"title": "Accent Color",
"default": "#0070d2"
}
}
}
<targetConfigs>
<targetConfig targets="lightningCommunity__Default">
<property name="cardStyle"
type="cardStyleType"
label="Card Styling"/>
</targetConfig>
</targetConfigs>
import { LightningElement, api } from 'lwc';
export default class MyCard extends LightningElement {
@api cardStyle; // Receives object with all sub-properties
get shadowClass() {
const depth = this.cardStyle?.shadowDepth || 'small';
return `slds-box slds-box_${depth}`;
}
get hasRoundedCorners() {
return this.cardStyle?.roundedCorners !== false;
}
get accentStyle() {
const color = this.cardStyle?.accentColor || '#0070d2';
return `border-left: 4px solid ${color}`;
}
}
Add design.json to customize layout and/or override specific property editors.
Folder Structure:
experiencePropertyTypeBundles/
themeConfigType/
schema.json
design.json
lwc/
colorSchemeEditor/ ← Custom editor for colorScheme
fontFamilyPicker/ ← Custom editor for fontFamily
myThemedComponent/ ← Target component
schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Theme Configuration",
"properties": {
"colorScheme": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Color Scheme",
"enum": ["light", "dark", "brand", "custom"],
"default": "light"
},
"primaryColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"title": "Primary Color",
"default": "#0070d2"
},
"secondaryColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"title": "Secondary Color",
"default": "#706e6b"
},
"fontFamily": {
"type": "string",
"lightning:type": "lightning__stringType",
"title": "Font Family",
"enum": ["system", "salesforce-sans", "serif", "monospace"],
"default": "system"
},
"fontSize": {
"type": "integer",
"lightning:type": "lightning__integerType",
"title": "Base Font Size",
"minimum": 12,
"maximum": 24,
"default": 14
},
"enableAnimations": {
"type": "boolean",
"lightning:type": "lightning__booleanType",
"title": "Enable Animations",
"default": true
}
},
"required": ["colorScheme"]
}
design.json:
{
"type": "tabs",
"properties": {
"Colors": ["colorScheme", "primaryColor", "secondaryColor"],
"Typography": ["fontFamily", "fontSize"],
"Behavior": ["enableAnimations"]
},
"propertyEditors": {
"colorScheme": "c/colorSchemeEditor",
"fontFamily": "c/fontFamilyPicker"
}
}
Custom Editor - colorSchemeEditor.js:
import { LightningElement, api } from 'lwc';
export default class ColorSchemeEditor extends LightningElement {
@api label;
@api description;
@api required;
@api value = 'light';
@api errors;
@api schema;
schemes = [
{ value: 'light', label: 'Light', icon: 'utility:daylight', class: 'scheme-light' },
{ value: 'dark', label: 'Dark', icon: 'utility:night', class: 'scheme-dark' },
{ value: 'brand', label: 'Brand', icon: 'utility:palette', class: 'scheme-brand' },
{ value: 'custom', label: 'Custom', icon: 'utility:settings', class: 'scheme-custom' }
];
get schemesWithSelection() {
return this.schemes.map(s => ({
...s,
isSelected: s.value === this.value,
variant: s.value === this.value ? 'brand' : 'neutral'
}));
}
handleSelect(event) {
const newValue = event.currentTarget.dataset.value;
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
Target Component XML - myThemedComponent.js-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Themed Component</masterLabel>
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
<targetConfigs>
<targetConfig targets="lightningCommunity__Default">
<property name="title" type="String" label="Title" default="Welcome"/>
<property name="themeConfig" type="themeConfigType" label="Theme Settings"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
Custom types can contain nested objects for complex hierarchies:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"header": {
"type": "object",
"title": "Header Settings",
"properties": {
"backgroundColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"default": "#ffffff"
},
"height": {
"type": "integer",
"lightning:type": "lightning__integerType",
"minimum": 40,
"maximum": 200,
"default": 60
}
}
},
"footer": {
"type": "object",
"title": "Footer Settings",
"properties": {
"backgroundColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"default": "#f3f3f3"
},
"showSocialLinks": {
"type": "boolean",
"lightning:type": "lightning__booleanType",
"default": true
}
}
}
}
}
Problem: The folder name in experiencePropertyTypeBundles/ must exactly match what you reference in XML.
experiencePropertyTypeBundles/
layoutStyleType/ ← Folder name
schema.json
<!-- Must match folder name exactly (case-sensitive) -->
<property name="layout" type="layoutStyleType" label="Layout"/>
^^^^^^^^^^^^^^^^
Problem: Modifying schema.json after components are configured can invalidate existing configurations.
Safe Changes:
Breaking Changes:
Workaround: Version your types (e.g., layoutStyleTypeV2)
Problem: Properties referenced in design.json must exist in schema.json.
// schema.json
{
"properties": {
"borderWidth": { ... } // ← Defined here
}
}
// design.json
{
"properties": {
"Borders": ["borderwidth"] // ❌ WRONG: case mismatch
"Borders": ["borderWidth"] // ✅ CORRECT: exact match
}
}
Problem: If you use design.json, any schema property NOT included in a group is hidden from the UI.
// schema.json has: borderStyle, borderWidth, borderColor, margin
// design.json
{
"type": "tabs",
"properties": {
"Borders": ["borderStyle", "borderWidth", "borderColor"]
// margin is NOT included - it will be HIDDEN!
}
}
Solution: Always include ALL properties in design.json groups, or omit design.json entirely.
Problem: When overriding an editor within a custom type, your CPE's @api value receives only that sub-property's value, not the entire type object.
// For a type with borderStyle property:
export default class BorderStylePicker extends LightningElement {
@api value; // Receives just the borderStyle value ("solid"),
// NOT the full type object
}
Problem: Enum values are shown as-is unless you use enumNames (not always supported).
Workaround: Use a custom property editor for user-friendly display:
// schema.json
{
"borderStyle": {
"enum": ["none", "solid", "dashed"] // Technical values
}
}
// Use CPE to show friendly labels:
// "None", "Solid Line", "Dashed Line"
Problem: Default values must be valid for the specified type and constraints.
// ❌ BAD: default outside min/max range
{
"borderWidth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 0 // Invalid! Below minimum
}
}
// ✅ GOOD: default within range
{
"borderWidth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 1 // Valid
}
}
Problem: Custom types must be deployed BEFORE components that reference them.
Solution: Deploy in order:
experiencePropertyTypeBundles/lwc/ (custom editors)lwc/ (target components)Or use a single deployment with all metadata together.
Problem: While JSON Schema supports arrays, Experience Builder doesn't provide array editing UI.
Workaround: Use JSON string serialization:
{
"itemsJson": {
"type": "string",
"lightning:type": "lightning__textType",
"title": "Items (JSON)",
"description": "JSON array of items"
}
}
Then parse in your component or create a custom editor.
Problem: ExperiencePropertyTypeBundle works ONLY in Experience Builder, NOT in Lightning App Builder.
Workaround: For App Builder, use:
buttonStyleTypeV1, buttonStyleTypeV2| Feature | Experience Cloud CPE | Flow CPE |
|---|---|---|
| Value Access | @api value | @api inputVariables |
| Value Type | Direct value | Array of {name, value, valueDataType} |
| Update Event | valuechange | configuration_editor_input_value_changed |
| Delete Event | N/A | configuration_editor_input_value_deleted |
| Type Mapping | @api schema | @api genericTypeMappings |
| Context | @api schema | @api builderContext |
| Element Info | N/A | @api elementInfo |
| Validation | @api errors (from framework) | @api validate() (you implement) |
| Registration | editor="c/component" in XML | configurationEditor="c-component" |
| isExposed | false | false |
import { LightningElement, api } from 'lwc';
export default class FlowPropertyEditor extends LightningElement {
_inputVariables = [];
@api
get inputVariables() {
return this._inputVariables;
}
set inputVariables(variables) {
this._inputVariables = variables || [];
}
@api
get builderContext() {
return this._builderContext;
}
set builderContext(context) {
this._builderContext = context;
}
// Access specific input
get myPropertyValue() {
const param = this._inputVariables.find(
({ name }) => name === 'myProperty'
);
return param?.value;
}
// Validation method (called by Flow Builder)
@api
validate() {
const errors = [];
if (!this.myPropertyValue) {
errors.push({
key: 'myProperty',
errorString: 'This field is required'
});
}
return errors;
}
handleChange(event) {
this.dispatchEvent(new CustomEvent(
'configuration_editor_input_value_changed',
{
bubbles: true,
cancelable: false,
composed: true,
detail: {
name: 'myProperty',
newValue: event.target.value,
newValueDataType: 'String'
}
}
));
}
}
Experience Cloud CPEs receive validation errors through @api errors from the framework, but you can implement additional UI validation:
import { LightningElement, api, track } from 'lwc';
export default class ValidatingEditor extends LightningElement {
@api label;
@api description;
@api required;
@api value;
@api errors; // Framework-provided errors
@api schema;
@track localErrors = [];
// Combine framework and local errors
get allErrors() {
return [...(this.errors || []), ...this.localErrors];
}
get hasErrors() {
return this.allErrors.length > 0;
}
get errorMessage() {
return this.allErrors.map(e => e.message).join('; ');
}
validateValue(value) {
const errors = [];
// Required check
if (this.required && !value) {
errors.push({ message: `${this.label} is required` });
}
// Schema-based validation
if (this.schema) {
if (this.schema.minimum != null && value < this.schema.minimum) {
errors.push({ message: `Value must be at least ${this.schema.minimum}` });
}
if (this.schema.maximum != null && value > this.schema.maximum) {
errors.push({ message: `Value must be at most ${this.schema.maximum}` });
}
if (this.schema.pattern) {
const regex = new RegExp(this.schema.pattern);
if (!regex.test(value)) {
errors.push({ message: `Value must match pattern: ${this.schema.pattern}` });
}
}
}
return errors;
}
handleChange(event) {
const newValue = event.target.value;
// Validate locally
this.localErrors = this.validateValue(newValue);
// Still dispatch the value (let framework handle persistence)
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
import { LightningElement, api, track } from 'lwc';
export default class DebouncedEditor extends LightningElement {
@api label;
@api value;
@api errors;
@track localErrors = [];
_debounceTimer;
handleInput(event) {
const newValue = event.target.value;
// Clear previous timer
clearTimeout(this._debounceTimer);
// Debounce validation (300ms)
this._debounceTimer = setTimeout(() => {
this.localErrors = this.validateValue(newValue);
if (this.localErrors.length === 0) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}, 300);
}
disconnectedCallback() {
clearTimeout(this._debounceTimer);
}
}
Create reusable complex property types with dedicated schema and design files:
force-app/main/default/
experiencePropertyTypeBundles/
layoutStyleType/
schema.json ← Property definitions
design.json ← UI layout (tabs/accordion)
schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"padding": {
"type": "integer",
"lightning:type": "lightning__integerType",
"minimum": 0,
"maximum": 100,
"default": 16
},
"margin": {
"type": "integer",
"lightning:type": "lightning__integerType",
"minimum": 0,
"maximum": 100,
"default": 0
},
"borderStyle": {
"type": "string",
"lightning:type": "lightning__stringType",
"enum": ["none", "solid", "dashed", "dotted"],
"default": "none"
},
"borderWidth": {
"type": "integer",
"lightning:type": "lightning__integerType",
"minimum": 0,
"maximum": 10,
"default": 1
},
"borderColor": {
"type": "string",
"lightning:type": "lightning__colorType",
"default": "#cccccc"
},
"borderRadius": {
"type": "integer",
"lightning:type": "lightning__integerType",
"minimum": 0,
"maximum": 50,
"default": 0
}
}
}
design.json:
{
"type": "tabs",
"properties": {
"Spacing": ["padding", "margin"],
"Borders": ["borderStyle", "borderWidth", "borderColor", "borderRadius"]
}
}
Since complex types aren't fully supported, use JSON serialization:
// CPE handling complex nested data
export default class ComplexDataEditor extends LightningElement {
@api label;
@api value;
// Parse incoming JSON string
get parsedConfig() {
try {
return this.value ? JSON.parse(this.value) : {
items: [],
settings: {}
};
} catch (e) {
console.error('Parse error:', e);
return { items: [], settings: {} };
}
}
get items() {
return this.parsedConfig.items || [];
}
get settings() {
return this.parsedConfig.settings || {};
}
handleAddItem(event) {
const newItem = {
id: Date.now(),
label: event.detail.label,
value: event.detail.value
};
const updated = {
...this.parsedConfig,
items: [...this.items, newItem]
};
this.dispatchValueChange(updated);
}
handleRemoveItem(event) {
const idToRemove = event.target.dataset.id;
const updated = {
...this.parsedConfig,
items: this.items.filter(item => item.id !== parseInt(idToRemove))
};
this.dispatchValueChange(updated);
}
handleSettingChange(event) {
const settingName = event.target.dataset.setting;
const settingValue = event.target.value;
const updated = {
...this.parsedConfig,
settings: {
...this.settings,
[settingName]: settingValue
}
};
this.dispatchValueChange(updated);
}
dispatchValueChange(data) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: JSON.stringify(data) },
bubbles: true,
composed: true
}));
}
}
Create one CPE that adapts based on schema:
// universalPickerEditor.js
export default class UniversalPickerEditor extends LightningElement {
@api label;
@api value;
@api schema;
get pickerType() {
// Determine UI type from schema
if (this.schema?.format === 'color') return 'color';
if (this.schema?.format === 'date') return 'date';
if (this.schema?.enum) return 'combobox';
if (this.schema?.type === 'boolean') return 'checkbox';
return 'text';
}
get options() {
if (!this.schema?.enum) return [];
return this.schema.enum.map(val => ({
label: this.schema.enumLabels?.[val] || this.humanize(val),
value: val
}));
}
humanize(str) {
return str.replace(/([A-Z])/g, ' $1')
.replace(/[_-]/g, ' ')
.trim()
.replace(/^\w/, c => c.toUpperCase());
}
}
Define type once, use in multiple components:
<!-- Component A -->
<property name="styling" type="layoutStyleType" label="Layout"/>
<!-- Component B (reuses same type) -->
<property name="containerStyle" type="layoutStyleType" label="Container Layout"/>
Create a base class with common functionality:
// basePropertyEditor.js
import { LightningElement, api } from 'lwc';
export default class BasePropertyEditor extends LightningElement {
@api label;
@api description;
@api required;
@api value;
@api errors;
@api schema;
get hasErrors() {
return this.errors?.length > 0;
}
get errorMessage() {
return this.hasErrors ? this.errors[0].message : '';
}
get isRequired() {
return this.required === true || this.required === 'true';
}
dispatchValue(newValue) {
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
// colorPickerEditor.js - extends base
import BasePropertyEditor from 'c/basePropertyEditor';
export default class ColorPickerEditor extends BasePropertyEditor {
handleColorChange(event) {
this.dispatchValue(event.target.value);
}
}
Note: LWC inheritance has limitations. Consider composition over inheritance.
const DEBUG = true;
export default class DebugPropertyEditor extends LightningElement {
@api
get value() {
return this._value;
}
set value(val) {
if (DEBUG) console.log('[CPE] Received value:', val);
this._value = val;
}
_value;
@api
get errors() {
return this._errors;
}
set errors(val) {
if (DEBUG) console.log('[CPE] Received errors:', val);
this._errors = val;
}
_errors;
@api
get schema() {
return this._schema;
}
set schema(val) {
if (DEBUG) console.log('[CPE] Received schema:', JSON.stringify(val, null, 2));
this._schema = val;
}
_schema;
handleChange(event) {
const newValue = event.target.value;
if (DEBUG) console.log('[CPE] Dispatching valuechange:', newValue);
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
| Symptom | Check |
|---|---|
| CPE not appearing | editor attribute in XML correct? |
| Values not persisting | bubbles: true, composed: true? |
| Errors not showing | Check @api errors property exists |
| Schema empty | Verify ExperiencePropertyTypeBundle deployed |
| CPE shows "undefined" | Check @api value default |
alignmentPropertyEditor.html:
<template>
<div class="slds-form-element" data-testid="alignment-editor">
<label class="slds-form-element__label">
<template if:true={isRequired}>
<abbr class="slds-required" title="required">*</abbr>
</template>
{label}
</label>
<div class="slds-form-element__control">
<lightning-button-group>
<lightning-button-icon
icon-name="utility:left_align_text"
data-value="left"
variant={leftVariant}
onclick={handleSelect}
alternative-text="Left align"
class="alignment-button">
</lightning-button-icon>
<lightning-button-icon
icon-name="utility:center_align_text"
data-value="center"
variant={centerVariant}
onclick={handleSelect}
alternative-text="Center align"
class="alignment-button">
</lightning-button-icon>
<lightning-button-icon
icon-name="utility:right_align_text"
data-value="right"
variant={rightVariant}
onclick={handleSelect}
alternative-text="Right align"
class="alignment-button">
</lightning-button-icon>
<lightning-button-icon
icon-name="utility:justify_text"
data-value="justify"
variant={justifyVariant}
onclick={handleSelect}
alternative-text="Justify"
class="alignment-button">
</lightning-button-icon>
</lightning-button-group>
</div>
<template if:true={hasErrors}>
<div class="slds-form-element__help slds-text-color_error" role="alert">
{errorMessage}
</div>
</template>
<template if:true={description}>
<div class="slds-form-element__help">{description}</div>
</template>
</div>
</template>
alignmentPropertyEditor.js:
import { LightningElement, api } from 'lwc';
export default class AlignmentPropertyEditor extends LightningElement {
// ══════════════════════════════════════════════════════════════
// Property Editor Contract - All six @api properties required
// ══════════════════════════════════════════════════════════════
@api label = 'Alignment';
@api description;
@api required = false;
@api value = 'left';
@api errors;
@api schema;
// ══════════════════════════════════════════════════════════════
// Computed Properties
// ══════════════════════════════════════════════════════════════
get isRequired() {
return this.required === true || this.required === 'true';
}
get hasErrors() {
return Array.isArray(this.errors) && this.errors.length > 0;
}
get errorMessage() {
return this.hasErrors ? this.errors.map(e => e.message).join(', ') : '';
}
get leftVariant() {
return this.value === 'left' ? 'brand' : 'border';
}
get centerVariant() {
return this.value === 'center' ? 'brand' : 'border';
}
get rightVariant() {
return this.value === 'right' ? 'brand' : 'border';
}
get justifyVariant() {
return this.value === 'justify' ? 'brand' : 'border';
}
// ══════════════════════════════════════════════════════════════
// Event Handlers
// ══════════════════════════════════════════════════════════════
handleSelect(event) {
const newValue = event.currentTarget.dataset.value;
// Don't dispatch if value unchanged
if (newValue === this.value) {
return;
}
// CRITICAL: Both bubbles and composed must be true
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
}
}
alignmentPropertyEditor.css:
.alignment-button {
margin: 0 2px;
}
.slds-form-element__help {
margin-top: 0.25rem;
font-size: 0.75rem;
}
alignmentPropertyEditor.js-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>false</isExposed>
<description>Custom property editor for text alignment selection</description>
</LightningComponentBundle>
□ Six @api properties: label, description, required, value, errors, schema
□ isExposed="false" in js-meta.xml
□ editor="c/componentName" in target component XML
□ valuechange event with bubbles:true AND composed:true
□ Handle null/undefined value with defaults
□ Copy data before passing in event detail (avoid reactive data)
□ Test in Experience Builder property panel
□ Verify values persist after page refresh
this.dispatchEvent(new CustomEvent('valuechange', {
detail: { value: /* your value */ },
bubbles: true,
composed: true
}));
Document Version History: