Loopar Framework allows you to create custom components that integrate seamlessly with the designer and form system. This guide covers the structure, best practices, and advanced features for building custom components.
Component Structure
Custom components in Loopar follow a specific structure to ensure compatibility with the framework.
Basic Component Template
import { Droppable } from "@droppable" ;
import { useDesigner } from "@context/@/designer-context" ;
import { ComponentDefaults } from "./base/ComponentDefaults" ;
export default function MyCustomComponent ( props ) {
const { set , data } = ComponentDefaults ( props );
const { designerMode } = useDesigner ();
return (
< div className = "my-custom-component" >
< h3 > { data . label || "My Component" } </ h3 >
< Droppable { ... props } />
</ div >
);
}
// Configure component properties
MyCustomComponent . droppable = true ;
MyCustomComponent . metaFields = () => {
return [
{
group: "custom" ,
elements: {
label: {
element: "input" ,
data: {
label: "Label" ,
name: "label"
}
}
}
}
];
};
Base Classes
Class-Based Components
For more complex components, extend the base Component class:
packages/loopar/src/components/base/component.jsx
import Component from "./base/component" ;
export default class MyComponent extends Component {
get droppable () { return true } ;
get draggable () { return true } ;
constructor ( props ) {
super ( props );
this . state = {
data: props . data ,
customState: null
};
}
render () {
const { label } = this . data ;
return (
< div className = "my-component" >
< h3 > { label } </ h3 >
{ this . props . children }
</ div >
);
}
// Lifecycle methods
onMount () {
// Called when component mounts
console . log ( "Component mounted" );
}
onUpdate () {
// Called when component updates
console . log ( "Component updated" );
}
// Helper methods available from base class
get identifier () {
const { key , id , name } = this . data ;
return key ?? id ?? name ?? elementManage . getUniqueKey ();
}
getSrc () {
// Get mapped file sources
return fileManager . getMappedFiles (
this . data . background_image ,
this . data . name
);
}
set ( key , value ) {
// Update component data
let data = this . data ;
if ( typeof key == "object" ) {
Object . assign ( data , key );
} else {
data [ key ] = value ;
}
loopar . Designer ?. updateElement ( data . key , data );
}
}
ComponentDefaults Helper
The ComponentDefaults helper provides common functionality for functional components:
packages/loopar/src/components/base/ComponentDefaults.jsx
import { ComponentDefaults } from "./base/ComponentDefaults" ;
export default function MyComponent ( props ) {
const {
getSrc , // Get background image sources
getTextSize , // Get text size class
getTextAlign , // Get text alignment class
set , // Update component data
setElements , // Update child elements
getSize , // Get size class
data // Component data
} = ComponentDefaults ( props );
// Use the helpers
const textSizeClass = getTextSize ( "lg" ); // Returns "text-lg"
const alignClass = getTextAlign ( "center" ); // Returns "text-center"
return (
< div className = { ` ${ textSizeClass } ${ alignClass } ` } >
{ data . content }
</ div >
);
}
Create custom form input components by extending BaseInput:
import BaseInput from "@base-input" ;
import { FormLabel , invalidClass } from "./input/index.js" ;
import { FormControl , FormDescription } from "@cn/components/ui/form" ;
export default function CustomInput ( props ) {
const { renderInput , data } = BaseInput ( props );
return renderInput (( field ) => {
return (
<>
< FormLabel { ... props } field = { field } />
< FormControl >
< input
{ ... field }
placeholder = { data . placeholder || data . label }
className = { field . isInvalid ? invalidClass . border : "" }
// Your custom input implementation
/>
</ FormControl >
{ data . description && (
< FormDescription > { data . description } </ FormDescription >
) }
</>
);
});
}
CustomInput . droppable = false ;
CustomInput . metaFields = () => {
return [
... BaseInput . metaFields (),
[
{
group: "custom" ,
elements: {
custom_prop: {
element: "input" ,
data: {
label: "Custom Property" ,
name: "custom_prop"
}
}
}
}
]
];
};
Component Properties
Static Properties
Whether the component can contain child elements
Whether the component can be dragged in the designer
Array of required child element types MetaTabs . requires = [ "tab" ]
Meta fields to exclude from the designer Col . dontHaveMetaElements = [ "label" , "text" ]
CSS classes to apply in designer mode MetaBanner . designerClasses = "h-full w-full p-3 py-6"
Define configurable properties for the designer:
MyComponent . metaFields = () => {
return [
{
group: "layout" ,
elements: {
width: {
element: "select" ,
data: {
label: "Width" ,
options: [ "auto" , "full" , "1/2" , "1/3" , "1/4" ],
default_value: "auto"
}
},
height: {
element: "input" ,
data: {
label: "Height" ,
format: "int" ,
description: "Height in pixels"
}
}
}
},
{
group: "style" ,
elements: {
background_color: {
element: "color_picker" ,
data: {
label: "Background Color"
}
},
text_color: {
element: "color_picker" ,
data: {
label: "Text Color"
}
}
}
}
];
};
Designer Integration
Using Designer Context
import { useDesigner } from "@context/@/designer-context" ;
export default function MyComponent ( props ) {
const {
designerMode , // Boolean: is designer active
designing , // Boolean: is component being edited
isDesigner , // Boolean: is user a designer
handleEditElement , // Function: edit element
handleDeleteElement , // Function: delete element
updateElement , // Function: update element data
updateElements // Function: update child elements
} = useDesigner ();
const handleClick = () => {
if ( designerMode ) {
handleEditElement ( props . data . key );
} else {
// Normal click behavior
}
};
return (
< div onClick = { handleClick } >
{ /* Component content */ }
</ div >
);
}
Conditional Rendering
export default function MyComponent ( props ) {
const { designerMode , designerModeType } = useDesigner ();
// Show different UI in designer vs. preview/runtime
if ( designerMode && designerModeType === "edit" ) {
return (
< div className = "designer-placeholder" >
< p > Component: { props . data . label } </ p >
< p > Click to edit </ p >
</ div >
);
}
return (
< div className = "runtime-component" >
{ /* Actual component rendering */ }
</ div >
);
}
Advanced Patterns
Preassembled Components
For components with default child elements:
import Preassembled from "@preassembled" ;
export default function MyPreassembledComponent ( props ) {
const data = props . data || {};
const defaultElements = [
{
element: "title" ,
data: {
key: data . key + "-title" ,
text: data . label || "Default Title" ,
size: "3xl"
}
},
{
element: "paragraph" ,
data: {
key: data . key + "-content" ,
text: data . text || "Default content"
}
}
];
return (
< Preassembled
{ ... props }
defaultElements = { defaultElements }
notDroppable = { false }
>
{ /* Your component implementation */ }
</ Preassembled >
);
}
Context Providers
Create context for sharing state between parent and child components:
import React , { createContext , useContext } from "react" ;
const MyComponentContext = createContext ();
export const MyComponentProvider = ({ children , value }) => {
return (
< MyComponentContext.Provider value = { value } >
{ children }
</ MyComponentContext.Provider >
);
};
export const useMyComponent = () => {
return useContext ( MyComponentContext );
};
// Usage in parent component
export default function ParentComponent ( props ) {
return (
< MyComponentProvider value = { { someProp: "value" } } >
< Droppable { ... props } />
</ MyComponentProvider >
);
}
// Usage in child component
export default function ChildComponent ( props ) {
const { someProp } = useMyComponent ();
return < div > { someProp } </ div > ;
}
Dynamic Columns (Like Row Component)
import { useState , useEffect , useCallback } from "react" ;
import elementManage from "@@tools/element-manage" ;
export default function DynamicGrid ( props ) {
const { setElements } = ComponentDefaults ( props );
const [ cols , setCols ] = useState ( props . elements || []);
const [ layout , setLayout ] = useState ([ 50 , 50 ]);
const conciliateCols = useCallback (() => {
if ( cols . length < layout . length ) {
const diff = layout . length - cols . length ;
const addCols = [ ... cols ];
for ( let i = 0 ; i < diff ; i ++ ) {
addCols . push ({
element: "col" ,
data: { key: elementManage . getUniqueKey () }
});
}
setElements ( addCols );
setCols ( addCols );
}
}, [ layout , cols , setElements ]);
useEffect (() => {
conciliateCols ();
}, [ layout ]);
return (
< div className = "grid" style = { { gridTemplateColumns: layout . map ( l => ` ${ l } %` ). join ( " " ) } } >
{ cols . map (( col , idx ) => (
< div key = { idx } >
{ /* Render column */ }
</ div >
)) }
</ div >
);
}
Registration
Add to Element Definition
Register your component in the element definition system:
packages/loopar/core/global/element-definition.js
export const elementsDefinition = {
// ... existing elements
[ DESIGN_ELEMENT ]: [
// ... existing design elements
{
element: "my_custom_component" ,
icon: "Star" ,
type: TYPES . text ,
designerOnly: false ,
clientOnly: false
},
],
}
Component File Location
Place your component file in:
packages/loopar/src/components/my-custom-component.jsx
Best Practices
Keep state as close to where it’s used as possible
Use context sparingly (only for truly global state)
Prefer props drilling for 2-3 levels
Use ComponentDefaults helper for common state
Use Tailwind CSS classes for consistency
Follow the framework’s design system
Use cn() utility for conditional classes
Provide customization through data.class prop
Implement validation for form components
Use the dataInterface class for common validators
Provide clear error messages
Support both client and server-side validation
Testing Custom Components
// Example test using Jest and React Testing Library
import { render , screen } from "@testing-library/react" ;
import MyCustomComponent from "./my-custom-component" ;
describe ( "MyCustomComponent" , () => {
it ( "renders with label" , () => {
const props = {
data: {
key: "test-component" ,
label: "Test Label"
}
};
render ( < MyCustomComponent { ... props } /> );
expect ( screen . getByText ( "Test Label" )). toBeInTheDocument ();
});
it ( "updates when data changes" , () => {
const { rerender } = render (
< MyCustomComponent data = { { label: "Old" } } />
);
expect ( screen . getByText ( "Old" )). toBeInTheDocument ();
rerender ( < MyCustomComponent data = { { label: "New" } } /> );
expect ( screen . getByText ( "New" )). toBeInTheDocument ();
});
});
Example: Complete Custom Component
Here’s a complete example of a custom rating component:
import { useState } from "react" ;
import BaseInput from "@base-input" ;
import { FormLabel } from "./input/index.js" ;
import { Star } from "lucide-react" ;
export default function RatingInput ( props ) {
const { renderInput , data } = BaseInput ( props );
const maxStars = data . max_stars || 5 ;
return renderInput (( field ) => {
const [ hover , setHover ] = useState ( 0 );
const currentRating = parseInt ( field . value ) || 0 ;
return (
<>
< FormLabel { ... props } field = { field } />
< div className = "flex gap-1" >
{ [ ... Array ( maxStars )]. map (( _ , idx ) => {
const starValue = idx + 1 ;
return (
< Star
key = { idx }
size = { 24 }
className = { `cursor-pointer transition-colors ${
starValue <= ( hover || currentRating )
? "fill-yellow-400 text-yellow-400"
: "text-gray-300"
} ` }
onClick = { () => field . onChange ({ target: { value: starValue } }) }
onMouseEnter = { () => setHover ( starValue ) }
onMouseLeave = { () => setHover ( 0 ) }
/>
);
}) }
</ div >
{ data . description && (
< p className = "text-sm text-gray-500" > { data . description } </ p >
) }
</>
);
});
}
RatingInput . droppable = false ;
RatingInput . metaFields = () => {
return [
... BaseInput . metaFields (),
[
{
group: "custom" ,
elements: {
max_stars: {
element: "input" ,
data: {
label: "Max Stars" ,
format: "int" ,
default_value: 5 ,
min: 1 ,
max: 10
}
}
}
}
]
];
};
Next Steps
Component API Explore the full component API reference
Designer API Learn about designer integration
Examples Browse component examples
Contributing Contribute your components to Loopar